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
@@ -0,0 +1,219 @@
// Regression guard for the actor-navigation path out of the
// notifications list.
//
// The AppView guarantees `author_handle` is non-empty, but it does NOT
// guarantee it's a real handle: for an author it has neither a
// `profiles` row nor an indexed post for, it synthesises a placeholder
// from the DID (`short_did_bare` in the AppView's `routes.rs`) — e.g.
// `"did:plc:f5…"`, a truncated DID with an ellipsis. Navigating with
// that string resolves to nothing, and `/api/profile/<handle>` answers
// with a *synthetic empty profile* rather than an error, so the click
// silently dead-ends on a blank page.
//
// The fix is to hand the navigation callback the real `author_did`
// alongside the handle. These tests pin that: the callback must
// receive the DID from the DTO, for both a real-handle author and a
// placeholder-handle one.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mount, unmount, tick } from "svelte";
const fetchNotificationsMock = vi.fn();
const markNotificationsSeenMock = vi.fn();
vi.mock("../api/client", async () => {
// Keep the real `notificationText` / `notificationIcon` — the row
// copy is part of what we're rendering — and stub only the two
// functions that would otherwise need a Tauri runtime.
const actual =
await vi.importActual<typeof import("../api/client")>("../api/client");
return {
...actual,
fetchNotifications: (...args: unknown[]) => fetchNotificationsMock(...args),
markNotificationsSeen: (...args: unknown[]) =>
markNotificationsSeenMock(...args),
// Avatar resolves blobs through this; every fixture below has a
// null avatar CID so it never fires, but stub it so a stray call
// can't reach for the Tauri shell.
fetchBlob: () => Promise.reject(new Error("no blob in test")),
};
});
import NotificationsView from "./NotificationsView.svelte";
let target: HTMLDivElement;
let app: ReturnType<typeof mount> | null = null;
beforeEach(() => {
target = document.createElement("div");
document.body.appendChild(target);
fetchNotificationsMock.mockReset();
markNotificationsSeenMock.mockReset();
markNotificationsSeenMock.mockResolvedValue({ ok: true, updated: 1 });
});
afterEach(() => {
if (app) unmount(app);
app = null;
target.remove();
});
/// Let the component's load effect and its awaited promises settle.
/// The mocked fetch resolves immediately, so a handful of microtask
/// turns plus a Svelte flush is enough.
async function flush(turns = 6) {
for (let i = 0; i < turns; i++) {
await Promise.resolve();
await tick();
}
}
function row(over: Record<string, unknown> = {}) {
return {
id: 1,
kind: "like",
author_did: "did:plc:realauthor",
author_handle: "alice.test",
author_avatar_cid: null,
subject_uri: "at://did:plc:me/app.twi.post/3k2",
subject_text: "hello",
created_at: "2026-09-09T10:00:00Z",
indexed_at: "2026-09-09T10:00:01Z",
read_at: null,
...over,
};
}
describe("NotificationsView actor navigation", () => {
it("hands the callback the author DID, not just the handle", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row()],
cursor: null,
});
const onActorClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_actor_click: onActorClick },
});
await flush();
const handleBtn = target.querySelector("button.notifs__handle");
expect(handleBtn).not.toBeNull();
handleBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onActorClick).toHaveBeenCalledTimes(1);
expect(onActorClick).toHaveBeenCalledWith(
"did:plc:realauthor",
"alice.test",
);
});
it("resolves an author whose handle is only a truncated-DID placeholder", async () => {
// Exactly what the AppView returns for an author it has never
// seen post and has no profile row for: `handle` is
// `short_did_bare(author_did)`, which matches nothing on the way
// back in. The DID beside it is real.
fetchNotificationsMock.mockResolvedValue({
notifications: [
row({
id: 2,
kind: "follow",
author_did: "did:plc:f5abcdefghijklmnop",
author_handle: "did:plc:f5a…",
subject_uri: null,
subject_text: undefined,
}),
],
cursor: null,
});
const onActorClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_actor_click: onActorClick },
});
await flush();
target
.querySelector("button.notifs__handle")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
const [didArg, handleArg] = onActorClick.mock.calls[0];
// The DID must be the full, real one — never the truncated
// display string.
expect(didArg).toBe("did:plc:f5abcdefghijklmnop");
expect(didArg).not.toContain("…");
// The placeholder still travels as the label, which is fine — it
// is not what the lookup keys off.
expect(handleArg).toBe("did:plc:f5a…");
});
it("renders the kind copy and marks the first page seen at the top row", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row({ kind: "follow", subject_uri: null })],
cursor: null,
});
const onSeen = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_seen: onSeen },
});
await flush();
expect(target.textContent).toContain("folgt dir jetzt");
// The read watermark is the newest rendered row's `indexed_at` —
// never null, so a notification landing mid-scroll survives.
expect(markNotificationsSeenMock).toHaveBeenCalledWith(
"did:plc:me",
"2026-09-09T10:00:01Z",
);
expect(onSeen).toHaveBeenCalledTimes(1);
});
it("a follow row has no thread target, so its row button is inert", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row({ kind: "follow", subject_uri: null })],
cursor: null,
});
const onThreadClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_thread_click: onThreadClick },
});
await flush();
const rowBtn = target.querySelector<HTMLButtonElement>("button.notifs__row");
expect(rowBtn?.disabled).toBe(true);
rowBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onThreadClick).not.toHaveBeenCalled();
});
it("opens the thread for a row that has a subject", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row()],
cursor: null,
});
const onThreadClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_thread_click: onThreadClick },
});
await flush();
target
.querySelector("button.notifs__row")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onThreadClick).toHaveBeenCalledWith(
"at://did:plc:me/app.twi.post/3k2",
);
});
});