feat(tauri-app): Notifications-View mit Badge, Follower-Listen, DID-Navigation
Bindet die neuen AppView-Endpoints an: sechs IPC-Commands
(fetch_notifications, notification_count, mark_notifications_seen,
fetch_followers, fetch_following, fetch_thread) plus profile_get_by_did,
dazu die TS-Gegenstücke.
* NotificationsView: Liste mit Icon/Text je Art, Avatar, Vorschau des
subject_text, Cursor-Pagination; Klick auf eine Zeile mit Subject öffnet
den Thread. Beim Öffnen wird mit dem indexed_at der obersten Zeile als
Wasserzeichen quittiert.
* NavRail: Eintrag mit Unread-Badge, gepollt im vorhandenen 5s-Timer und
über den bestehenden Teardown-Pfad abgeräumt; der Poll pausiert, solange
die Liste offen ist.
* ProfileView: Follower- und Following-Zahlen sind jetzt Buttons und öffnen
die jeweilige Liste inline.
Navigiert wird über die DID, nicht über den Handle: für Actors, die weder
profiles noch posts kennen, liefert die AppView einen abgeschnittenen
Platzhalter im handle-Feld ("did:plc:abcd…"), und ein Klick darauf landete
in einem synthetischen Leerprofil. Die echte DID steht im DTO und wird jetzt
explizit durchgereicht — keine Heuristik auf das Platzhalter-Format.
Dabei aufgefallen und mitgefixt: ProfileView lud nur in onMount, obwohl die
Komponente bei Profil-zu-Profil-Navigation gemountet bleibt — Posts und
Zahlen des vorherigen Nutzers wären unter dem neuen Namen stehengeblieben.
Jetzt ein auf (did, handle) gekeyter Effekt.
Das Thread-Overlay hing im else-Zweig der leeren Timeline und wäre aus dem
Notifications-View unsichtbar gewesen; es ist jetzt ein Snippet, das beide
Views rendern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
co-authored by
Claude Opus 5
parent
bce4c7862f
commit
31880e1005
@@ -370,6 +370,24 @@ export async function fetchProfile(handle: string): Promise<ProfileResponse> {
|
||||
return await safeInvoke<ProfileResponse>("profile_get", { handle });
|
||||
}
|
||||
|
||||
/// Resolve a profile by DID (`GET /api/profile?did=…`) instead of by
|
||||
/// handle.
|
||||
///
|
||||
/// Use this — not [`fetchProfile`] — whenever the actor came out of a
|
||||
/// notification or a follower/following list. The AppView synthesises
|
||||
/// a placeholder `handle` for actors it has neither a profile row nor
|
||||
/// an indexed post for: a truncated DID with an ellipsis, e.g.
|
||||
/// `"did:plc:f5…"`. That string matches nothing on the way back in, and
|
||||
/// the server answers with a synthetic empty profile (`did: ""`, zero
|
||||
/// posts) rather than an error — so a handle-based navigation from
|
||||
/// those lists is a silent dead end. The DID in the DTO is always
|
||||
/// real, so navigate by that.
|
||||
export async function fetchProfileByDid(
|
||||
did: string,
|
||||
): Promise<ProfileResponse> {
|
||||
return await safeInvoke<ProfileResponse>("profile_get_by_did", { did });
|
||||
}
|
||||
|
||||
export async function fetchSearch(
|
||||
q: string,
|
||||
limit: number = 30,
|
||||
@@ -381,6 +399,189 @@ export async function fetchPost(uri: string): Promise<ThreadResponse> {
|
||||
return await safeInvoke<ThreadResponse>("post_get", { uri });
|
||||
}
|
||||
|
||||
/// `GET /api/thread?uri=…` — the *full* thread around a post: the
|
||||
/// entire ancestor chain plus the direct replies, in one round trip.
|
||||
///
|
||||
/// `parents` is ordered root-first (so `parents[parents.length - 1]`
|
||||
/// is the immediate parent) and `replies` oldest-first. A `parents[0]`
|
||||
/// whose own `parent_uri` is non-null means "the chain continues
|
||||
/// above but wasn't loaded" — the walk is depth-limited server-side.
|
||||
///
|
||||
/// `post` is `null` when the URI isn't in the index; the counters are
|
||||
/// then absent too, so a single field check renders "post not found".
|
||||
/// `viewer_liked` / `viewer_reposted` are `undefined` when no
|
||||
/// `viewerDid` was passed — that means "unknown", not "false".
|
||||
export type ThreadFullResponse = {
|
||||
post: Post | null;
|
||||
parents: Post[];
|
||||
root: Post | null;
|
||||
replies: Post[];
|
||||
like_count?: number;
|
||||
repost_count?: number;
|
||||
viewer_liked?: boolean;
|
||||
viewer_reposted?: boolean;
|
||||
};
|
||||
|
||||
export async function fetchThread(
|
||||
uri: string,
|
||||
viewerDid: string | null = null,
|
||||
): Promise<ThreadFullResponse> {
|
||||
return await safeInvoke<ThreadFullResponse>("fetch_thread", {
|
||||
uri,
|
||||
viewerDid,
|
||||
});
|
||||
}
|
||||
|
||||
/// One row of `GET /api/notifications`, hydrated server-side with the
|
||||
/// author's profile and the subject post's text so the list renders
|
||||
/// without any follow-up fetch.
|
||||
///
|
||||
/// `kind` is typed as the four known values plus `string` so an
|
||||
/// unknown kind added upstream still type-checks here; the UI's
|
||||
/// `notificationText` falls back to a generic line.
|
||||
export type NotificationKind = "like" | "repost" | "follow" | "reply";
|
||||
|
||||
export type Notification = {
|
||||
id: number;
|
||||
kind: NotificationKind | string;
|
||||
author_did: string;
|
||||
/// Without a leading `@` (the UI renders `@{handle}`). Never null —
|
||||
/// the AppView falls back to a truncated DID.
|
||||
author_handle: string;
|
||||
author_display_name?: string | null;
|
||||
author_avatar_cid?: string | null;
|
||||
/// The post this is about. `null` for `"follow"`. For
|
||||
/// `"like"`/`"repost"` it's the recipient's own post; for `"reply"`
|
||||
/// it's the reply itself.
|
||||
subject_uri: string | null;
|
||||
subject_text?: string | null;
|
||||
created_at: string;
|
||||
/// When the AppView indexed it — the list's sort key and the value
|
||||
/// to echo back as `seenAt`.
|
||||
indexed_at: string;
|
||||
/// `null` while unread.
|
||||
read_at: string | null;
|
||||
};
|
||||
|
||||
export type NotificationsResponse = {
|
||||
notifications: Notification[];
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
/// German UI copy for a notification row. Kept next to the type (and
|
||||
/// exported) so the mapping is unit-testable without mounting a
|
||||
/// component. An unrecognised `kind` gets a neutral fallback rather
|
||||
/// than an empty line.
|
||||
export function notificationText(kind: string): string {
|
||||
switch (kind) {
|
||||
case "like":
|
||||
return "hat deinen Post geliked";
|
||||
case "repost":
|
||||
return "hat repostet";
|
||||
case "follow":
|
||||
return "folgt dir jetzt";
|
||||
case "reply":
|
||||
return "hat geantwortet";
|
||||
default:
|
||||
return "hat interagiert";
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-glyph icon for a notification row, same switch as
|
||||
/// [`notificationText`]. Monospace glyphs rather than SVGs so the
|
||||
/// list keeps the terminal look of the rest of the UI.
|
||||
export function notificationIcon(kind: string): string {
|
||||
switch (kind) {
|
||||
case "like":
|
||||
return "♥";
|
||||
case "repost":
|
||||
return "⇄";
|
||||
case "follow":
|
||||
return "+";
|
||||
case "reply":
|
||||
return "↩";
|
||||
default:
|
||||
return "•";
|
||||
}
|
||||
}
|
||||
|
||||
/// One page of notifications, newest first. Same opaque-cursor
|
||||
/// contract as [`fetchTimeline`]: pass the previous response's
|
||||
/// `cursor` to page down, and `cursor === null` means end of list.
|
||||
export async function fetchNotifications(
|
||||
did: string,
|
||||
cursor: string | null = null,
|
||||
limit: number = 30,
|
||||
): Promise<NotificationsResponse> {
|
||||
return await safeInvoke<NotificationsResponse>("fetch_notifications", {
|
||||
did,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
/// Unread-notification count for the NavRail badge. Cheap enough to
|
||||
/// poll on the same cadence as the timeline refresh.
|
||||
export async function notificationCount(did: string): Promise<number> {
|
||||
const r = await safeInvoke<{ count: number }>("notification_count", { did });
|
||||
return r.count;
|
||||
}
|
||||
|
||||
/// Mark every notification indexed at or before `seenAt` as read.
|
||||
/// Pass the `indexed_at` of the topmost row the user actually sees, so
|
||||
/// a notification arriving mid-scroll isn't swallowed. `null` marks
|
||||
/// everything currently unread. Idempotent — a repeat call reports
|
||||
/// `updated: 0`.
|
||||
export async function markNotificationsSeen(
|
||||
did: string,
|
||||
seenAt: string | null = null,
|
||||
): Promise<{ ok: boolean; updated: number }> {
|
||||
return await safeInvoke<{ ok: boolean; updated: number }>(
|
||||
"mark_notifications_seen",
|
||||
{ did, seenAt },
|
||||
);
|
||||
}
|
||||
|
||||
/// A minimal profile card from `GET /api/followers` / `/api/following`.
|
||||
/// Deliberately not a full `ProfileResponse` — a page of 30 followers
|
||||
/// would otherwise be 30 post queries server-side. Click a row and the
|
||||
/// UI navigates to the full profile by `handle`.
|
||||
export type ActorProfile = {
|
||||
did: string;
|
||||
handle: string;
|
||||
display_name?: string | null;
|
||||
avatar_cid?: string | null;
|
||||
};
|
||||
|
||||
export type ActorListResponse = {
|
||||
profiles: ActorProfile[];
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export async function fetchFollowers(
|
||||
did: string,
|
||||
cursor: string | null = null,
|
||||
limit: number = 30,
|
||||
): Promise<ActorListResponse> {
|
||||
return await safeInvoke<ActorListResponse>("fetch_followers", {
|
||||
did,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchFollowing(
|
||||
did: string,
|
||||
cursor: string | null = null,
|
||||
limit: number = 30,
|
||||
): Promise<ActorListResponse> {
|
||||
return await safeInvoke<ActorListResponse>("fetch_following", {
|
||||
did,
|
||||
cursor,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
/// `app.bsky.feed.like.create` — Tauri command. Builds the
|
||||
/// flat-shape like body on the Rust side, signs a commit, pushes
|
||||
/// to the AppView. Returns the new like's `uri` and `cid`.
|
||||
|
||||
Reference in New Issue
Block a user