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
+140
-22
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user