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:
co-authored by
Claude Opus 5
parent
bce4c7862f
commit
31880e1005
@@ -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>
|
||||
Reference in New Issue
Block a user