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
@@ -77,6 +77,104 @@ pub struct ThreadView {
pub root: Option<PostDto>,
}
/// `GET /api/thread?uri=…` response — the *full* thread shape (the
/// whole ancestor chain plus the direct replies), as opposed to the
/// narrower `{ post, thread: { parent, root } }` of
/// [`ThreadResponse`]. Both come from the same server-side thread
/// walk, so they can never disagree about who the parent is.
///
/// `parents` is ordered root-first, `replies` oldest-first. The
/// `viewer_*` flags are only populated when a `viewer_did` was passed;
/// `None` means "unknown", not "false".
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadFullResponse {
pub post: Option<PostDto>,
#[serde(default)]
pub parents: Vec<PostDto>,
#[serde(default)]
pub root: Option<PostDto>,
#[serde(default)]
pub replies: Vec<PostDto>,
#[serde(default)]
pub like_count: Option<i64>,
#[serde(default)]
pub repost_count: Option<i64>,
#[serde(default)]
pub viewer_liked: Option<bool>,
#[serde(default)]
pub viewer_reposted: Option<bool>,
}
/// One hydrated row of `GET /api/notifications`.
///
/// `kind` is `"like" | "repost" | "follow" | "reply"` — kept as a
/// `String` rather than an enum so an unknown kind added server-side
/// deserialises instead of failing the whole page; the frontend has
/// the same fallback.
///
/// `read_at` is `None` while the notification is unread — that's the
/// state the tray badge keys off.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationDto {
pub id: i64,
pub kind: String,
pub author_did: String,
pub author_handle: String,
#[serde(default)]
pub author_display_name: Option<String>,
#[serde(default)]
pub author_avatar_cid: Option<String>,
/// `null` for `"follow"`; the recipient's own post for
/// `"like"`/`"repost"`; the reply itself for `"reply"`.
#[serde(default)]
pub subject_uri: Option<String>,
#[serde(default)]
pub subject_text: Option<String>,
pub created_at: String,
/// The list's sort key, and the value the client echoes back as
/// `seen_at` when marking the page read.
pub indexed_at: String,
#[serde(default)]
pub read_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationsResponse {
pub notifications: Vec<NotificationDto>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationCountResponse {
pub count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSeenResponse {
pub ok: bool,
pub updated: i64,
}
/// A minimal profile card as returned by `GET /api/followers` and
/// `GET /api/following`. Deliberately not [`ProfileResponse`] — a page
/// of 30 followers would otherwise be 30 post queries server-side.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActorProfileDto {
pub did: String,
/// Without a leading `@` (the UI renders `@{handle}`).
pub handle: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub avatar_cid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActorListResponse {
pub profiles: Vec<ActorProfileDto>,
pub cursor: Option<String>,
}
#[derive(Clone)]
pub struct AppViewClient {
pub base_url: String,
@@ -238,6 +336,196 @@ impl AppViewClient {
.await
.context("appview: post JSON parse")
}
/// `GET /api/thread?uri=…&viewer_did=…` — the full thread: the
/// whole ancestor chain (root first) plus the direct replies
/// (oldest first).
///
/// We use the query-param spelling rather than
/// `/api/thread/<uri>` because the `at://` URI survives a
/// query-string round trip without the manual percent-encoding
/// [`Self::fetch_post`] needs — `reqwest`'s `.query()` does the
/// escaping itself.
pub async fn fetch_thread(
&self,
uri: &str,
viewer_did: Option<&str>,
) -> Result<ThreadFullResponse> {
let mut req = self
.client
.get(format!("{}/api/thread", self.base_url))
.query(&[("uri", uri)]);
if let Some(v) = viewer_did {
req = req.query(&[("viewer_did", v)]);
}
let resp = req
.send()
.await
.context("appview: failed to send thread request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: thread returned {}: {}",
status,
body
));
}
resp
.json::<ThreadFullResponse>()
.await
.context("appview: thread JSON parse")
}
/// `GET /api/notifications?did=&limit=&cursor=` — newest first,
/// same opaque-cursor pagination contract as the timeline.
pub async fn fetch_notifications(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<NotificationsResponse> {
let mut req = self
.client
.get(format!("{}/api/notifications", self.base_url))
.query(&[("did", did), ("limit", &limit.to_string())]);
if let Some(c) = cursor {
req = req.query(&[("cursor", c)]);
}
let resp = req
.send()
.await
.context("appview: failed to send notifications request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: notifications returned {}: {}",
status,
body
));
}
resp
.json::<NotificationsResponse>()
.await
.context("appview: notifications JSON parse")
}
/// `GET /api/notifications/count?did=` — unread count for the
/// NavRail badge. Cheap enough to poll (partial index on the
/// server side).
pub async fn notification_count(&self, did: &str) -> Result<NotificationCountResponse> {
let resp = self
.client
.get(format!("{}/api/notifications/count", self.base_url))
.query(&[("did", did)])
.send()
.await
.context("appview: failed to send notification-count request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: notification count returned {}: {}",
status,
body
));
}
resp
.json::<NotificationCountResponse>()
.await
.context("appview: notification count JSON parse")
}
/// `POST /api/notifications/seen` — mark everything indexed at or
/// before `seen_at` as read. Passing `None` marks *all* currently
/// unread rows. Idempotent; a second call reports `updated: 0`.
///
/// The server accepts both `seenAt` and `seen_at`; we send the
/// camelCase spelling because that's what the wire contract
/// documents.
pub async fn mark_notifications_seen(
&self,
did: &str,
seen_at: Option<&str>,
) -> Result<NotificationSeenResponse> {
let mut body = serde_json::json!({ "did": did });
if let Some(ts) = seen_at {
body["seenAt"] = Value::String(ts.to_string());
}
let resp = self
.client
.post(format!("{}/api/notifications/seen", self.base_url))
.json(&body)
.send()
.await
.context("appview: failed to send notifications-seen request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: notifications seen returned {}: {}",
status,
body
));
}
resp
.json::<NotificationSeenResponse>()
.await
.context("appview: notifications seen JSON parse")
}
/// `GET /api/followers?did=&limit=&cursor=`
pub async fn fetch_followers(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ActorListResponse> {
self.actor_list("followers", did, cursor, limit).await
}
/// `GET /api/following?did=&limit=&cursor=`
pub async fn fetch_following(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ActorListResponse> {
self.actor_list("following", did, cursor, limit).await
}
/// Shared body of [`Self::fetch_followers`] and
/// [`Self::fetch_following`] — the two endpoints have an identical
/// request and response shape and differ only in the path segment.
async fn actor_list(
&self,
path: &str,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<ActorListResponse> {
let mut req = self
.client
.get(format!("{}/api/{}", self.base_url, path))
.query(&[("did", did), ("limit", &limit.to_string())]);
if let Some(c) = cursor {
req = req.query(&[("cursor", c)]);
}
let resp = req
.send()
.await
.with_context(|| format!("appview: failed to send {path} request"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("appview: {} returned {}: {}", path, status, body));
}
resp
.json::<ActorListResponse>()
.await
.with_context(|| format!("appview: {path} JSON parse"))
}
}
/// Percent-encode every byte of `s` for use as a URL path segment.
+132
View File
@@ -381,6 +381,29 @@ async fn profile_get(
.map_err(|e| e.to_string())
}
/// `profile_get_by_did(did)` — resolve a profile by DID rather than
/// by handle (`GET /api/profile?did=…`).
///
/// This is the *only* correct lookup for an actor that came out of a
/// notification or a follower list: the AppView synthesises a
/// placeholder `handle` (a truncated DID, see `short_did_bare`) for
/// actors it has neither a `profiles` row nor an indexed post for, and
/// feeding that placeholder back into the handle lookup matches
/// nothing — `resolve_profile` then answers with a synthetic empty
/// profile. The DID in the notification / actor DTO is real, so we
/// navigate by that instead.
#[tauri::command]
async fn profile_get_by_did(
state: tauri::State<'_, AppState>,
did: String,
) -> Result<appview_client::ProfileResponse, String> {
state
.appview
.fetch_profile_by_did(&did)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn search(
state: tauri::State<'_, AppState>,
@@ -407,6 +430,108 @@ async fn post_get(
.map_err(|e| e.to_string())
}
/// `fetch_thread(uri, viewer_did?)` — the full thread around `uri`:
/// the whole ancestor chain (root first) plus the direct replies
/// (oldest first). `post_get` stays the narrower two-hop shape the
/// timeline's inline thread modal uses; this is what a dedicated
/// thread view wants.
#[tauri::command]
async fn fetch_thread(
state: tauri::State<'_, AppState>,
uri: String,
viewer_did: Option<String>,
) -> Result<appview_client::ThreadFullResponse, String> {
state
.appview
.fetch_thread(&uri, viewer_did.as_deref())
.await
.map_err(|e| e.to_string())
}
/// `fetch_notifications(did, cursor?, limit?)` — one page of the
/// recipient's notifications, newest first. Same limit clamp as
/// `timeline_home` (the server clamps too, but doing it here means a
/// bad `limit` never costs a round trip).
#[tauri::command]
async fn fetch_notifications(
state: tauri::State<'_, AppState>,
did: String,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<appview_client::NotificationsResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
state
.appview
.fetch_notifications(&did, cursor.as_deref(), lim)
.await
.map_err(|e| e.to_string())
}
/// `notification_count(did)` — unread count for the NavRail badge.
#[tauri::command]
async fn notification_count(
state: tauri::State<'_, AppState>,
did: String,
) -> Result<appview_client::NotificationCountResponse, String> {
state
.appview
.notification_count(&did)
.await
.map_err(|e| e.to_string())
}
/// `mark_notifications_seen(did, seen_at?)` — mark every notification
/// indexed at or before `seen_at` as read. The frontend passes the
/// `indexed_at` of the topmost row it actually rendered, so a
/// notification that lands mid-scroll is never silently swallowed.
/// Omitting `seen_at` marks everything currently unread.
#[tauri::command]
async fn mark_notifications_seen(
state: tauri::State<'_, AppState>,
did: String,
seen_at: Option<String>,
) -> Result<appview_client::NotificationSeenResponse, String> {
state
.appview
.mark_notifications_seen(&did, seen_at.as_deref())
.await
.map_err(|e| e.to_string())
}
/// `fetch_followers(did, cursor?, limit?)` — one page of the actors
/// who follow `did`.
#[tauri::command]
async fn fetch_followers(
state: tauri::State<'_, AppState>,
did: String,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<appview_client::ActorListResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
state
.appview
.fetch_followers(&did, cursor.as_deref(), lim)
.await
.map_err(|e| e.to_string())
}
/// `fetch_following(did, cursor?, limit?)` — one page of the actors
/// `did` follows.
#[tauri::command]
async fn fetch_following(
state: tauri::State<'_, AppState>,
did: String,
cursor: Option<String>,
limit: Option<u32>,
) -> Result<appview_client::ActorListResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
state
.appview
.fetch_following(&did, cursor.as_deref(), lim)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn status_pds(state: tauri::State<'_, AppState>) -> Result<serde_json::Value, String> {
let sess = state.store.load();
@@ -791,8 +916,15 @@ pub fn run() {
resolve_handle,
timeline_home,
profile_get,
profile_get_by_did,
search,
post_get,
fetch_thread,
fetch_notifications,
notification_count,
mark_notifications_seen,
fetch_followers,
fetch_following,
like_post,
unlike_post,
repost_post,
+1 -1
View File
@@ -40,7 +40,7 @@
"https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "",
"_comment": "Auto-update is disabled for dev. To enable for releases: (1) stand up a release-artifacts server that serves update.json, (2) run `tauri signer generate` and paste the pubkey here, (3) flip active+dialog to true. Capabilities already include `updater:default` so the frontend can request update checks via the plugin once enabled."
"_comment": "Auto-update is inert in dev: nothing in the app calls the updater's check(), and `pubkey` is empty. Note that `active` and `dialog` above are Tauri v1 leftovers — the v2 updater plugin ignores unknown keys, so they do NOT switch anything on or off. The full production path (signer keys, pubkey, endpoints, bundle.createUpdaterArtifacts, latest.json format, per-platform build + artifact paths, and the still-missing check() call) is documented in docs/tauri-release.md. Enable it via a release config overlay passed to `tauri build --config`, so this dev config stays as is. Capabilities already include `updater:default` (check/download/install)."
}
},
"bundle": {
+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}
+201
View File
@@ -370,6 +370,24 @@ export async function fetchProfile(handle: string): Promise<ProfileResponse> {
return await safeInvoke<ProfileResponse>("profile_get", { handle });
}
/// Resolve a profile by DID (`GET /api/profile?did=…`) instead of by
/// handle.
///
/// Use this — not [`fetchProfile`] — whenever the actor came out of a
/// notification or a follower/following list. The AppView synthesises
/// a placeholder `handle` for actors it has neither a profile row nor
/// an indexed post for: a truncated DID with an ellipsis, e.g.
/// `"did:plc:f5…"`. That string matches nothing on the way back in, and
/// the server answers with a synthetic empty profile (`did: ""`, zero
/// posts) rather than an error — so a handle-based navigation from
/// those lists is a silent dead end. The DID in the DTO is always
/// real, so navigate by that.
export async function fetchProfileByDid(
did: string,
): Promise<ProfileResponse> {
return await safeInvoke<ProfileResponse>("profile_get_by_did", { did });
}
export async function fetchSearch(
q: string,
limit: number = 30,
@@ -381,6 +399,189 @@ export async function fetchPost(uri: string): Promise<ThreadResponse> {
return await safeInvoke<ThreadResponse>("post_get", { uri });
}
/// `GET /api/thread?uri=…` — the *full* thread around a post: the
/// entire ancestor chain plus the direct replies, in one round trip.
///
/// `parents` is ordered root-first (so `parents[parents.length - 1]`
/// is the immediate parent) and `replies` oldest-first. A `parents[0]`
/// whose own `parent_uri` is non-null means "the chain continues
/// above but wasn't loaded" — the walk is depth-limited server-side.
///
/// `post` is `null` when the URI isn't in the index; the counters are
/// then absent too, so a single field check renders "post not found".
/// `viewer_liked` / `viewer_reposted` are `undefined` when no
/// `viewerDid` was passed — that means "unknown", not "false".
export type ThreadFullResponse = {
post: Post | null;
parents: Post[];
root: Post | null;
replies: Post[];
like_count?: number;
repost_count?: number;
viewer_liked?: boolean;
viewer_reposted?: boolean;
};
export async function fetchThread(
uri: string,
viewerDid: string | null = null,
): Promise<ThreadFullResponse> {
return await safeInvoke<ThreadFullResponse>("fetch_thread", {
uri,
viewerDid,
});
}
/// One row of `GET /api/notifications`, hydrated server-side with the
/// author's profile and the subject post's text so the list renders
/// without any follow-up fetch.
///
/// `kind` is typed as the four known values plus `string` so an
/// unknown kind added upstream still type-checks here; the UI's
/// `notificationText` falls back to a generic line.
export type NotificationKind = "like" | "repost" | "follow" | "reply";
export type Notification = {
id: number;
kind: NotificationKind | string;
author_did: string;
/// Without a leading `@` (the UI renders `@{handle}`). Never null —
/// the AppView falls back to a truncated DID.
author_handle: string;
author_display_name?: string | null;
author_avatar_cid?: string | null;
/// The post this is about. `null` for `"follow"`. For
/// `"like"`/`"repost"` it's the recipient's own post; for `"reply"`
/// it's the reply itself.
subject_uri: string | null;
subject_text?: string | null;
created_at: string;
/// When the AppView indexed it — the list's sort key and the value
/// to echo back as `seenAt`.
indexed_at: string;
/// `null` while unread.
read_at: string | null;
};
export type NotificationsResponse = {
notifications: Notification[];
cursor: string | null;
};
/// German UI copy for a notification row. Kept next to the type (and
/// exported) so the mapping is unit-testable without mounting a
/// component. An unrecognised `kind` gets a neutral fallback rather
/// than an empty line.
export function notificationText(kind: string): string {
switch (kind) {
case "like":
return "hat deinen Post geliked";
case "repost":
return "hat repostet";
case "follow":
return "folgt dir jetzt";
case "reply":
return "hat geantwortet";
default:
return "hat interagiert";
}
}
/// Single-glyph icon for a notification row, same switch as
/// [`notificationText`]. Monospace glyphs rather than SVGs so the
/// list keeps the terminal look of the rest of the UI.
export function notificationIcon(kind: string): string {
switch (kind) {
case "like":
return "♥";
case "repost":
return "⇄";
case "follow":
return "+";
case "reply":
return "↩";
default:
return "•";
}
}
/// One page of notifications, newest first. Same opaque-cursor
/// contract as [`fetchTimeline`]: pass the previous response's
/// `cursor` to page down, and `cursor === null` means end of list.
export async function fetchNotifications(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<NotificationsResponse> {
return await safeInvoke<NotificationsResponse>("fetch_notifications", {
did,
cursor,
limit,
});
}
/// Unread-notification count for the NavRail badge. Cheap enough to
/// poll on the same cadence as the timeline refresh.
export async function notificationCount(did: string): Promise<number> {
const r = await safeInvoke<{ count: number }>("notification_count", { did });
return r.count;
}
/// Mark every notification indexed at or before `seenAt` as read.
/// Pass the `indexed_at` of the topmost row the user actually sees, so
/// a notification arriving mid-scroll isn't swallowed. `null` marks
/// everything currently unread. Idempotent — a repeat call reports
/// `updated: 0`.
export async function markNotificationsSeen(
did: string,
seenAt: string | null = null,
): Promise<{ ok: boolean; updated: number }> {
return await safeInvoke<{ ok: boolean; updated: number }>(
"mark_notifications_seen",
{ did, seenAt },
);
}
/// A minimal profile card from `GET /api/followers` / `/api/following`.
/// Deliberately not a full `ProfileResponse` — a page of 30 followers
/// would otherwise be 30 post queries server-side. Click a row and the
/// UI navigates to the full profile by `handle`.
export type ActorProfile = {
did: string;
handle: string;
display_name?: string | null;
avatar_cid?: string | null;
};
export type ActorListResponse = {
profiles: ActorProfile[];
cursor: string | null;
};
export async function fetchFollowers(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<ActorListResponse> {
return await safeInvoke<ActorListResponse>("fetch_followers", {
did,
cursor,
limit,
});
}
export async function fetchFollowing(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<ActorListResponse> {
return await safeInvoke<ActorListResponse>("fetch_following", {
did,
cursor,
limit,
});
}
/// `app.bsky.feed.like.create` — Tauri command. Builds the
/// flat-shape like body on the Rust side, signs a commit, pushes
/// to the AppView. Returns the new like's `uri` and `cid`.
@@ -0,0 +1,368 @@
// Unit tests for the notification / actor-list half of `client.ts`.
//
// Same setup as `client.test.ts`: `@tauri-apps/api/core` is mocked so
// no Tauri shell is needed, and every assertion is about the exact
// command name + argument bag we hand the Rust IPC layer. That
// argument bag is the contract — Tauri's `invoke` serialises camelCase
// JS keys to the snake_case Rust command parameters, so a typo here
// surfaces at runtime as "command not found" or a null argument, not
// at compile time.
//
// Covered:
// * `fetchNotifications` — command name, args, wire→object mapping
// (snake_case fields pass through verbatim), cursor pagination;
// * `notificationCount` — unwraps `{ count }` to a number;
// * `markNotificationsSeen` — sends the `seenAt` watermark;
// * `notificationText` / `notificationIcon` — the kind→copy map,
// including the fallback for an unknown kind;
// * `fetchFollowers` / `fetchFollowing` — distinct commands, same
// shape.
//
// Run with:
// npx vitest run src/lib/api/notifications.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const invokeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
isTauri: () => true,
}));
beforeEach(() => {
invokeMock.mockReset();
});
/// A full-fat wire row, exactly as `NotificationItem` serialises it
/// server-side (snake_case, `read_at` present even when null).
function wireNotification(over: Record<string, unknown> = {}) {
return {
id: 1,
kind: "like",
author_did: "did:plc:alice",
author_handle: "alice.test",
author_display_name: "Alice",
author_avatar_cid: "bafyavatar",
subject_uri: "at://did:plc:me/app.twi.post/3k2",
subject_text: "hello world",
created_at: "2026-09-09T10:00:00Z",
indexed_at: "2026-09-09T10:00:01Z",
read_at: null,
...over,
};
}
describe("fetchNotifications", () => {
it("invokes fetch_notifications and maps the wire rows verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [wireNotification()],
cursor: "cur1",
});
const r = await fetchNotifications("did:plc:me");
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.cursor).toBe("cur1");
expect(r.notifications).toHaveLength(1);
const n = r.notifications[0];
expect(n.id).toBe(1);
expect(n.kind).toBe("like");
expect(n.author_handle).toBe("alice.test");
expect(n.author_display_name).toBe("Alice");
expect(n.author_avatar_cid).toBe("bafyavatar");
expect(n.subject_uri).toBe("at://did:plc:me/app.twi.post/3k2");
expect(n.subject_text).toBe("hello world");
expect(n.indexed_at).toBe("2026-09-09T10:00:01Z");
// `read_at: null` is the unread marker the badge keys off — it
// must survive as null, not become undefined.
expect(n.read_at).toBeNull();
});
it("forwards the cursor and limit for a follow-up page", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({ notifications: [], cursor: null });
const r = await fetchNotifications("did:plc:me", "cur1", 50);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: "cur1",
limit: 50,
});
// A null cursor is the documented end-of-list sentinel.
expect(r.cursor).toBeNull();
});
it("handles a follow row (no subject) without inventing fields", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [
{
id: 7,
kind: "follow",
author_did: "did:plc:bob",
author_handle: "bob.test",
subject_uri: null,
created_at: "2026-09-09T09:00:00Z",
indexed_at: "2026-09-09T09:00:01Z",
read_at: "2026-09-09T09:30:00Z",
},
],
cursor: null,
});
const [n] = (await fetchNotifications("did:plc:me")).notifications;
expect(n.kind).toBe("follow");
expect(n.subject_uri).toBeNull();
expect(n.subject_text).toBeUndefined();
expect(n.author_display_name).toBeUndefined();
expect(n.read_at).toBe("2026-09-09T09:30:00Z");
});
it("propagates a Tauri-side error verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockRejectedValueOnce(
new Error("appview: notifications returned 400: did is required"),
);
await expect(fetchNotifications("")).rejects.toThrow(
/notifications returned 400/,
);
});
});
describe("notificationCount", () => {
it("unwraps the {count} envelope to a plain number", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 12 });
const n = await notificationCount("did:plc:me");
expect(n).toBe(12);
expect(invokeMock).toHaveBeenCalledWith("notification_count", {
did: "did:plc:me",
});
});
it("returns 0 for a fully-read inbox", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 0 });
await expect(notificationCount("did:plc:me")).resolves.toBe(0);
});
});
describe("markNotificationsSeen", () => {
it("sends the seenAt watermark", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 4 });
const r = await markNotificationsSeen(
"did:plc:me",
"2026-09-09T10:00:01Z",
);
expect(r).toEqual({ ok: true, updated: 4 });
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: "2026-09-09T10:00:01Z",
});
});
it("omitting the watermark sends null (mark everything read)", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 0 });
await markNotificationsSeen("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: null,
});
});
});
describe("notificationText / notificationIcon", () => {
it("maps each kind to its German line", async () => {
const { notificationText } = await import("./client");
expect(notificationText("like")).toBe("hat deinen Post geliked");
expect(notificationText("repost")).toBe("hat repostet");
expect(notificationText("follow")).toBe("folgt dir jetzt");
expect(notificationText("reply")).toBe("hat geantwortet");
});
it("falls back rather than rendering an empty line for an unknown kind", async () => {
const { notificationText } = await import("./client");
expect(notificationText("quote")).toBe("hat interagiert");
expect(notificationText("")).toBe("hat interagiert");
});
it("gives every known kind its own icon glyph", async () => {
const { notificationIcon } = await import("./client");
const icons = ["like", "repost", "follow", "reply"].map(notificationIcon);
expect(new Set(icons).size).toBe(4);
expect(icons.every((i) => i.length > 0)).toBe(true);
expect(notificationIcon("quote")).toBe("•");
});
});
describe("fetchFollowers / fetchFollowing", () => {
const wireProfile = {
did: "did:plc:carol",
handle: "carol.test",
display_name: "Carol",
avatar_cid: "bafycarol",
};
it("fetchFollowers hits the followers command", async () => {
const { fetchFollowers } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: null,
});
const r = await fetchFollowers("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_followers", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.profiles[0].handle).toBe("carol.test");
expect(r.profiles[0].display_name).toBe("Carol");
expect(r.cursor).toBeNull();
});
it("fetchFollowing hits the following command and pages", async () => {
const { fetchFollowing } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: "cur2",
});
const r = await fetchFollowing("did:plc:me", "cur1", 10);
expect(invokeMock).toHaveBeenCalledWith("fetch_following", {
did: "did:plc:me",
cursor: "cur1",
limit: 10,
});
expect(r.cursor).toBe("cur2");
});
});
describe("fetchProfileByDid", () => {
it("invokes profile_get_by_did with the DID", async () => {
const { fetchProfileByDid } = await import("./client");
invokeMock.mockResolvedValueOnce({
did: "did:plc:f5abcdefghijklmnop",
handle: "alice.test",
posts: [],
followers: 2,
following: 3,
post_count: 0,
});
const p = await fetchProfileByDid("did:plc:f5abcdefghijklmnop");
expect(invokeMock).toHaveBeenCalledWith("profile_get_by_did", {
did: "did:plc:f5abcdefghijklmnop",
});
expect(p.did).toBe("did:plc:f5abcdefghijklmnop");
expect(p.followers).toBe(2);
});
it("resolves an actor whose only known handle is a truncated-DID placeholder", async () => {
// The dead end this endpoint exists to avoid: the AppView hands
// us `handle: "did:plc:f5a…"` (from `short_did_bare`) for an actor
// it has no profile row or indexed post for. Feeding that back
// into the *handle* lookup matches nothing and the server answers
// 200 with a synthetic empty profile — `did: ""`, zero counts —
// so the UI shows a blank page instead of an error. The DID
// lookup resolves the real row.
const { fetchProfile, fetchProfileByDid } = await import("./client");
const placeholderHandle = "did:plc:f5a…";
const realDid = "did:plc:f5abcdefghijklmnop";
// 1. What the handle path actually gets back today.
invokeMock.mockResolvedValueOnce({
did: "",
handle: placeholderHandle,
posts: [],
followers: 0,
following: 0,
post_count: 0,
});
const viaHandle = await fetchProfile(placeholderHandle);
expect(viaHandle.did).toBe("");
expect(viaHandle.post_count).toBe(0);
// 2. What the DID path gets back — the real profile.
invokeMock.mockResolvedValueOnce({
did: realDid,
handle: "alice.test",
posts: [],
followers: 5,
following: 1,
post_count: 12,
});
const viaDid = await fetchProfileByDid(realDid);
expect(viaDid.did).toBe(realDid);
expect(viaDid.handle).toBe("alice.test");
expect(viaDid.post_count).toBe(12);
// The two calls must be distinct commands — a shared one would
// reintroduce the ambiguity.
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
"profile_get",
"profile_get_by_did",
]);
});
});
describe("fetchThread", () => {
it("invokes fetch_thread with the viewer DID", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
const r = await fetchThread("at://did:plc:me/app.twi.post/3k2", "did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: "did:plc:me",
});
// `post: null` is the "not in our index" sentinel — one field
// check is enough to render "post not found".
expect(r.post).toBeNull();
expect(r.parents).toEqual([]);
expect(r.replies).toEqual([]);
});
it("defaults the viewer DID to null", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
await fetchThread("at://did:plc:me/app.twi.post/3k2");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: null,
});
});
});
@@ -4,18 +4,35 @@
// `$bindable`, use a callback prop to bubble state changes up to
// the parent.
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
let {
view = "home",
on_select,
unread = 0,
}: {
view?: View;
on_select?: (v: View) => void;
/// Unread-notification count from `/api/notifications/count`.
/// The parent polls it and hands it down; `0` (or anything
/// below) hides the badge entirely rather than rendering a "0".
unread?: number;
} = $props();
/// Badge label. Anything past 99 collapses to "99+" so a long
/// unread backlog can't widen the 88px rail.
const badge = $derived(unread > 99 ? "99+" : String(unread));
const items: Array<{ id: View; label: string; key: string; icon: string }> = [
{ id: "home", label: "home", key: "g h", icon: "home" },
{ id: "notifications", label: "notifs", key: "g n", icon: "bell" },
{ id: "compose", label: "compose", key: "c", icon: "compose" },
{ id: "profile", label: "profile", key: "p", icon: "profile" },
{ id: "search", label: "search", key: "/", icon: "search" },
@@ -37,6 +54,10 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M3 11l9-8 9 8v9a2 2 0 0 1-2 2h-3v-7H8v7H5a2 2 0 0 1-2-2z"/>
</svg>
{:else if item.icon === "bell"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 15v-4a6 6 0 1 0-12 0v4l-2 3h16z"/><path d="M10 21h4"/>
</svg>
{:else if item.icon === "compose"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<text x="3" y="17" font-family="ui-monospace,monospace" font-size="14" font-weight="700" fill="currentColor" stroke="none">&gt;_</text>
@@ -55,6 +76,16 @@
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
</svg>
{/if}
{#if item.id === "notifications" && unread > 0}
<!-- Live region so a screen reader announces a badge that
appears while the rail is already on screen. -->
<span
class="badge"
data-testid="notif-badge"
aria-live="polite"
aria-label={`${unread} ungelesene Benachrichtigungen`}
>{badge}</span>
{/if}
</span>
<span class="label">{item.label}</span>
</button>
@@ -107,6 +138,26 @@
height: 24px;
display: grid;
place-items: center;
/* Anchor for the unread badge, which overhangs the glyph's
top-right corner the way a tray badge does. */
position: relative;
}
.badge {
position: absolute;
top: -6px;
right: -10px;
min-width: 16px;
padding: 0 4px;
border-radius: var(--r-pill);
background: var(--orange);
color: var(--bg);
font-family: var(--font-mono);
font-size: 10px;
line-height: 16px;
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.icon :global(svg) {
width: 22px;
@@ -5,6 +5,10 @@
//
// Pattern: `let view = $state("home")` in the harness, then the
// `on_select` callback updates it (same shape as App.svelte).
//
// The rail order is: home, notifications, compose, profile, search,
// settings. The indices below follow that order — if you reorder the
// `items` array in NavRail.svelte, update them here too.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mount, unmount, tick } from "svelte";
@@ -23,16 +27,16 @@ afterEach(() => {
target.remove();
});
async function mountHarness() {
app = mount(NavRailHarness, { target });
async function mountHarness(props: { unread?: number } = {}) {
app = mount(NavRailHarness, { target, props });
await tick();
}
describe("NavRail (callback-prop pattern)", () => {
it("renders 5 buttons with home active", async () => {
it("renders 6 buttons with home active", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
expect(btns.length).toBe(5);
expect(btns.length).toBe(6);
expect(btns[0].classList.contains("active")).toBe(true);
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
});
@@ -41,25 +45,68 @@ describe("NavRail (callback-prop pattern)", () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("search");
expect(btns[3].classList.contains("active")).toBe(true);
expect(btns[4].classList.contains("active")).toBe(true);
expect(btns[0].classList.contains("active")).toBe(false);
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
expect(btns[1].classList.contains("active")).toBe(true);
btns[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
expect(btns[2].classList.contains("active")).toBe(true);
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(btns[3].classList.contains("active")).toBe(true);
btns[5].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings");
expect(btns[4].classList.contains("active")).toBe(true);
expect(btns[5].classList.contains("active")).toBe(true);
});
it("routes to the notifications view", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe(
"notifications",
);
expect(btns[1].classList.contains("active")).toBe(true);
});
});
describe("NavRail unread badge", () => {
it("renders no badge at zero unread", async () => {
await mountHarness({ unread: 0 });
expect(target.querySelector('[data-testid="notif-badge"]')).toBeNull();
});
it("renders the count on the notifications button", async () => {
await mountHarness({ unread: 3 });
const badge = target.querySelector('[data-testid="notif-badge"]');
expect(badge).not.toBeNull();
expect(badge?.textContent?.trim()).toBe("3");
// The badge must sit on the notifications entry, not on some
// other rail button.
const btns = target.querySelectorAll("button.rail__btn");
expect(btns[1].contains(badge!)).toBe(true);
});
it("caps a large backlog at 99+ so the 88px rail can't widen", async () => {
await mountHarness({ unread: 1234 });
const badge = target.querySelector('[data-testid="notif-badge"]');
expect(badge?.textContent?.trim()).toBe("99+");
});
it("keeps 99 unabbreviated (the cap is exclusive)", async () => {
await mountHarness({ unread: 99 });
expect(
target.querySelector('[data-testid="notif-badge"]')?.textContent?.trim(),
).toBe("99");
});
});
@@ -5,12 +5,26 @@
import NavRail from "./NavRail.svelte";
type View = "home" | "compose" | "profile" | "user" | "search" | "settings";
type View =
| "home"
| "notifications"
| "compose"
| "profile"
| "user"
| "search"
| "settings";
// `unread` is a prop so the badge test can drive it without going
// through the polling path in App.svelte. Defaults to 0 (no badge),
// which is what every non-badge test expects.
let { unread = 0 }: { unread?: number } = $props();
let view: View = $state("home");
</script>
<NavRail
{view}
{unread}
on_select={(v) => {
view = v;
}}
@@ -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>
@@ -0,0 +1,219 @@
// Regression guard for the actor-navigation path out of the
// notifications list.
//
// The AppView guarantees `author_handle` is non-empty, but it does NOT
// guarantee it's a real handle: for an author it has neither a
// `profiles` row nor an indexed post for, it synthesises a placeholder
// from the DID (`short_did_bare` in the AppView's `routes.rs`) — e.g.
// `"did:plc:f5…"`, a truncated DID with an ellipsis. Navigating with
// that string resolves to nothing, and `/api/profile/<handle>` answers
// with a *synthetic empty profile* rather than an error, so the click
// silently dead-ends on a blank page.
//
// The fix is to hand the navigation callback the real `author_did`
// alongside the handle. These tests pin that: the callback must
// receive the DID from the DTO, for both a real-handle author and a
// placeholder-handle one.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mount, unmount, tick } from "svelte";
const fetchNotificationsMock = vi.fn();
const markNotificationsSeenMock = vi.fn();
vi.mock("../api/client", async () => {
// Keep the real `notificationText` / `notificationIcon` — the row
// copy is part of what we're rendering — and stub only the two
// functions that would otherwise need a Tauri runtime.
const actual =
await vi.importActual<typeof import("../api/client")>("../api/client");
return {
...actual,
fetchNotifications: (...args: unknown[]) => fetchNotificationsMock(...args),
markNotificationsSeen: (...args: unknown[]) =>
markNotificationsSeenMock(...args),
// Avatar resolves blobs through this; every fixture below has a
// null avatar CID so it never fires, but stub it so a stray call
// can't reach for the Tauri shell.
fetchBlob: () => Promise.reject(new Error("no blob in test")),
};
});
import NotificationsView from "./NotificationsView.svelte";
let target: HTMLDivElement;
let app: ReturnType<typeof mount> | null = null;
beforeEach(() => {
target = document.createElement("div");
document.body.appendChild(target);
fetchNotificationsMock.mockReset();
markNotificationsSeenMock.mockReset();
markNotificationsSeenMock.mockResolvedValue({ ok: true, updated: 1 });
});
afterEach(() => {
if (app) unmount(app);
app = null;
target.remove();
});
/// Let the component's load effect and its awaited promises settle.
/// The mocked fetch resolves immediately, so a handful of microtask
/// turns plus a Svelte flush is enough.
async function flush(turns = 6) {
for (let i = 0; i < turns; i++) {
await Promise.resolve();
await tick();
}
}
function row(over: Record<string, unknown> = {}) {
return {
id: 1,
kind: "like",
author_did: "did:plc:realauthor",
author_handle: "alice.test",
author_avatar_cid: null,
subject_uri: "at://did:plc:me/app.twi.post/3k2",
subject_text: "hello",
created_at: "2026-09-09T10:00:00Z",
indexed_at: "2026-09-09T10:00:01Z",
read_at: null,
...over,
};
}
describe("NotificationsView actor navigation", () => {
it("hands the callback the author DID, not just the handle", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row()],
cursor: null,
});
const onActorClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_actor_click: onActorClick },
});
await flush();
const handleBtn = target.querySelector("button.notifs__handle");
expect(handleBtn).not.toBeNull();
handleBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onActorClick).toHaveBeenCalledTimes(1);
expect(onActorClick).toHaveBeenCalledWith(
"did:plc:realauthor",
"alice.test",
);
});
it("resolves an author whose handle is only a truncated-DID placeholder", async () => {
// Exactly what the AppView returns for an author it has never
// seen post and has no profile row for: `handle` is
// `short_did_bare(author_did)`, which matches nothing on the way
// back in. The DID beside it is real.
fetchNotificationsMock.mockResolvedValue({
notifications: [
row({
id: 2,
kind: "follow",
author_did: "did:plc:f5abcdefghijklmnop",
author_handle: "did:plc:f5a…",
subject_uri: null,
subject_text: undefined,
}),
],
cursor: null,
});
const onActorClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_actor_click: onActorClick },
});
await flush();
target
.querySelector("button.notifs__handle")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
const [didArg, handleArg] = onActorClick.mock.calls[0];
// The DID must be the full, real one — never the truncated
// display string.
expect(didArg).toBe("did:plc:f5abcdefghijklmnop");
expect(didArg).not.toContain("…");
// The placeholder still travels as the label, which is fine — it
// is not what the lookup keys off.
expect(handleArg).toBe("did:plc:f5a…");
});
it("renders the kind copy and marks the first page seen at the top row", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row({ kind: "follow", subject_uri: null })],
cursor: null,
});
const onSeen = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_seen: onSeen },
});
await flush();
expect(target.textContent).toContain("folgt dir jetzt");
// The read watermark is the newest rendered row's `indexed_at` —
// never null, so a notification landing mid-scroll survives.
expect(markNotificationsSeenMock).toHaveBeenCalledWith(
"did:plc:me",
"2026-09-09T10:00:01Z",
);
expect(onSeen).toHaveBeenCalledTimes(1);
});
it("a follow row has no thread target, so its row button is inert", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row({ kind: "follow", subject_uri: null })],
cursor: null,
});
const onThreadClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_thread_click: onThreadClick },
});
await flush();
const rowBtn = target.querySelector<HTMLButtonElement>("button.notifs__row");
expect(rowBtn?.disabled).toBe(true);
rowBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onThreadClick).not.toHaveBeenCalled();
});
it("opens the thread for a row that has a subject", async () => {
fetchNotificationsMock.mockResolvedValue({
notifications: [row()],
cursor: null,
});
const onThreadClick = vi.fn();
app = mount(NotificationsView, {
target,
props: { did: "did:plc:me", on_thread_click: onThreadClick },
});
await flush();
target
.querySelector("button.notifs__row")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(onThreadClick).toHaveBeenCalledWith(
"at://did:plc:me/app.twi.post/3k2",
);
});
});
@@ -9,22 +9,49 @@
getAppviewUrl,
followUser,
unfollowUser,
fetchFollowers,
fetchFollowing,
fetchProfileByDid,
showInfo,
showError,
type ActorProfile,
} from "../api/client";
import { localStorageKey } from "../utils/localstorage";
import { onDestroy, onMount, untrack } from "svelte";
import { onDestroy, untrack } from "svelte";
type Props = {
handle: string;
/// DID of the profile to show, when the caller already knows it.
/// Takes precedence over `handle`: the AppView synthesises a
/// placeholder handle (a truncated DID — `short_did_bare`) for
/// actors it has neither a `profiles` row nor an indexed post for,
/// and feeding that back into the handle lookup resolves to a
/// synthetic empty profile. Navigation out of a notification or a
/// follower list therefore passes the DID; `handle` is still
/// supplied so the header has a label while the fetch is in
/// flight. `null`/absent keeps the handle lookup (PostCard header,
/// search, the current user's own profile).
did?: string | null;
on_thread_click?: (uri: string) => void;
/// DID of the authenticated user. When this matches the
/// profile's DID, the "edit profile" button is shown; the
/// /user-profile/<handle> route is then the user's own
/// profile (and the avatar / bio are editable).
current_user_did?: string | null;
/// Navigate to another profile. Receives `(did, handle)` and the
/// caller resolves by DID — see the `did` prop above for why the
/// handle alone is not a reliable key. Used by the follower /
/// following list rows and by the PostCards in the feed (which
/// carry a real `post.did`).
on_actor_click?: (did: string, handle: string) => void;
};
let { handle, on_thread_click, current_user_did }: Props = $props();
let {
handle,
did = null,
on_thread_click,
current_user_did,
on_actor_click,
}: Props = $props();
type State =
| { kind: "loading" }
@@ -91,6 +118,15 @@
async function load() {
viewModel = { kind: "loading" };
try {
// DID lookup wins when the caller supplied one — see the `did`
// prop. `/api/profile?did=…` is an exact match on the `posts` /
// `profiles` DID column, so it works for an actor whose only
// known "handle" is the server's truncated-DID placeholder.
if (did) {
const data = (await fetchProfileByDid(did)) as AppViewProfile;
viewModel = { kind: "ready", data };
return;
}
// Absolute URL because the Tauri webview's origin is the Vite
// dev server (port 1430), not the AppView (port 2584) — a
// relative `/api/profile/…` would resolve against Vite, hit a
@@ -145,8 +181,25 @@
};
});
onMount(() => {
void load();
/// Identity of the profile currently loaded, as an opaque key. A
/// plain (non-`$state`) variable on purpose: the effect below reads
/// it, and making it reactive would turn "remember what we loaded"
/// into a self-write that trips Svelte 5's depth guard — the same
/// pattern the banner effect documents around `untrack`.
let loadedKey: string | null = null;
// Reload whenever the profile *identity* changes, not just on
// mount. App.svelte keeps this component mounted across a
// profile→profile navigation (the view stays `"user"`, only the
// props change), so an `onMount`-only load would leave the previous
// user's posts and counts on screen under the new name — which is
// exactly the path a follower-list or notification click takes.
$effect(() => {
const key = did ? `did:${did}` : `handle:${handle}`;
if (!did && !handle) return;
if (loadedKey === key) return;
loadedKey = key;
untrack(() => void load());
});
onDestroy(() => {
@@ -299,6 +352,93 @@
type Tab = "posts" | "replies" | "likes";
let activeTab: Tab = $state("posts");
// ─── follower / following list ──────────────────────────────────
//
// Clicking a count opens an inline list under the counts row rather
// than navigating away — the user came here for this profile, and a
// separate route would cost a second profile fetch on the way back.
// Clicking the same count again closes it (the counts double as the
// toggle, the way a disclosure does).
type ActorListKind = "followers" | "following";
let actorKind: ActorListKind | null = $state(null);
let actors: ActorProfile[] = $state([]);
let actorCursor: string | null = $state(null);
let actorLoading: boolean = $state(false);
let actorError: string | null = $state(null);
async function toggleActorList(kind: ActorListKind) {
if (actorKind === kind) {
closeActorList();
return;
}
if (viewModel.kind !== "ready") return;
const did = viewModel.data.did;
actorKind = kind;
actors = [];
actorCursor = null;
actorError = null;
actorLoading = true;
try {
const r =
kind === "followers"
? await fetchFollowers(did, null, 30)
: await fetchFollowing(did, null, 30);
// Guard against a fast double-click on the other count: only
// commit if we're still the list the user asked for.
if (actorKind !== kind) return;
actors = r.profiles;
actorCursor = r.cursor;
} catch (e) {
if (actorKind === kind) actorError = String(e);
} finally {
if (actorKind === kind) actorLoading = false;
}
}
async function loadMoreActors() {
if (viewModel.kind !== "ready") return;
const kind = actorKind;
if (!kind || !actorCursor || actorLoading) return;
const did = viewModel.data.did;
actorLoading = true;
try {
const r =
kind === "followers"
? await fetchFollowers(did, actorCursor, 30)
: await fetchFollowing(did, actorCursor, 30);
if (actorKind !== kind) return;
// De-dupe on DID — a follow edge indexed between two page
// fetches can otherwise repeat a row across the boundary.
const seen = new Set(actors.map((a) => a.did));
actors = [...actors, ...r.profiles.filter((a) => !seen.has(a.did))];
actorCursor = r.cursor;
} catch (e) {
if (actorKind === kind) actorError = String(e);
} finally {
if (actorKind === kind) actorLoading = false;
}
}
function closeActorList() {
actorKind = null;
actors = [];
actorCursor = null;
actorError = null;
actorLoading = false;
}
// Switching to another profile must not leave the previous user's
// follower list on screen. Keyed on the same `(did, handle)`
// identity the load effect uses, so the two can't disagree about
// when "the profile changed".
let actorListKey: string | null = null;
$effect(() => {
const key = did ? `did:${did}` : `handle:${handle}`;
if (actorListKey === key) return;
actorListKey = key;
untrack(() => closeActorList());
});
</script>
<section class="profile">
@@ -426,17 +566,105 @@
<dt>posts</dt>
<dd>{viewModel.data.post_count}</dd>
</div>
<!--
The follower / following counts are buttons: they open the
matching actor list inline (and close it on a second click).
The post count stays inert — there's no endpoint behind it
that the feed below doesn't already show.
The button lives *inside* the `<dd>` rather than wrapping the
`dt`/`dd` pair, because a `<dl>` may only contain `dt`/`dd`
(via an optional `div`) — a button in between would be invalid
markup. `aria-label` puts the term back on the control so a
screen reader still hears "followers", not a bare number.
-->
<div>
<dt>followers</dt>
<dd>{viewModel.data.followers}</dd>
<dd>
<button
class="profile__count-btn"
class:profile__count-btn--open={actorKind === "followers"}
type="button"
aria-expanded={actorKind === "followers"}
aria-label={`${viewModel.data.followers} followers anzeigen`}
onclick={() => void toggleActorList("followers")}
>{viewModel.data.followers}</button>
</dd>
</div>
<div>
<dt>following</dt>
<dd>{viewModel.data.following}</dd>
<dd>
<button
class="profile__count-btn"
class:profile__count-btn--open={actorKind === "following"}
type="button"
aria-expanded={actorKind === "following"}
aria-label={`${viewModel.data.following} following anzeigen`}
onclick={() => void toggleActorList("following")}
>{viewModel.data.following}</button>
</dd>
</div>
</dl>
{/if}
<!-- ─── follower / following list ─────────────────────────────── -->
{#if actorKind}
<div class="actors">
<header class="actors__head">
<span class="actors__title">// {actorKind}</span>
<button
class="actors__close"
type="button"
onclick={closeActorList}
>close</button>
</header>
{#if actorError}
<div class="actors__err">err: {actorError}</div>
{/if}
{#if actorLoading && actors.length === 0}
<div class="actors__empty">// loading…</div>
{:else if actors.length === 0}
<div class="actors__empty">// niemand hier.</div>
{:else}
<ul class="actors__list">
{#each actors as a (a.did)}
<li>
<button
class="actors__row"
type="button"
title={a.did}
onclick={() => on_actor_click?.(a.did, a.handle)}
>
<Avatar
did={a.did}
cid={a.avatar_cid ?? null}
name={a.display_name ?? a.handle}
size={32}
/>
<span class="actors__names">
<span class="actors__name">{a.display_name ?? a.handle}</span>
<span class="actors__handle">@{a.handle}</span>
</span>
</button>
</li>
{/each}
</ul>
{#if actorCursor}
<div class="actors__loadmore">
<button
class="actors__more"
type="button"
disabled={actorLoading}
onclick={() => void loadMoreActors()}
>
{actorLoading ? "loading…" : "load more"}
</button>
</div>
{/if}
{/if}
</div>
{/if}
<!-- ─── tabs ──────────────────────────────────────────────────── -->
<nav class="profile__tabs" aria-label="Profile sections">
<button
@@ -465,7 +693,17 @@
<div class="profile__feed">
{#if viewModel.kind === "ready"}
{#each viewModel.data.posts as p (p.uri)}
<PostCard post={p} on_thread_click={on_thread_click} />
<!--
The feed's PostCards carry a real `post.did`, so we route
their header clicks through the same DID-first path as the
actor list rather than through the (possibly decorated)
`post.handle`.
-->
<PostCard
post={p}
on_thread_click={on_thread_click}
on_handle_click={(h) => on_actor_click?.(p.did, h)}
/>
{/each}
{#if viewModel.data.posts.length === 0}
<div class="profile__empty">// no posts yet.</div>
@@ -705,6 +943,157 @@
font-variant-numeric: tabular-nums;
}
/* The count button inherits the `dd` typography so the clickable
followers/following numbers read identically to the inert post
count — only the hover/open colour marks them as interactive. */
.profile__count-btn {
background: none;
border: 0;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
cursor: pointer;
border-bottom: 2px solid transparent;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.profile__count-btn:hover {
color: var(--orange);
}
.profile__count-btn--open {
color: var(--orange);
border-bottom-color: var(--orange);
}
/* ─── follower / following list ─────────────────────────── */
.actors {
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: var(--bg-elev);
}
.actors__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
border-bottom: 1px solid var(--line);
}
.actors__title {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--orange);
letter-spacing: var(--tracking-label);
font-weight: 700;
}
.actors__close {
background: transparent;
border: 1px solid var(--line-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: var(--fs-50);
padding: 0.2rem 0.6rem;
border-radius: var(--r-sm);
cursor: pointer;
transition:
color var(--dur) var(--ease),
border-color var(--dur) var(--ease);
}
.actors__close:hover {
color: var(--orange);
border-color: var(--orange);
}
.actors__err {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--red);
padding: var(--s-2) var(--s-4);
}
.actors__empty {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
font-style: italic;
padding: var(--s-3) var(--s-4);
}
.actors__list {
list-style: none;
margin: 0;
padding: 0;
/* Cap the inline list so it never pushes the feed off screen —
"load more" keeps the rest reachable. */
max-height: 320px;
overflow-y: auto;
}
.actors__row {
width: 100%;
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-4);
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
text-align: left;
cursor: pointer;
color: var(--text);
transition: background-color var(--dur) var(--ease);
}
.actors__row:hover {
background: var(--orange-8);
}
.actors__names {
display: flex;
flex-direction: column;
min-width: 0;
}
.actors__name {
font-family: var(--font-mono);
font-size: var(--fs-50);
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actors__handle {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actors__loadmore {
display: flex;
justify-content: center;
padding: var(--s-3);
}
.actors__more {
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);
}
.actors__more:hover:not(:disabled) {
color: var(--orange);
border-color: var(--orange);
}
.actors__more:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ─── tabs ──────────────────────────────────────────────── */
.profile__tabs {
display: flex;