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,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>