diff --git a/crates/tauri-app/src-tauri/src/appview_client.rs b/crates/tauri-app/src-tauri/src/appview_client.rs index c7887e4..e219572 100644 --- a/crates/tauri-app/src-tauri/src/appview_client.rs +++ b/crates/tauri-app/src-tauri/src/appview_client.rs @@ -77,6 +77,104 @@ pub struct ThreadView { pub root: Option, } +/// `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, + #[serde(default)] + pub parents: Vec, + #[serde(default)] + pub root: Option, + #[serde(default)] + pub replies: Vec, + #[serde(default)] + pub like_count: Option, + #[serde(default)] + pub repost_count: Option, + #[serde(default)] + pub viewer_liked: Option, + #[serde(default)] + pub viewer_reposted: Option, +} + +/// 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, + #[serde(default)] + pub author_avatar_cid: Option, + /// `null` for `"follow"`; the recipient's own post for + /// `"like"`/`"repost"`; the reply itself for `"reply"`. + #[serde(default)] + pub subject_uri: Option, + #[serde(default)] + pub subject_text: Option, + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationsResponse { + pub notifications: Vec, + pub cursor: Option, +} + +#[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, + #[serde(default)] + pub avatar_cid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActorListResponse { + pub profiles: Vec, + pub cursor: Option, +} + #[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/` 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 { + 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::() + .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 { + 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::() + .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 { + 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::() + .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 { + 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::() + .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 { + 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 { + 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 { + 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::() + .await + .with_context(|| format!("appview: {path} JSON parse")) + } } /// Percent-encode every byte of `s` for use as a URL path segment. diff --git a/crates/tauri-app/src-tauri/src/lib.rs b/crates/tauri-app/src-tauri/src/lib.rs index 6a31bb9..d213254 100644 --- a/crates/tauri-app/src-tauri/src/lib.rs +++ b/crates/tauri-app/src-tauri/src/lib.rs @@ -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 { + 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, +) -> Result { + 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, + limit: Option, +) -> Result { + 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 { + 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, +) -> Result { + 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, + limit: Option, +) -> Result { + 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, + limit: Option, +) -> Result { + 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 { 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, diff --git a/crates/tauri-app/src-tauri/tauri.conf.json b/crates/tauri-app/src-tauri/tauri.conf.json index 8201aa6..1a847ed 100644 --- a/crates/tauri-app/src-tauri/tauri.conf.json +++ b/crates/tauri-app/src-tauri/tauri.conf.json @@ -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": { diff --git a/crates/tauri-app/src/App.svelte b/crates/tauri-app/src/App.svelte index f0ccc7d..6d23b3e 100644 --- a/crates/tauri-app/src/App.svelte +++ b/crates/tauri-app/src/App.svelte @@ -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). `` 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 = 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 - /// `` and the avatar/handle buttons in - /// the post header. The actual profile fetch happens inside - /// `` on mount. + /// `` on the timeline / search results and + /// the avatar/handle buttons in the post header. The actual profile + /// fetch happens inside ``. + /// + /// 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 `` 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 @@ } + +{#snippet threadModal()} + {#if threadRoot || threadLoading || threadError} +
+
+ // thread + +
+ {#if threadLoading} + + {:else if threadError} +
err: {threadError}
+ {:else if threadRoot} + {#if threadParent && threadParent.uri !== threadRoot.uri} +
+ {/if} + + {/if} +
+ {/if} +{/snippet} + {#if !currentUser} {/if} diff --git a/crates/tauri-app/src/lib/api/client.ts b/crates/tauri-app/src/lib/api/client.ts index 9397232..70c22a0 100644 --- a/crates/tauri-app/src/lib/api/client.ts +++ b/crates/tauri-app/src/lib/api/client.ts @@ -370,6 +370,24 @@ export async function fetchProfile(handle: string): Promise { return await safeInvoke("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 { + return await safeInvoke("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 { return await safeInvoke("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 { + return await safeInvoke("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 { + return await safeInvoke("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 { + 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 { + return await safeInvoke("fetch_followers", { + did, + cursor, + limit, + }); +} + +export async function fetchFollowing( + did: string, + cursor: string | null = null, + limit: number = 30, +): Promise { + return await safeInvoke("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`. diff --git a/crates/tauri-app/src/lib/api/notifications.test.ts b/crates/tauri-app/src/lib/api/notifications.test.ts new file mode 100644 index 0000000..9b06a1b --- /dev/null +++ b/crates/tauri-app/src/lib/api/notifications.test.ts @@ -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 = {}) { + 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, + }); + }); +}); diff --git a/crates/tauri-app/src/lib/components/NavRail.svelte b/crates/tauri-app/src/lib/components/NavRail.svelte index ce77e68..05e92eb 100644 --- a/crates/tauri-app/src/lib/components/NavRail.svelte +++ b/crates/tauri-app/src/lib/components/NavRail.svelte @@ -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 @@ + {:else if item.icon === "bell"} + + + {:else if item.icon === "compose"} >_ @@ -55,6 +76,16 @@ {/if} + {#if item.id === "notifications" && unread > 0} + + {badge} + {/if} {item.label} @@ -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; diff --git a/crates/tauri-app/src/lib/components/NavRail.test.ts b/crates/tauri-app/src/lib/components/NavRail.test.ts index 9814ed1..4ff2214 100644 --- a/crates/tauri-app/src/lib/components/NavRail.test.ts +++ b/crates/tauri-app/src/lib/components/NavRail.test.ts @@ -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); }); -}); \ No newline at end of file + + 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"); + }); +}); diff --git a/crates/tauri-app/src/lib/components/NavRailHarness.svelte b/crates/tauri-app/src/lib/components/NavRailHarness.svelte index b00bdd2..3898674 100644 --- a/crates/tauri-app/src/lib/components/NavRailHarness.svelte +++ b/crates/tauri-app/src/lib/components/NavRailHarness.svelte @@ -5,14 +5,28 @@ 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"); { view = v; }} /> -{view} \ No newline at end of file +{view} diff --git a/crates/tauri-app/src/lib/components/NotificationsView.svelte b/crates/tauri-app/src/lib/components/NotificationsView.svelte new file mode 100644 index 0000000..e888a07 --- /dev/null +++ b/crates/tauri-app/src/lib/components/NotificationsView.svelte @@ -0,0 +1,366 @@ + + +
+ {#if error} +
err: {error}
+ {/if} + + {#if loading && items.length === 0} + + {:else if items.length === 0} +
// keine Benachrichtigungen.
+ {:else} +
    + {#each items as n (n.id)} +
  • + + + +
  • + {/each} +
+ {#if cursor} +
+ +
+ {/if} + {/if} +
+ + diff --git a/crates/tauri-app/src/lib/components/NotificationsView.test.ts b/crates/tauri-app/src/lib/components/NotificationsView.test.ts new file mode 100644 index 0000000..10a56fc --- /dev/null +++ b/crates/tauri-app/src/lib/components/NotificationsView.test.ts @@ -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/` 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("../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 | 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 = {}) { + 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("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", + ); + }); +}); diff --git a/crates/tauri-app/src/lib/components/ProfileView.svelte b/crates/tauri-app/src/lib/components/ProfileView.svelte index 9b070fc..9494189 100644 --- a/crates/tauri-app/src/lib/components/ProfileView.svelte +++ b/crates/tauri-app/src/lib/components/ProfileView.svelte @@ -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/ 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()); + });
@@ -426,17 +566,105 @@
posts
{viewModel.data.post_count}
+
followers
-
{viewModel.data.followers}
+
+ +
following
-
{viewModel.data.following}
+
+ +
{/if} + + {#if actorKind} +
+
+ // {actorKind} + +
+ {#if actorError} +
err: {actorError}
+ {/if} + {#if actorLoading && actors.length === 0} +
// loading…
+ {:else if actors.length === 0} +
// niemand hier.
+ {:else} +
    + {#each actors as a (a.did)} +
  • + +
  • + {/each} +
+ {#if actorCursor} +
+ +
+ {/if} + {/if} +
+ {/if} +