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;