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
@@ -4,18 +4,35 @@
// `$bindable`, use a callback prop to bubble state changes up to
// the parent.
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
let {
view = "home",
on_select,
unread = 0,
}: {
view?: View;
on_select?: (v: View) => void;
/// Unread-notification count from `/api/notifications/count`.
/// The parent polls it and hands it down; `0` (or anything
/// below) hides the badge entirely rather than rendering a "0".
unread?: number;
} = $props();
/// Badge label. Anything past 99 collapses to "99+" so a long
/// unread backlog can't widen the 88px rail.
const badge = $derived(unread > 99 ? "99+" : String(unread));
const items: Array<{ id: View; label: string; key: string; icon: string }> = [
{ id: "home", label: "home", key: "g h", icon: "home" },
{ id: "notifications", label: "notifs", key: "g n", icon: "bell" },
{ id: "compose", label: "compose", key: "c", icon: "compose" },
{ id: "profile", label: "profile", key: "p", icon: "profile" },
{ id: "search", label: "search", key: "/", icon: "search" },
@@ -37,6 +54,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M3 11l9-8 9 8v9a2 2 0 0 1-2 2h-3v-7H8v7H5a2 2 0 0 1-2-2z"/>
</svg>
{:else if item.icon === "bell"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 15v-4a6 6 0 1 0-12 0v4l-2 3h16z"/><path d="M10 21h4"/>
</svg>
{:else if item.icon === "compose"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">&gt;_</text>
@@ -55,6 +76,16 @@
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
</svg>
{/if}
{#if item.id === "notifications" && unread > 0}
<!-- Live region so a screen reader announces a badge that
appears while the rail is already on screen. -->
<span
class="badge"
data-testid="notif-badge"
aria-live="polite"
aria-label={`${unread} ungelesene Benachrichtigungen`}
>{badge}</span>
{/if}
</span>
<span class="label">{item.label}</span>
</button>
@@ -107,6 +138,26 @@
height: 24px;
display: grid;
place-items: center;
/* Anchor for the unread badge, which overhangs the glyph's
top-right corner the way a tray badge does. */
position: relative;
}
.badge {
position: absolute;
top: -6px;
right: -10px;
min-width: 16px;
padding: 0 4px;
border-radius: var(--r-pill);
background: var(--orange);
color: var(--bg);
font-family: var(--font-mono);
font-size: 10px;
line-height: 16px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.icon :global(svg) {
width: 22px;
@@ -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");
});
});
@@ -5,14 +5,28 @@
import NavRail from "./NavRail.svelte";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
// `unread` is a prop so the badge test can drive it without going
// through the polling path in App.svelte. Defaults to 0 (no badge),
// which is what every non-badge test expects.
let { unread = 0 }: { unread?: number } = $props();
let view: View = $state("home");
</script>
<NavRail
{view}
{unread}
on_select={(v) => {
view = v;
}}
/>
<span data-testid="view-value">{view}</span>
<span data-testid="view-value">{view}</span>
@@ -0,0 +1,366 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
import Skeleton from "./Skeleton.svelte";
import {
fetchNotifications,
markNotificationsSeen,
notificationIcon,
notificationText,
type Notification,
} from "../api/client";
type Props = {
/// Recipient DID — whose notifications to list.
did: string;
/// Opens the thread for a notification's `subject_uri`. Wired to
/// App.svelte's existing `openThread` helper, so a notification
/// and a PostCard's thread button land in the same modal.
on_thread_click?: (uri: string) => void;
/// Navigates to the author's profile. Takes the DID *and* the
/// handle, and the DID is what the navigation resolves by: the
/// AppView hands us a placeholder handle (a truncated DID, see
/// `short_did_bare`) for any author it has neither a profile row
/// nor an indexed post for, and that placeholder resolves to a
/// synthetic empty profile on the way back in. `author_did` is
/// always real, so it's the one field worth navigating on. The
/// handle still travels along for the header label while the
/// profile loads.
on_actor_click?: (did: string, handle: string) => void;
/// Fired once the first page is marked read, so the parent can
/// zero the NavRail badge without waiting for the next poll.
on_seen?: () => void;
};
let { did, on_thread_click, on_actor_click, on_seen }: Props = $props();
let items: Notification[] = $state([]);
let cursor: string | null = $state(null);
let loading: boolean = $state(false);
let error: string | null = $state(null);
/// Guards `load()` against a re-entrant `$effect` run: the effect
/// tracks `did`, but `load` writes `$state` the effect would
/// otherwise see as a self-write (the same depth-guard problem
/// ProfileView documents around `untrack`).
let loadedDid: string | null = null;
async function load() {
loading = true;
error = null;
try {
const r = await fetchNotifications(did, null, 30);
items = r.notifications;
cursor = r.cursor;
// Watermark the read marker at the newest row we actually
// rendered — never `null` — so a notification that lands while
// the user is scrolling isn't silently marked as seen.
const top = r.notifications[0]?.indexed_at ?? null;
if (top) {
try {
await markNotificationsSeen(did, top);
// Reflect it locally too: the rows are already on screen,
// and re-fetching just to flip `read_at` would be a wasted
// round trip.
items = items.map((n) =>
n.read_at ? n : { ...n, read_at: top },
);
on_seen?.();
} catch (e) {
// A failed ack is not a failed load — the list is usable,
// the badge just stays until the next attempt.
console.warn("mark_notifications_seen failed", e);
}
}
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
async function loadMore() {
if (!cursor || loading) return;
loading = true;
try {
const r = await fetchNotifications(did, cursor, 30);
// De-dupe on `id`: the keyset cursor is stable, but a row
// indexed between two page fetches can otherwise shift a row
// across the page boundary.
const seen = new Set(items.map((n) => n.id));
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
cursor = r.cursor;
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
$effect(() => {
if (!did || loadedDid === did) return;
loadedDid = did;
void load();
});
/// Display label for the author — the profile's display name when
/// the AppView has one cached, else the handle (which the server
/// guarantees is non-empty, falling back to a truncated DID).
function authorName(n: Notification): string {
return n.author_display_name || n.author_handle;
}
/// Same relative-time rendering as PostCard's `timeAgo`, on
/// `indexed_at` (the list's sort key) rather than `created_at`.
function timeAgo(iso: string): string {
const timestamp = new Date(iso).getTime();
if (!Number.isFinite(timestamp)) return iso;
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
function openSubject(n: Notification) {
if (!n.subject_uri) return;
on_thread_click?.(n.subject_uri);
}
</script>
<section class="notifs">
{#if error}
<div class="notifs__err">err: {error}</div>
{/if}
{#if loading && items.length === 0}
<Skeleton rows={3} />
{:else if items.length === 0}
<div class="notifs__empty">// keine Benachrichtigungen.</div>
{:else}
<ul class="notifs__list">
{#each items as n (n.id)}
<li class="notifs__item" class:notifs__item--unread={!n.read_at}>
<!--
The whole row is the click target when there's a subject
to open; for a "follow" (no subject) the row is inert and
only the handle button navigates. `type="button"` +
`disabled` keeps the keyboard order honest instead of
faking it with a div.
-->
<button
class="notifs__row"
type="button"
disabled={!n.subject_uri}
aria-label={`${authorName(n)} ${notificationText(n.kind)}`}
onclick={() => openSubject(n)}
>
<span class="notifs__icon" data-kind={n.kind} aria-hidden="true">
{notificationIcon(n.kind)}
</span>
<span class="notifs__avatar">
<Avatar
did={n.author_did}
cid={n.author_avatar_cid ?? null}
name={authorName(n)}
size={32}
/>
</span>
<span class="notifs__body">
<span class="notifs__line">
<span class="notifs__name">{authorName(n)}</span>
<span class="notifs__text">{notificationText(n.kind)}</span>
<span class="notifs__age">{timeAgo(n.indexed_at)}</span>
</span>
{#if n.subject_text}
<span class="notifs__subject">{n.subject_text}</span>
{/if}
</span>
</button>
<button
class="notifs__handle"
type="button"
title={n.author_did}
onclick={() => on_actor_click?.(n.author_did, n.author_handle)}
>@{n.author_handle}</button>
</li>
{/each}
</ul>
{#if cursor}
<div class="notifs__loadmore">
<button
class="notifs__btn"
type="button"
disabled={loading}
onclick={() => void loadMore()}
>
{loading ? "loading…" : "load more"}
</button>
</div>
{/if}
{/if}
</section>
<style>
.notifs {
padding: 0 0 var(--s-6);
}
.notifs__err {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: var(--s-2) var(--s-3);
margin: 0 var(--s-5) var(--s-3);
border-left: 3px solid var(--red);
background: var(--orange-3);
color: var(--red);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.notifs__empty {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
padding: var(--s-4) var(--s-5);
font-style: italic;
}
.notifs__list {
list-style: none;
margin: 0;
padding: 0;
}
/* One row per notification: the clickable body plus the handle
button, which is a separate target so "open the thread" and "go
to the author" don't fight over the same click. */
.notifs__item {
display: flex;
align-items: center;
gap: var(--s-2);
padding: 0 var(--s-4) 0 0;
border-bottom: 1px solid var(--line);
}
/* Unread rows get the orange left rail + tint the rest of the app
uses for "needs attention" (same tokens as the settings hover
state), so the read/unread split is visible without a legend. */
.notifs__item--unread {
background: var(--orange-8);
box-shadow: inset 3px 0 0 var(--orange);
}
.notifs__row {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-3) var(--s-4);
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
color: var(--text);
font-family: var(--font-mono);
transition: background-color var(--dur) var(--ease);
}
.notifs__row:hover:not(:disabled) {
background: var(--orange-3);
}
.notifs__row:disabled {
cursor: default;
}
.notifs__icon {
flex: 0 0 auto;
width: 1.25rem;
text-align: center;
font-size: var(--fs-100);
color: var(--text-dim);
}
/* Per-kind accent: likes red, reposts green, follows/replies cyan
— all straight from the token palette, no new hex values. */
.notifs__icon[data-kind="like"] { color: var(--red); }
.notifs__icon[data-kind="repost"] { color: var(--green); }
.notifs__icon[data-kind="follow"] { color: var(--cyan); }
.notifs__icon[data-kind="reply"] { color: var(--orange); }
.notifs__avatar {
flex: 0 0 auto;
line-height: 0;
}
.notifs__body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.notifs__line {
display: flex;
align-items: baseline;
gap: var(--s-2);
min-width: 0;
}
.notifs__name {
color: var(--text);
font-weight: 700;
font-size: var(--fs-50);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notifs__text {
color: var(--text-dim);
font-size: var(--fs-50);
white-space: nowrap;
}
.notifs__age {
color: var(--text-dim);
font-size: var(--fs-50);
font-variant-numeric: tabular-nums;
margin-left: auto;
flex: 0 0 auto;
}
/* Subject preview — one line, clipped. The full text is a click
away in the thread, so wrapping here would only push rows apart. */
.notifs__subject {
color: var(--text-dim);
font-size: var(--fs-50);
font-family: var(--font-sans);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notifs__handle {
flex: 0 0 auto;
background: transparent;
border: 0;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
cursor: pointer;
padding: var(--s-1) var(--s-2);
border-radius: var(--r-sm);
transition: color var(--dur) var(--ease);
}
.notifs__handle:hover {
color: var(--orange);
}
.notifs__loadmore {
display: flex;
justify-content: center;
padding: var(--s-4) var(--s-5);
}
.notifs__btn {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.notifs__btn:hover:not(:disabled) {
color: var(--orange);
border-color: var(--orange);
}
.notifs__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -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",
);
});
});
@@ -9,22 +9,49 @@
getAppviewUrl,
followUser,
unfollowUser,
fetchFollowers,
fetchFollowing,
fetchProfileByDid,
showInfo,
showError,
type ActorProfile,
} from "../api/client";
import { localStorageKey } from "../utils/localstorage";
import { onDestroy, onMount, untrack } from "svelte";
import { onDestroy, untrack } from "svelte";
type Props = {
handle: string;
/// DID of the profile to show, when the caller already knows it.
/// Takes precedence over `handle`: the AppView synthesises a
/// placeholder handle (a truncated DID — `short_did_bare`) for
/// actors it has neither a `profiles` row nor an indexed post for,
/// and feeding that back into the handle lookup resolves to a
/// synthetic empty profile. Navigation out of a notification or a
/// follower list therefore passes the DID; `handle` is still
/// supplied so the header has a label while the fetch is in
/// flight. `null`/absent keeps the handle lookup (PostCard header,
/// search, the current user's own profile).
did?: string | null;
on_thread_click?: (uri: string) => void;
/// DID of the authenticated user. When this matches the
/// profile's DID, the "edit profile" button is shown; the
/// /user-profile/<handle> route is then the user's own
/// profile (and the avatar / bio are editable).
current_user_did?: string | null;
/// Navigate to another profile. Receives `(did, handle)` and the
/// caller resolves by DID — see the `did` prop above for why the
/// handle alone is not a reliable key. Used by the follower /
/// following list rows and by the PostCards in the feed (which
/// carry a real `post.did`).
on_actor_click?: (did: string, handle: string) => void;
};
let { handle, on_thread_click, current_user_did }: Props = $props();
let {
handle,
did = null,
on_thread_click,
current_user_did,
on_actor_click,
}: Props = $props();
type State =
| { kind: "loading" }
@@ -91,6 +118,15 @@
async function load() {
viewModel = { kind: "loading" };
try {
// DID lookup wins when the caller supplied one — see the `did`
// prop. `/api/profile?did=…` is an exact match on the `posts` /
// `profiles` DID column, so it works for an actor whose only
// known "handle" is the server's truncated-DID placeholder.
if (did) {
const data = (await fetchProfileByDid(did)) as AppViewProfile;
viewModel = { kind: "ready", data };
return;
}
// Absolute URL because the Tauri webview's origin is the Vite
// dev server (port 1430), not the AppView (port 2584) — a
// relative `/api/profile/…` would resolve against Vite, hit a
@@ -145,8 +181,25 @@
};
});
onMount(() => {
void load();
/// Identity of the profile currently loaded, as an opaque key. A
/// plain (non-`$state`) variable on purpose: the effect below reads
/// it, and making it reactive would turn "remember what we loaded"
/// into a self-write that trips Svelte 5's depth guard — the same
/// pattern the banner effect documents around `untrack`.
let loadedKey: string | null = null;
// Reload whenever the profile *identity* changes, not just on
// mount. App.svelte keeps this component mounted across a
// profile→profile navigation (the view stays `"user"`, only the
// props change), so an `onMount`-only load would leave the previous
// user's posts and counts on screen under the new name — which is
// exactly the path a follower-list or notification click takes.
$effect(() => {
const key = did ? `did:${did}` : `handle:${handle}`;
if (!did && !handle) return;
if (loadedKey === key) return;
loadedKey = key;
untrack(() => void load());
});
onDestroy(() => {
@@ -299,6 +352,93 @@
type Tab = "posts" | "replies" | "likes";
let activeTab: Tab = $state("posts");
// ─── follower / following list ──────────────────────────────────
//
// Clicking a count opens an inline list under the counts row rather
// than navigating away — the user came here for this profile, and a
// separate route would cost a second profile fetch on the way back.
// Clicking the same count again closes it (the counts double as the
// toggle, the way a disclosure does).
type ActorListKind = "followers" | "following";
let actorKind: ActorListKind | null = $state(null);
let actors: ActorProfile[] = $state([]);
let actorCursor: string | null = $state(null);
let actorLoading: boolean = $state(false);
let actorError: string | null = $state(null);
async function toggleActorList(kind: ActorListKind) {
if (actorKind === kind) {
closeActorList();
return;
}
if (viewModel.kind !== "ready") return;
const did = viewModel.data.did;
actorKind = kind;
actors = [];
actorCursor = null;
actorError = null;
actorLoading = true;
try {
const r =
kind === "followers"
? await fetchFollowers(did, null, 30)
: await fetchFollowing(did, null, 30);
// Guard against a fast double-click on the other count: only
// commit if we're still the list the user asked for.
if (actorKind !== kind) return;
actors = r.profiles;
actorCursor = r.cursor;
} catch (e) {
if (actorKind === kind) actorError = String(e);
} finally {
if (actorKind === kind) actorLoading = false;
}
}
async function loadMoreActors() {
if (viewModel.kind !== "ready") return;
const kind = actorKind;
if (!kind || !actorCursor || actorLoading) return;
const did = viewModel.data.did;
actorLoading = true;
try {
const r =
kind === "followers"
? await fetchFollowers(did, actorCursor, 30)
: await fetchFollowing(did, actorCursor, 30);
if (actorKind !== kind) return;
// De-dupe on DID — a follow edge indexed between two page
// fetches can otherwise repeat a row across the boundary.
const seen = new Set(actors.map((a) => a.did));
actors = [...actors, ...r.profiles.filter((a) => !seen.has(a.did))];
actorCursor = r.cursor;
} catch (e) {
if (actorKind === kind) actorError = String(e);
} finally {
if (actorKind === kind) actorLoading = false;
}
}
function closeActorList() {
actorKind = null;
actors = [];
actorCursor = null;
actorError = null;
actorLoading = false;
}
// Switching to another profile must not leave the previous user's
// follower list on screen. Keyed on the same `(did, handle)`
// identity the load effect uses, so the two can't disagree about
// when "the profile changed".
let actorListKey: string | null = null;
$effect(() => {
const key = did ? `did:${did}` : `handle:${handle}`;
if (actorListKey === key) return;
actorListKey = key;
untrack(() => closeActorList());
});
</script>
<section class="profile">
@@ -426,17 +566,105 @@
<dt>posts</dt>
<dd>{viewModel.data.post_count}</dd>
</div>
<!--
The follower / following counts are buttons: they open the
matching actor list inline (and close it on a second click).
The post count stays inert — there's no endpoint behind it
that the feed below doesn't already show.
The button lives *inside* the `<dd>` rather than wrapping the
`dt`/`dd` pair, because a `<dl>` may only contain `dt`/`dd`
(via an optional `div`) — a button in between would be invalid
markup. `aria-label` puts the term back on the control so a
screen reader still hears "followers", not a bare number.
-->
<div>
<dt>followers</dt>
<dd>{viewModel.data.followers}</dd>
<dd>
<button
class="profile__count-btn"
class:profile__count-btn--open={actorKind === "followers"}
type="button"
aria-expanded={actorKind === "followers"}
aria-label={`${viewModel.data.followers} followers anzeigen`}
onclick={() => void toggleActorList("followers")}
>{viewModel.data.followers}</button>
</dd>
</div>
<div>
<dt>following</dt>
<dd>{viewModel.data.following}</dd>
<dd>
<button
class="profile__count-btn"
class:profile__count-btn--open={actorKind === "following"}
type="button"
aria-expanded={actorKind === "following"}
aria-label={`${viewModel.data.following} following anzeigen`}
onclick={() => void toggleActorList("following")}
>{viewModel.data.following}</button>
</dd>
</div>
</dl>
{/if}
<!-- ─── follower / following list ─────────────────────────────── -->
{#if actorKind}
<div class="actors">
<header class="actors__head">
<span class="actors__title">// {actorKind}</span>
<button
class="actors__close"
type="button"
onclick={closeActorList}
>close</button>
</header>
{#if actorError}
<div class="actors__err">err: {actorError}</div>
{/if}
{#if actorLoading && actors.length === 0}
<div class="actors__empty">// loading…</div>
{:else if actors.length === 0}
<div class="actors__empty">// niemand hier.</div>
{:else}
<ul class="actors__list">
{#each actors as a (a.did)}
<li>
<button
class="actors__row"
type="button"
title={a.did}
onclick={() => on_actor_click?.(a.did, a.handle)}
>
<Avatar
did={a.did}
cid={a.avatar_cid ?? null}
name={a.display_name ?? a.handle}
size={32}
/>
<span class="actors__names">
<span class="actors__name">{a.display_name ?? a.handle}</span>
<span class="actors__handle">@{a.handle}</span>
</span>
</button>
</li>
{/each}
</ul>
{#if actorCursor}
<div class="actors__loadmore">
<button
class="actors__more"
type="button"
disabled={actorLoading}
onclick={() => void loadMoreActors()}
>
{actorLoading ? "loading…" : "load more"}
</button>
</div>
{/if}
{/if}
</div>
{/if}
<!-- ─── tabs ──────────────────────────────────────────────────── -->
<nav class="profile__tabs" aria-label="Profile sections">
<button
@@ -465,7 +693,17 @@
<div class="profile__feed">
{#if viewModel.kind === "ready"}
{#each viewModel.data.posts as p (p.uri)}
<PostCard post={p} on_thread_click={on_thread_click} />
<!--
The feed's PostCards carry a real `post.did`, so we route
their header clicks through the same DID-first path as the
actor list rather than through the (possibly decorated)
`post.handle`.
-->
<PostCard
post={p}
on_thread_click={on_thread_click}
on_handle_click={(h) => on_actor_click?.(p.did, h)}
/>
{/each}
{#if viewModel.data.posts.length === 0}
<div class="profile__empty">// no posts yet.</div>
@@ -705,6 +943,157 @@
font-variant-numeric: tabular-nums;
}
/* The count button inherits the `dd` typography so the clickable
followers/following numbers read identically to the inert post
count — only the hover/open colour marks them as interactive. */
.profile__count-btn {
background: none;
border: 0;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.profile__count-btn:hover {
color: var(--orange);
}
.profile__count-btn--open {
color: var(--orange);
border-bottom-color: var(--orange);
}
/* ─── follower / following list ─────────────────────────── */
.actors {
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: var(--bg-elev);
}
.actors__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
border-bottom: 1px solid var(--line);
}
.actors__title {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--orange);
letter-spacing: var(--tracking-label);
font-weight: 700;
}
.actors__close {
background: transparent;
border: 1px solid var(--line-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.2rem 0.6rem;
border-radius: var(--r-sm);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.actors__close:hover {
color: var(--orange);
border-color: var(--orange);
}
.actors__err {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--red);
padding: var(--s-2) var(--s-4);
}
.actors__empty {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
font-style: italic;
padding: var(--s-3) var(--s-4);
}
.actors__list {
list-style: none;
margin: 0;
padding: 0;
/* Cap the inline list so it never pushes the feed off screen —
"load more" keeps the rest reachable. */
max-height: 320px;
overflow-y: auto;
}
.actors__row {
width: 100%;
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
text-align: left;
cursor: pointer;
color: var(--text);
transition: background-color var(--dur) var(--ease);
}
.actors__row:hover {
background: var(--orange-8);
}
.actors__names {
display: flex;
flex-direction: column;
min-width: 0;
}
.actors__name {
font-family: var(--font-mono);
font-size: var(--fs-50);
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actors__handle {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actors__loadmore {
display: flex;
justify-content: center;
padding: var(--s-3);
}
.actors__more {
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.4rem 0.8rem;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.actors__more:hover:not(:disabled) {
color: var(--orange);
border-color: var(--orange);
}
.actors__more:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ─── tabs ──────────────────────────────────────────────── */
.profile__tabs {
display: flex;