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
+140 -22
View File
@@ -6,12 +6,14 @@
fetchTimeline,
fetchSearch,
fetchPost,
notificationCount,
openExternalUrl,
showError,
type Session,
type Post,
} from "./lib/api/client";
import NavRail from "./lib/components/NavRail.svelte";
import NotificationsView from "./lib/components/NotificationsView.svelte";
import StatusBar from "./lib/components/StatusBar.svelte";
import PostCard from "./lib/components/PostCard.svelte";
import ComposeBox from "./lib/components/ComposeBox.svelte";
@@ -21,7 +23,14 @@
import Skeleton from "./lib/components/Skeleton.svelte";
import Sidebar from "./lib/components/Sidebar.svelte";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
let view: View = $state("home");
// Handle for the "user" view (i.e. someone else's profile). The
@@ -29,6 +38,13 @@
// goes there). Selecting a handle (via the PostCard avatar link or
// a future deep-link) navigates to "user" with `selectedHandle` set.
let selectedHandle: string = $state("");
// DID for the "user" view, when the navigation had one. Set by
// `openActor` (notification rows, follower/following lists, the
// PostCards inside a profile feed) and cleared by
// `openUserProfile` (timeline / search, where only a handle is
// available). `<ProfileView>` prefers it over the handle — see the
// comment on `openActor`.
let selectedDid: string | null = $state(null);
let currentUser: Session | null = $state(null);
let status: { did?: string; handle?: string; authenticated: boolean } = $state({ authenticated: false });
@@ -40,6 +56,14 @@
let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined;
// Unread-notification badge on the NavRail. Polled on the same 5s
// cadence as the timeline refresh (`startPoll`) — the count query is
// backed by a partial index server-side, so it's cheap enough to sit
// next to the timeline poll rather than needing its own slower
// timer. The poll shares `_pollTimer`, so `stopPoll` (logout /
// unmount) tears both down in one place.
let unreadCount: number = $state(0);
// Home tab strip — "for you" is a placeholder (no real algo yet),
// "following" is the live behavior. Mirrors the X-style "For you /
// Following" tabs.
@@ -116,11 +140,44 @@
}
/// Navigate to the "user" profile view for `handle`. Called from
/// `<PostCard on_handle_click>` and the avatar/handle buttons in
/// the post header. The actual profile fetch happens inside
/// `<ProfileView>` on mount.
/// `<PostCard on_handle_click>` on the timeline / search results and
/// the avatar/handle buttons in the post header. The actual profile
/// fetch happens inside `<ProfileView>`.
///
/// Handle-only path: those handles come from `posts.handle`, which
/// the AppView resolves from the record itself. Where a DID is
/// available, prefer [`openActor`].
function openUserProfile(handle: string) {
selectedHandle = handle;
selectedDid = null;
view = "user";
threadRoot = null;
threadParent = null;
}
/// Navigate to a profile by DID, with `handle` carried along only as
/// a label for the header while the fetch is in flight.
///
/// This is the path every actor coming out of a notification or a
/// follower/following list must take. The AppView synthesises a
/// placeholder `handle` for actors it has neither a `profiles` row
/// nor an indexed post for — a truncated DID like `"did:plc:f5…"`
/// (`short_did_bare` in the AppView's `routes.rs`). Feeding that
/// back into the handle lookup matches nothing, and the server
/// answers with a synthetic empty profile instead of an error, so
/// the click reads as a dead end rather than as a failure. The DID
/// on the DTO is always real.
///
/// We deliberately do NOT sniff the handle for the `…` placeholder
/// marker: that would bake the server's current display format into
/// the client. Passing the DID explicitly keeps the path correct
/// whatever the placeholder ends up looking like.
function openActor(did: string, handle: string) {
selectedHandle = handle;
// An empty DID would resolve to the "did is required" 400; fall
// back to the handle lookup in that case rather than guaranteeing
// an error.
selectedDid = did || null;
view = "user";
threadRoot = null;
threadParent = null;
@@ -302,14 +359,42 @@
let _pollTimer: number | undefined;
function startPoll() {
if (_pollTimer != null) return;
// Prime the badge immediately — otherwise a freshly logged-in
// user stares at a blank rail for a full interval.
void refreshUnreadCount();
_pollTimer = window.setInterval(() => {
if (view === "home") void refreshTimeline(false);
void refreshUnreadCount();
}, 5000);
}
function stopPoll() {
if (_pollTimer == null) return;
window.clearInterval(_pollTimer);
_pollTimer = undefined;
unreadCount = 0;
}
/// Pull the unread count for the NavRail badge. Swallows errors:
/// the badge is ambient information, and a transient AppView hiccup
/// shouldn't produce a toast every 5 seconds.
async function refreshUnreadCount() {
if (!currentUser) return;
// While the notifications view is open the user is by definition
// reading them; the view marks the page seen itself and calls
// back into `onNotificationsSeen`. Polling on top of that would
// race the ack and flicker the badge back on.
if (view === "notifications") return;
try {
unreadCount = await notificationCount(currentUser.did);
} catch {
/* ignore — keep the last known count */
}
}
/// Called by `<NotificationsView on_seen>` once it has acked the
/// first page. Zeroes the badge without waiting for the next poll.
function onNotificationsSeen() {
unreadCount = 0;
}
async function refreshTimeline(reset: boolean) {
@@ -457,6 +542,35 @@
}
</script>
<!--
Thread overlay. Defined once as a snippet because two views open a
thread through the same `openThread` helper — the timeline (a
PostCard's thread button) and the notifications list (a row with a
`subject_uri`). Rendering it twice from one definition keeps the
close button, the loading skeleton and the parent/root layout from
drifting apart.
-->
{#snippet threadModal()}
{#if threadRoot || threadLoading || threadError}
<div class="thread-modal">
<header class="thread-modal__head">
<span class="crumb">// thread</span>
<button class="btn--ghost" onclick={closeThread}>close</button>
</header>
{#if threadLoading}
<Skeleton rows={2} />
{:else if threadError}
<div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if}
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
{/if}
</div>
{/if}
{/snippet}
{#if !currentUser}
<div class="login-wrap">
<LoginScreen
@@ -470,6 +584,7 @@
<div class="shell">
<NavRail
{view}
unread={unreadCount}
on_select={(v) => setView(v)}
/>
<div class="main">
@@ -504,24 +619,7 @@
{:else if userPosts.length === 0}
<div class="empty">// timeline is empty. compose your first post →</div>
{:else}
{#if threadRoot}
<div class="thread-modal">
<header class="thread-modal__head">
<span class="crumb">// thread</span>
<button class="btn--ghost" onclick={closeThread}>close</button>
</header>
{#if threadLoading}
<Skeleton rows={2} />
{:else if threadError}
<div class="toast toast--err">err: {threadError}</div>
{:else if threadRoot}
{#if threadParent && threadParent.uri !== threadRoot.uri}
<div class="thread-parent"><PostCard post={threadParent} on_handle_click={openUserProfile} on_reply={onReply} /></div>
{/if}
<PostCard post={threadRoot} on_handle_click={openUserProfile} on_reply={onReply} />
{/if}
</div>
{/if}
{@render threadModal()}
{#each userPosts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} on_handle_click={openUserProfile} on_reply={onReply} />
{/each}
@@ -533,6 +631,22 @@
</div>
{/if}
{/if}
{:else if view === "notifications"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// benachrichtigungen —</span>
<span class="as">@{currentUser.handle}</span>
<span class="meta">
{unreadCount > 0 ? `${unreadCount} ungelesen` : "alles gelesen"}
</span>
</div>
{@render threadModal()}
<NotificationsView
did={currentUser.did}
on_thread_click={openThread}
on_actor_click={openActor}
on_seen={onNotificationsSeen}
/>
{:else if view === "compose"}
<div class="head">
<span class="prompt">$</span>
@@ -553,7 +667,9 @@
</div>
<ProfileView
handle={selectedHandle}
did={selectedDid}
on_thread_click={openThread}
on_actor_click={openActor}
current_user_did={currentUser?.did ?? null}
/>
{:else if view === "profile"}
@@ -565,7 +681,9 @@
</div>
<ProfileView
handle={currentUser.handle}
did={currentUser.did}
on_thread_click={openThread}
on_actor_click={openActor}
current_user_did={currentUser.did}
/>
{/if}