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
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user