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
367 lines
11 KiB
Svelte
367 lines
11 KiB
Svelte
<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>
|