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:
tomdebone
2026-09-09 21:37:00 +02:00
co-authored by Claude Opus 5
parent bce4c7862f
commit 31880e1005
12 changed files with 2241 additions and 48 deletions
+201
View File
@@ -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`.