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: Eifelduds <noreply@eifelduds.de>
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 Eifelduds
parent 8db4704f8d
commit f281914899
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`.
@@ -0,0 +1,368 @@
// Unit tests for the notification / actor-list half of `client.ts`.
//
// Same setup as `client.test.ts`: `@tauri-apps/api/core` is mocked so
// no Tauri shell is needed, and every assertion is about the exact
// command name + argument bag we hand the Rust IPC layer. That
// argument bag is the contract — Tauri's `invoke` serialises camelCase
// JS keys to the snake_case Rust command parameters, so a typo here
// surfaces at runtime as "command not found" or a null argument, not
// at compile time.
//
// Covered:
// * `fetchNotifications` — command name, args, wire→object mapping
// (snake_case fields pass through verbatim), cursor pagination;
// * `notificationCount` — unwraps `{ count }` to a number;
// * `markNotificationsSeen` — sends the `seenAt` watermark;
// * `notificationText` / `notificationIcon` — the kind→copy map,
// including the fallback for an unknown kind;
// * `fetchFollowers` / `fetchFollowing` — distinct commands, same
// shape.
//
// Run with:
// npx vitest run src/lib/api/notifications.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const invokeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
isTauri: () => true,
}));
beforeEach(() => {
invokeMock.mockReset();
});
/// A full-fat wire row, exactly as `NotificationItem` serialises it
/// server-side (snake_case, `read_at` present even when null).
function wireNotification(over: Record<string, unknown> = {}) {
return {
id: 1,
kind: "like",
author_did: "did:plc:alice",
author_handle: "alice.test",
author_display_name: "Alice",
author_avatar_cid: "bafyavatar",
subject_uri: "at://did:plc:me/app.twi.post/3k2",
subject_text: "hello world",
created_at: "2026-09-09T10:00:00Z",
indexed_at: "2026-09-09T10:00:01Z",
read_at: null,
...over,
};
}
describe("fetchNotifications", () => {
it("invokes fetch_notifications and maps the wire rows verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [wireNotification()],
cursor: "cur1",
});
const r = await fetchNotifications("did:plc:me");
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.cursor).toBe("cur1");
expect(r.notifications).toHaveLength(1);
const n = r.notifications[0];
expect(n.id).toBe(1);
expect(n.kind).toBe("like");
expect(n.author_handle).toBe("alice.test");
expect(n.author_display_name).toBe("Alice");
expect(n.author_avatar_cid).toBe("bafyavatar");
expect(n.subject_uri).toBe("at://did:plc:me/app.twi.post/3k2");
expect(n.subject_text).toBe("hello world");
expect(n.indexed_at).toBe("2026-09-09T10:00:01Z");
// `read_at: null` is the unread marker the badge keys off — it
// must survive as null, not become undefined.
expect(n.read_at).toBeNull();
});
it("forwards the cursor and limit for a follow-up page", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({ notifications: [], cursor: null });
const r = await fetchNotifications("did:plc:me", "cur1", 50);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: "cur1",
limit: 50,
});
// A null cursor is the documented end-of-list sentinel.
expect(r.cursor).toBeNull();
});
it("handles a follow row (no subject) without inventing fields", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [
{
id: 7,
kind: "follow",
author_did: "did:plc:bob",
author_handle: "bob.test",
subject_uri: null,
created_at: "2026-09-09T09:00:00Z",
indexed_at: "2026-09-09T09:00:01Z",
read_at: "2026-09-09T09:30:00Z",
},
],
cursor: null,
});
const [n] = (await fetchNotifications("did:plc:me")).notifications;
expect(n.kind).toBe("follow");
expect(n.subject_uri).toBeNull();
expect(n.subject_text).toBeUndefined();
expect(n.author_display_name).toBeUndefined();
expect(n.read_at).toBe("2026-09-09T09:30:00Z");
});
it("propagates a Tauri-side error verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockRejectedValueOnce(
new Error("appview: notifications returned 400: did is required"),
);
await expect(fetchNotifications("")).rejects.toThrow(
/notifications returned 400/,
);
});
});
describe("notificationCount", () => {
it("unwraps the {count} envelope to a plain number", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 12 });
const n = await notificationCount("did:plc:me");
expect(n).toBe(12);
expect(invokeMock).toHaveBeenCalledWith("notification_count", {
did: "did:plc:me",
});
});
it("returns 0 for a fully-read inbox", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 0 });
await expect(notificationCount("did:plc:me")).resolves.toBe(0);
});
});
describe("markNotificationsSeen", () => {
it("sends the seenAt watermark", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 4 });
const r = await markNotificationsSeen(
"did:plc:me",
"2026-09-09T10:00:01Z",
);
expect(r).toEqual({ ok: true, updated: 4 });
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: "2026-09-09T10:00:01Z",
});
});
it("omitting the watermark sends null (mark everything read)", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 0 });
await markNotificationsSeen("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: null,
});
});
});
describe("notificationText / notificationIcon", () => {
it("maps each kind to its German line", async () => {
const { notificationText } = await import("./client");
expect(notificationText("like")).toBe("hat deinen Post geliked");
expect(notificationText("repost")).toBe("hat repostet");
expect(notificationText("follow")).toBe("folgt dir jetzt");
expect(notificationText("reply")).toBe("hat geantwortet");
});
it("falls back rather than rendering an empty line for an unknown kind", async () => {
const { notificationText } = await import("./client");
expect(notificationText("quote")).toBe("hat interagiert");
expect(notificationText("")).toBe("hat interagiert");
});
it("gives every known kind its own icon glyph", async () => {
const { notificationIcon } = await import("./client");
const icons = ["like", "repost", "follow", "reply"].map(notificationIcon);
expect(new Set(icons).size).toBe(4);
expect(icons.every((i) => i.length > 0)).toBe(true);
expect(notificationIcon("quote")).toBe("•");
});
});
describe("fetchFollowers / fetchFollowing", () => {
const wireProfile = {
did: "did:plc:carol",
handle: "carol.test",
display_name: "Carol",
avatar_cid: "bafycarol",
};
it("fetchFollowers hits the followers command", async () => {
const { fetchFollowers } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: null,
});
const r = await fetchFollowers("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_followers", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.profiles[0].handle).toBe("carol.test");
expect(r.profiles[0].display_name).toBe("Carol");
expect(r.cursor).toBeNull();
});
it("fetchFollowing hits the following command and pages", async () => {
const { fetchFollowing } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: "cur2",
});
const r = await fetchFollowing("did:plc:me", "cur1", 10);
expect(invokeMock).toHaveBeenCalledWith("fetch_following", {
did: "did:plc:me",
cursor: "cur1",
limit: 10,
});
expect(r.cursor).toBe("cur2");
});
});
describe("fetchProfileByDid", () => {
it("invokes profile_get_by_did with the DID", async () => {
const { fetchProfileByDid } = await import("./client");
invokeMock.mockResolvedValueOnce({
did: "did:plc:f5abcdefghijklmnop",
handle: "alice.test",
posts: [],
followers: 2,
following: 3,
post_count: 0,
});
const p = await fetchProfileByDid("did:plc:f5abcdefghijklmnop");
expect(invokeMock).toHaveBeenCalledWith("profile_get_by_did", {
did: "did:plc:f5abcdefghijklmnop",
});
expect(p.did).toBe("did:plc:f5abcdefghijklmnop");
expect(p.followers).toBe(2);
});
it("resolves an actor whose only known handle is a truncated-DID placeholder", async () => {
// The dead end this endpoint exists to avoid: the AppView hands
// us `handle: "did:plc:f5a…"` (from `short_did_bare`) for an actor
// it has no profile row or indexed post for. Feeding that back
// into the *handle* lookup matches nothing and the server answers
// 200 with a synthetic empty profile — `did: ""`, zero counts —
// so the UI shows a blank page instead of an error. The DID
// lookup resolves the real row.
const { fetchProfile, fetchProfileByDid } = await import("./client");
const placeholderHandle = "did:plc:f5a…";
const realDid = "did:plc:f5abcdefghijklmnop";
// 1. What the handle path actually gets back today.
invokeMock.mockResolvedValueOnce({
did: "",
handle: placeholderHandle,
posts: [],
followers: 0,
following: 0,
post_count: 0,
});
const viaHandle = await fetchProfile(placeholderHandle);
expect(viaHandle.did).toBe("");
expect(viaHandle.post_count).toBe(0);
// 2. What the DID path gets back — the real profile.
invokeMock.mockResolvedValueOnce({
did: realDid,
handle: "alice.test",
posts: [],
followers: 5,
following: 1,
post_count: 12,
});
const viaDid = await fetchProfileByDid(realDid);
expect(viaDid.did).toBe(realDid);
expect(viaDid.handle).toBe("alice.test");
expect(viaDid.post_count).toBe(12);
// The two calls must be distinct commands — a shared one would
// reintroduce the ambiguity.
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
"profile_get",
"profile_get_by_did",
]);
});
});
describe("fetchThread", () => {
it("invokes fetch_thread with the viewer DID", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
const r = await fetchThread("at://did:plc:me/app.twi.post/3k2", "did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: "did:plc:me",
});
// `post: null` is the "not in our index" sentinel — one field
// check is enough to render "post not found".
expect(r.post).toBeNull();
expect(r.parents).toEqual([]);
expect(r.replies).toEqual([]);
});
it("defaults the viewer DID to null", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
await fetchThread("at://did:plc:me/app.twi.post/3k2");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: null,
});
});
});