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
@@ -5,6 +5,10 @@
//
// Pattern: `let view = $state("home")` in the harness, then the
// `on_select` callback updates it (same shape as App.svelte).
//
// The rail order is: home, notifications, compose, profile, search,
// settings. The indices below follow that order — if you reorder the
// `items` array in NavRail.svelte, update them here too.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mount, unmount, tick } from "svelte";
@@ -23,16 +27,16 @@ afterEach(() => {
target.remove();
});
async function mountHarness() {
app = mount(NavRailHarness, { target });
async function mountHarness(props: { unread?: number } = {}) {
app = mount(NavRailHarness, { target, props });
await tick();
}
describe("NavRail (callback-prop pattern)", () => {
it("renders 5 buttons with home active", async () => {
it("renders 6 buttons with home active", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
expect(btns.length).toBe(5);
expect(btns.length).toBe(6);
expect(btns[0].classList.contains("active")).toBe(true);
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
});
@@ -41,25 +45,68 @@ describe("NavRail (callback-prop pattern)", () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("search");
expect(btns[3].classList.contains("active")).toBe(true);
expect(btns[4].classList.contains("active")).toBe(true);
expect(btns[0].classList.contains("active")).toBe(false);
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
expect(btns[1].classList.contains("active")).toBe(true);
btns[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
expect(btns[2].classList.contains("active")).toBe(true);
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(btns[3].classList.contains("active")).toBe(true);
btns[5].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings");
expect(btns[4].classList.contains("active")).toBe(true);
expect(btns[5].classList.contains("active")).toBe(true);
});
});
it("routes to the notifications view", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe(
"notifications",
);
expect(btns[1].classList.contains("active")).toBe(true);
});
});
describe("NavRail unread badge", () => {
it("renders no badge at zero unread", async () => {
await mountHarness({ unread: 0 });
expect(target.querySelector('[data-testid="notif-badge"]')).toBeNull();
});
it("renders the count on the notifications button", async () => {
await mountHarness({ unread: 3 });
const badge = target.querySelector('[data-testid="notif-badge"]');
expect(badge).not.toBeNull();
expect(badge?.textContent?.trim()).toBe("3");
// The badge must sit on the notifications entry, not on some
// other rail button.
const btns = target.querySelectorAll("button.rail__btn");
expect(btns[1].contains(badge!)).toBe(true);
});
it("caps a large backlog at 99+ so the 88px rail can't widen", async () => {
await mountHarness({ unread: 1234 });
const badge = target.querySelector('[data-testid="notif-badge"]');
expect(badge?.textContent?.trim()).toBe("99+");
});
it("keeps 99 unabbreviated (the cap is exclusive)", async () => {
await mountHarness({ unread: 99 });
expect(
target.querySelector('[data-testid="notif-badge"]')?.textContent?.trim(),
).toBe("99");
});
});