feat(tauri-app): Notifications-View mit Badge, Follower-Listen, DID-Navigation
Bindet die neuen AppView-Endpoints an: sechs IPC-Commands
(fetch_notifications, notification_count, mark_notifications_seen,
fetch_followers, fetch_following, fetch_thread) plus profile_get_by_did,
dazu die TS-Gegenstücke.
* NotificationsView: Liste mit Icon/Text je Art, Avatar, Vorschau des
subject_text, Cursor-Pagination; Klick auf eine Zeile mit Subject öffnet
den Thread. Beim Öffnen wird mit dem indexed_at der obersten Zeile als
Wasserzeichen quittiert.
* NavRail: Eintrag mit Unread-Badge, gepollt im vorhandenen 5s-Timer und
über den bestehenden Teardown-Pfad abgeräumt; der Poll pausiert, solange
die Liste offen ist.
* ProfileView: Follower- und Following-Zahlen sind jetzt Buttons und öffnen
die jeweilige Liste inline.
Navigiert wird über die DID, nicht über den Handle: für Actors, die weder
profiles noch posts kennen, liefert die AppView einen abgeschnittenen
Platzhalter im handle-Feld ("did:plc:abcd…"), und ein Klick darauf landete
in einem synthetischen Leerprofil. Die echte DID steht im DTO und wird jetzt
explizit durchgereicht — keine Heuristik auf das Platzhalter-Format.
Dabei aufgefallen und mitgefixt: ProfileView lud nur in onMount, obwohl die
Komponente bei Profil-zu-Profil-Navigation gemountet bleibt — Posts und
Zahlen des vorherigen Nutzers wären unter dem neuen Namen stehengeblieben.
Jetzt ein auf (did, handle) gekeyter Effekt.
Das Thread-Overlay hing im else-Zweig der leeren Timeline und wäre aus dem
Notifications-View unsichtbar gewesen; es ist jetzt ein Snippet, das beide
Views rendern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
co-authored by
Claude Opus 5
parent
bce4c7862f
commit
31880e1005
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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">>_</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,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");
|
||||
</script>
|
||||
|
||||
<NavRail
|
||||
{view}
|
||||
{unread}
|
||||
on_select={(v) => {
|
||||
view = v;
|
||||
}}
|
||||
/>
|
||||
<span data-testid="view-value">{view}</span>
|
||||
<span data-testid="view-value">{view}</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user