BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement notifications system with polling and UI updates

+967 -34
+2
README.md
··· 2 2 3 3 This app is the "successor" to [bsky-browser](https://tangled.org/did:plc:xg2vq45muivyy3xwatcehspu/skybidi) and companion to [Lazurite for Mobile](https://github.com/stormlightlabs/lazurite). 4 4 5 + ![main feed](docs/screens/main.png) 6 + 5 7 ## Features 6 8 7 9 - Account switching
docs/screens/main.png

This is a binary file and will not be displayed.

+15 -10
docs/tasks/04-notifications.md
··· 6 6 7 7 ### Tauri 8 8 9 - - [ ] Create `src-tauri/src/notifications.rs` 9 + - [x] Create `src-tauri/src/notifications.rs` 10 10 - `src-tauri/src/commands/notifications.rs` for Tauri commands 11 - - [ ] `list_notifications(cursor: Option<String>)` — `app.bsky.notification.listNotifications` 12 - - [ ] `update_seen()` — `app.bsky.notification.updateSeen` 13 - - [ ] `get_unread_count()` — `app.bsky.notification.getUnreadCount` 14 - - [ ] Background polling: spawn async task on login, poll every 30s, emit Tauri event on new notifications 15 - - [ ] System notifications via `tauri-plugin-notification` for mentions when app is in background 11 + - [x] `list_notifications(cursor: Option<String>)` — `app.bsky.notification.listNotifications` 12 + - [x] `update_seen()` — `app.bsky.notification.updateSeen` 13 + - [x] `get_unread_count()` — `app.bsky.notification.getUnreadCount` 14 + - [x] Background polling: spawn async task on login, poll every 30s, emit Tauri event on new notifications 15 + - [x] System notifications via `tauri-plugin-notification` for mentions when app is in background 16 16 17 17 ## Frontend 18 18 19 - - [ ] notifications panel with two tabs — Mentions / Activity (Aeronaut pattern) 20 - - [ ] unread badge on sidebar notification icon with `Motion` scale-in pop 21 - - [ ] new notification items `Motion` slide-in from top 22 - - [ ] tab switch `Presence` crossfade between Mentions/Activity 19 + - [x] notifications panel with two tabs — Mentions / Activity (Aeronaut pattern) 20 + - [x] unread badge on sidebar notification icon with `Motion` scale-in pop 21 + - [x] new notification items `Motion` slide-in from top 22 + - [x] tab switch `Presence` crossfade between Mentions/Activity 23 + 24 + ## Tests 25 + 26 + - [x] Frontend tests for notification payload parsing, rail unread badge, route wiring, and notifications panel behavior 27 + - [x] Rust unit tests for mention-notification formatting and dedupe helpers
+1
src-tauri/src/lib.rs
··· 26 26 app.manage(app_state); 27 27 28 28 AppState::spawn_token_refresh_task(app.handle().clone()); 29 + notifications::spawn_notification_poll_task(app.handle().clone()); 29 30 30 31 let app_handle = app.handle().clone(); 31 32 app.deep_link().on_open_url(move |event| {
+262 -6
src-tauri/src/notifications.rs
··· 6 6 use jacquard::api::app_bsky::notification::update_seen::UpdateSeen; 7 7 use jacquard::types::datetime::Datetime; 8 8 use jacquard::xrpc::XrpcClient; 9 + use std::collections::VecDeque; 9 10 use std::sync::Arc; 11 + use std::time::Duration; 12 + use tauri::{AppHandle, Emitter, Manager}; 10 13 use tauri_plugin_log::log; 14 + use tauri_plugin_notification::NotificationExt; 15 + 16 + pub const NOTIFICATIONS_UNREAD_COUNT_EVENT: &str = "notifications:unread-count"; 17 + 18 + const MAIN_WINDOW_LABEL: &str = "main"; 19 + const POLL_INITIAL_DELAY: Duration = Duration::from_secs(5); 20 + const POLL_INTERVAL: Duration = Duration::from_secs(30); 21 + const MAX_SYSTEM_NOTIFICATIONS: usize = 3; 22 + const MAX_TRACKED_NOTIFICATION_URIS: usize = 128; 11 23 12 24 async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> { 13 25 let did = state ··· 40 52 }) 41 53 } 42 54 55 + fn active_session_did(state: &AppState) -> Result<Option<String>> { 56 + Ok(state 57 + .active_session 58 + .read() 59 + .map_err(|error| { 60 + log::error!("active_session poisoned: {error}"); 61 + AppError::StatePoisoned("active_session") 62 + })? 63 + .as_ref() 64 + .map(|session| session.did.clone())) 65 + } 66 + 43 67 pub async fn list_notifications(cursor: Option<String>, state: &AppState) -> Result<serde_json::Value> { 44 68 let session = get_session(state).await?; 45 69 let mut req = ListNotifications::new().limit(50i64); ··· 66 90 pub async fn update_seen(state: &AppState) -> Result<()> { 67 91 let session = get_session(state).await?; 68 92 69 - session 93 + let response = session 70 94 .send(UpdateSeen::new().seen_at(Datetime::now()).build()) 71 95 .await 72 96 .map_err(|error| { 73 97 log::error!("updateSeen error: {error}"); 74 98 AppError::validation("updateSeen error") 75 - })? 76 - .into_output() 77 - .map_err(|error| { 78 - log::error!("updateSeen output error: {error}"); 79 - AppError::validation("updateSeen output error") 80 99 })?; 81 100 101 + if response.status().is_success() { 102 + return Ok(()); 103 + } 104 + 105 + response.into_output().map_err(|error| { 106 + log::error!("updateSeen output error: {error}"); 107 + AppError::validation("updateSeen output error") 108 + })?; 109 + 82 110 Ok(()) 83 111 } 84 112 ··· 100 128 101 129 Ok(output.count) 102 130 } 131 + 132 + /// Returns a human-readable system notification body for a mention notification, 133 + /// or `None` if the reason is not one that warrants a system notification. 134 + pub fn mention_notification_body(reason: &str, handle: &str) -> Option<String> { 135 + match reason { 136 + "mention" => Some(format!("@{handle} mentioned you")), 137 + "reply" => Some(format!("@{handle} replied to you")), 138 + "quote" => Some(format!("@{handle} quoted your post")), 139 + _ => None, 140 + } 141 + } 142 + 143 + fn is_main_window_focused(app: &AppHandle) -> bool { 144 + app.get_webview_window(MAIN_WINDOW_LABEL) 145 + .and_then(|w| w.is_focused().ok()) 146 + .unwrap_or(false) 147 + } 148 + 149 + fn collect_new_mention_notifications( 150 + notifications_value: &serde_json::Value, notified_uris: &VecDeque<String>, 151 + ) -> Vec<(String, String)> { 152 + let Some(notifications) = notifications_value.get("notifications").and_then(|v| v.as_array()) else { 153 + return Vec::new(); 154 + }; 155 + 156 + let mut new_mentions = Vec::new(); 157 + 158 + for notification in notifications { 159 + if new_mentions.len() >= MAX_SYSTEM_NOTIFICATIONS { 160 + break; 161 + } 162 + 163 + let is_read = notification.get("isRead").and_then(|v| v.as_bool()).unwrap_or(true); 164 + if is_read { 165 + continue; 166 + } 167 + 168 + let reason = notification.get("reason").and_then(|v| v.as_str()).unwrap_or(""); 169 + let handle = notification 170 + .get("author") 171 + .and_then(|v| v.get("handle")) 172 + .and_then(|v| v.as_str()) 173 + .unwrap_or("someone"); 174 + 175 + let Some(body) = mention_notification_body(reason, handle) else { 176 + continue; 177 + }; 178 + 179 + let Some(uri) = notification.get("uri").and_then(|v| v.as_str()) else { 180 + continue; 181 + }; 182 + 183 + if notified_uris.iter().any(|existing| existing == uri) { 184 + continue; 185 + } 186 + 187 + new_mentions.push((uri.to_owned(), body)); 188 + } 189 + 190 + new_mentions 191 + } 192 + 193 + fn remember_notified_uri(notified_uris: &mut VecDeque<String>, uri: String) { 194 + if notified_uris.iter().any(|existing| existing == &uri) { 195 + return; 196 + } 197 + 198 + notified_uris.push_front(uri); 199 + 200 + while notified_uris.len() > MAX_TRACKED_NOTIFICATION_URIS { 201 + notified_uris.pop_back(); 202 + } 203 + } 204 + 205 + fn send_mention_system_notifications( 206 + app: &AppHandle, notifications_value: &serde_json::Value, notified_uris: &mut VecDeque<String>, 207 + ) { 208 + for (uri, body) in collect_new_mention_notifications(notifications_value, notified_uris) { 209 + match app.notification().builder().title("Lazurite").body(body).show() { 210 + Ok(_) => remember_notified_uri(notified_uris, uri), 211 + Err(error) => log::warn!("failed to show system notification: {error}"), 212 + } 213 + } 214 + } 215 + 216 + /// Spawns a background task that polls for new notifications every 30 seconds 217 + /// and emits a `notifications:unread-count` event when the count changes. 218 + /// System notifications are shown for new mentions when the app is not focused. 219 + pub fn spawn_notification_poll_task(app: AppHandle) { 220 + tauri::async_runtime::spawn(async move { 221 + tokio::time::sleep(POLL_INITIAL_DELAY).await; 222 + 223 + let mut last_count: i64 = -1; 224 + let mut last_did: Option<String> = None; 225 + let mut notified_uris = VecDeque::new(); 226 + 227 + loop { 228 + let state = app.state::<AppState>(); 229 + 230 + let active_did = match active_session_did(&state) { 231 + Ok(value) => value, 232 + Err(error) => { 233 + log::warn!("notification poll failed to read active session: {error}"); 234 + tokio::time::sleep(POLL_INTERVAL).await; 235 + continue; 236 + } 237 + }; 238 + 239 + if active_did.is_none() { 240 + last_count = -1; 241 + last_did = None; 242 + notified_uris.clear(); 243 + tokio::time::sleep(POLL_INTERVAL).await; 244 + continue; 245 + } 246 + 247 + if active_did != last_did { 248 + last_count = -1; 249 + last_did = active_did; 250 + notified_uris.clear(); 251 + } 252 + 253 + match get_unread_count(&state).await { 254 + Ok(count) => { 255 + if last_count >= 0 && count > last_count { 256 + log::info!("new notifications: unread count increased from {last_count} to {count}"); 257 + let _ = app.emit(NOTIFICATIONS_UNREAD_COUNT_EVENT, count); 258 + 259 + if !is_main_window_focused(&app) { 260 + if let Ok(value) = list_notifications(None, &state).await { 261 + send_mention_system_notifications(&app, &value, &mut notified_uris); 262 + } 263 + } 264 + } else if last_count != count { 265 + let _ = app.emit(NOTIFICATIONS_UNREAD_COUNT_EVENT, count); 266 + } 267 + 268 + last_count = count; 269 + } 270 + Err(_) => { 271 + log::debug!("notification poll skipped"); 272 + } 273 + } 274 + 275 + tokio::time::sleep(POLL_INTERVAL).await; 276 + } 277 + }); 278 + } 279 + 280 + #[cfg(test)] 281 + mod tests { 282 + use super::{collect_new_mention_notifications, mention_notification_body, remember_notified_uri}; 283 + use serde_json::json; 284 + use std::collections::VecDeque; 285 + 286 + #[test] 287 + fn mention_reason_formats_correctly() { 288 + let body = mention_notification_body("mention", "alice.bsky.social").unwrap(); 289 + assert_eq!(body, "@alice.bsky.social mentioned you"); 290 + } 291 + 292 + #[test] 293 + fn reply_reason_formats_correctly() { 294 + let body = mention_notification_body("reply", "bob.bsky.social").unwrap(); 295 + assert_eq!(body, "@bob.bsky.social replied to you"); 296 + } 297 + 298 + #[test] 299 + fn quote_reason_formats_correctly() { 300 + let body = mention_notification_body("quote", "carol.bsky.social").unwrap(); 301 + assert_eq!(body, "@carol.bsky.social quoted your post"); 302 + } 303 + 304 + #[test] 305 + fn non_mention_reasons_return_none() { 306 + assert!(mention_notification_body("like", "alice.bsky.social").is_none()); 307 + assert!(mention_notification_body("repost", "alice.bsky.social").is_none()); 308 + assert!(mention_notification_body("follow", "alice.bsky.social").is_none()); 309 + assert!(mention_notification_body("starterpack-joined", "alice.bsky.social").is_none()); 310 + } 311 + 312 + #[test] 313 + fn only_new_mention_notifications_are_collected() { 314 + let mut notified_uris = VecDeque::new(); 315 + remember_notified_uri(&mut notified_uris, "at://notification/1".into()); 316 + 317 + let notifications = json!({ 318 + "notifications": [ 319 + { 320 + "author": { "handle": "alice.bsky.social" }, 321 + "isRead": false, 322 + "reason": "mention", 323 + "uri": "at://notification/1" 324 + }, 325 + { 326 + "author": { "handle": "bob.bsky.social" }, 327 + "isRead": false, 328 + "reason": "reply", 329 + "uri": "at://notification/2" 330 + }, 331 + { 332 + "author": { "handle": "carol.bsky.social" }, 333 + "isRead": true, 334 + "reason": "quote", 335 + "uri": "at://notification/3" 336 + } 337 + ] 338 + }); 339 + 340 + let new_mentions = collect_new_mention_notifications(&notifications, &notified_uris); 341 + 342 + assert_eq!( 343 + new_mentions, 344 + vec![("at://notification/2".into(), "@bob.bsky.social replied to you".into())] 345 + ); 346 + } 347 + 348 + #[test] 349 + fn remembering_notified_uris_avoids_duplicates() { 350 + let mut notified_uris = VecDeque::new(); 351 + 352 + remember_notified_uri(&mut notified_uris, "at://notification/1".into()); 353 + remember_notified_uri(&mut notified_uris, "at://notification/1".into()); 354 + 355 + assert_eq!(notified_uris.len(), 1); 356 + assert_eq!(notified_uris.front().map(String::as_str), Some("at://notification/1")); 357 + } 358 + }
+26 -2
src/App.tsx
··· 4 4 logout as logoutRequest, 5 5 switchAccount as switchAccountRequest, 6 6 } from "$/lib/api/app"; 7 + import { getUnreadCount } from "$/lib/api/notifications"; 7 8 import { listen } from "@tauri-apps/api/event"; 8 9 import { getCurrentWindow } from "@tauri-apps/api/window"; 9 10 import { createEffect, createMemo, onCleanup, onMount, Show, startTransition } from "solid-js"; ··· 16 17 import { ComposerWindow } from "./components/feeds/ComposerWindow"; 17 18 import { FeedWorkspace } from "./components/feeds/FeedWorkspace"; 18 19 import { LoginPanel } from "./components/LoginPanel"; 20 + import { NotificationsPanel } from "./components/notifications/NotificationsPanel"; 19 21 import { HeaderPanel } from "./components/panels/Header"; 20 22 import { SessionSpotlight } from "./components/Session"; 21 23 import { ErrorToast } from "./components/shared/ErrorToast"; 22 - import { ACCOUNT_SWITCH_EVENT } from "./lib/constants/events"; 24 + import { ACCOUNT_SWITCH_EVENT, NOTIFICATIONS_UNREAD_COUNT_EVENT } from "./lib/constants/events"; 23 25 import type { AccountSummary, ActiveSession } from "./lib/types"; 24 26 import { AppRouter } from "./router"; 25 27 ··· 40 42 shakeCount: number; 41 43 showSwitcher: boolean; 42 44 switchingDid: string | null; 45 + unreadNotifications: number; 43 46 }; 44 47 45 48 function createInitialAppState(): AppState { ··· 57 60 shakeCount: 0, 58 61 showSwitcher: false, 59 62 switchingDid: null, 63 + unreadNotifications: 0, 60 64 }; 61 65 } 62 66 ··· 94 98 setApp("accounts", payload.accountList); 95 99 setApp("reauthNeeded", payload.accountList.length > 0 && !payload.activeSession); 96 100 }); 101 + 102 + if (payload.activeSession) { 103 + try { 104 + setApp("unreadNotifications", await getUnreadCount()); 105 + } catch { 106 + setApp("unreadNotifications", 0); 107 + } 108 + } else { 109 + setApp("unreadNotifications", 0); 110 + } 97 111 } catch (error) { 98 112 setApp("errorMessage", `Failed to load app bootstrap: ${String(error)}`); 99 113 } finally { ··· 198 212 unlisten = dispose; 199 213 }); 200 214 215 + let unlistenUnread: (() => void) | undefined; 216 + void listen<number>(NOTIFICATIONS_UNREAD_COUNT_EVENT, (event) => { 217 + setApp("unreadNotifications", event.payload); 218 + }).then((dispose) => { 219 + unlistenUnread = dispose; 220 + }); 221 + 201 222 onCleanup(() => { 202 223 unlisten?.(); 224 + unlistenUnread?.(); 203 225 media.removeEventListener("change", syncViewport); 204 226 }); 205 227 }); ··· 223 245 logoutDid={app.logoutDid} 224 246 narrow={app.narrowViewport} 225 247 openSwitcher={app.showSwitcher} 248 + unreadNotifications={app.unreadNotifications} 226 249 onCloseSwitcher={closeSwitcher} 227 250 switchingDid={app.switchingDid} 228 251 onLogout={(did) => void logout(did)} ··· 316 339 onError={(message) => setApp("errorMessage", message)} 317 340 onThreadRouteChange={context.onThreadRouteChange} 318 341 threadUri={context.threadUri} /> 319 - )} /> 342 + )} 343 + renderNotifications={() => <NotificationsPanel onMarkSeen={() => setApp("unreadNotifications", 0)} />} /> 320 344 </Show> 321 345 ); 322 346 }
+13 -3
src/components/AppRail.tsx
··· 25 25 ); 26 26 } 27 27 28 - function RailNavigation(props: { collapsed: boolean; hasSession: boolean }) { 28 + function RailNavigation(props: { collapsed: boolean; hasSession: boolean; unreadNotifications: number }) { 29 29 return ( 30 30 <div class="grid gap-1 max-[1180px]:col-start-2 max-[1180px]:row-start-1 max-[1180px]:flex max-[1180px]:min-w-0 max-[1180px]:items-center max-[1180px]:gap-2 max-[1180px]:overflow-x-auto max-[1180px]:overscroll-contain max-[1180px]:[scrollbar-width:none] max-[1180px]:[&::-webkit-scrollbar]:hidden"> 31 31 <Show ··· 33 33 fallback={<RailButton end compact={props.collapsed} href="/auth" label="Accounts" icon="profile" />}> 34 34 <RailButton end compact={props.collapsed} href="/timeline" label="Timeline" icon="timeline" /> 35 35 <RailButton end compact={props.collapsed} href="/search" label="Search" icon="search" /> 36 - <RailButton end compact={props.collapsed} href="/notifications" label="Notifications" icon="notifications" /> 36 + <RailButton 37 + end 38 + badge={props.unreadNotifications} 39 + compact={props.collapsed} 40 + href="/notifications" 41 + label="Notifications" 42 + icon="notifications" /> 37 43 <RailButton end compact={props.collapsed} href="/explorer" label="Explorer" icon="explorer" /> 38 44 </Show> 39 45 </div> ··· 50 56 logoutDid: string | null; 51 57 narrow: boolean; 52 58 openSwitcher: boolean; 59 + unreadNotifications: number; 53 60 onCloseSwitcher: () => void; 54 61 switchingDid: string | null; 55 62 onLogout: (did: string) => void; ··· 64 71 classList={{ "items-center px-4": props.collapsed && !props.narrow, "gap-5": props.collapsed && !props.narrow }} 65 72 aria-label="Primary navigation"> 66 73 <RailHeader collapsed={props.collapsed} onToggleCollapse={props.onToggleCollapse} /> 67 - <RailNavigation collapsed={props.collapsed} hasSession={props.hasSession} /> 74 + <RailNavigation 75 + collapsed={props.collapsed} 76 + hasSession={props.hasSession} 77 + unreadNotifications={props.unreadNotifications} /> 68 78 <AccountSwitcher 69 79 activeAccount={props.activeAccount} 70 80 activeSession={props.activeSession}
+6
src/components/RailButton.test.tsx
··· 31 31 expect(link.className).toContain("rounded-lg"); 32 32 expect(link.className).not.toContain("rounded-full"); 33 33 }); 34 + 35 + it("renders an unread badge when the count is positive", () => { 36 + renderInRouter(() => <RailButton badge={3} href="/notifications" label="Notifications" icon="notifications" />); 37 + 38 + expect(screen.getByRole("status", { name: "3 unread" })).toBeInTheDocument(); 39 + }); 34 40 });
+25 -3
src/components/RailButton.tsx
··· 1 1 import { A } from "@solidjs/router"; 2 2 import { Show } from "solid-js"; 3 + import { Motion, Presence } from "solid-motionone"; 3 4 import { Icon, type IconKind } from "./shared/Icon"; 4 5 5 - type RailButtonProps = { label: string; href: string; icon: IconKind; compact?: boolean; end?: boolean }; 6 + type RailButtonProps = { 7 + badge?: number; 8 + compact?: boolean; 9 + end?: boolean; 10 + href: string; 11 + icon: IconKind; 12 + label: string; 13 + }; 6 14 7 15 export function RailButton(props: RailButtonProps) { 8 16 return ( 9 17 <A 10 18 href={props.href} 11 19 end={props.end} 12 - class="flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 20 + class="relative flex h-11 shrink-0 items-center gap-2.5 rounded-lg border-0 bg-transparent text-on-surface-variant no-underline transition duration-150 ease-out hover:-translate-y-px hover:bg-surface-bright hover:text-on-surface" 13 21 activeClass="bg-surface-container text-primary" 14 22 inactiveClass="" 15 23 classList={{ "w-[2.75rem] justify-center": !!props.compact, "px-3": !props.compact }} 16 24 aria-label={props.label} 17 25 title={props.label}> 18 - <Icon kind={props.icon} name={props.label} aria-hidden="true" class="shrink-0 text-[1.25rem]" /> 26 + <div class="relative"> 27 + <Icon kind={props.icon} name={props.label} aria-hidden="true" class="shrink-0 text-[1.25rem]" /> 28 + <Presence> 29 + <Show when={(props.badge ?? 0) > 0}> 30 + <Motion.span 31 + class="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-primary" 32 + initial={{ scale: 0, opacity: 0 }} 33 + animate={{ scale: 1, opacity: 1 }} 34 + exit={{ scale: 0, opacity: 0 }} 35 + transition={{ duration: 0.18, easing: [0.34, 1.56, 0.64, 1] }} 36 + aria-label={`${props.badge} unread`} 37 + role="status" /> 38 + </Show> 39 + </Presence> 40 + </div> 19 41 <Show when={!props.compact}> 20 42 <span class="text-sm font-medium leading-none">{props.label}</span> 21 43 </Show>
+2 -2
src/components/Session.tsx
··· 16 16 17 17 export function SessionExpiredState(props: { account: AccountSummary }) { 18 18 return ( 19 - <div class="grid items-center gap-4 [align-content:start] grid-cols-[auto_minmax(0,1fr)]"> 19 + <div class="flex items-center gap-4 [align-content:start] grid-cols-[auto_minmax(0,1fr)]"> 20 20 <AvatarBadge label={props.account.handle || props.account.did} src={props.account.avatar} tone="muted" /> 21 21 <div class="grid"> 22 22 <h2 class="m-0 text-[clamp(1.3rem,2vw,1.7rem)] tracking-[-0.02em]"> 23 23 {props.account.handle || props.account.did} 24 24 </h2> 25 25 <p class="m-0 text-xs text-on-surface-variant">Stored account</p> 26 + <p class="m-0 text-xs text-on-surface-variant">PDS: {props.account.pdsUrl || "PDS unavailable"}</p> 26 27 </div> 27 - <p class="m-0 text-xs text-on-surface-variant">{props.account.pdsUrl || "PDS unavailable"}</p> 28 28 </div> 29 29 ); 30 30 }
+1 -1
src/components/feeds/FeedWorkspaceSidebar.tsx
··· 29 29 }, 30 30 ) { 31 31 return ( 32 - <SidebarCard title="Saved Feeds" subtitle="Drawer access"> 32 + <SidebarCard title="Saved Feeds" subtitle="All your feeds"> 33 33 <div class="grid gap-2"> 34 34 <For each={props.drawerFeeds.slice(0, 4)}> 35 35 {(feed) => (
+123
src/components/notifications/NotificationItem.tsx
··· 1 + import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 2 + import type { NotificationReason, NotificationView } from "$/lib/types"; 3 + import { createMemo, Show } from "solid-js"; 4 + import { Icon } from "../shared/Icon"; 5 + 6 + type ReasonStyle = { color: string; iconClass: string }; 7 + 8 + export function reasonStyle(reason: NotificationReason): ReasonStyle { 9 + switch (reason) { 10 + case "like": { 11 + return { color: "text-[#ff6b6b]", iconClass: "i-ri-heart-3-fill" }; 12 + } 13 + case "repost": { 14 + return { color: "text-[#4cd964]", iconClass: "i-ri-repeat-2-line" }; 15 + } 16 + case "mention": 17 + case "reply": { 18 + return { color: "text-primary", iconClass: "i-ri-chat-3-line" }; 19 + } 20 + case "quote": { 21 + return { color: "text-primary", iconClass: "i-ri-chat-quote-line" }; 22 + } 23 + case "follow": { 24 + return { color: "text-primary", iconClass: "i-ri-user-add-line" }; 25 + } 26 + default: { 27 + return { color: "text-on-surface-variant", iconClass: "i-ri-notification-3-line" }; 28 + } 29 + } 30 + } 31 + 32 + export function reasonText(reason: NotificationReason): string { 33 + switch (reason) { 34 + case "like": { 35 + return "liked your post"; 36 + } 37 + case "repost": { 38 + return "reposted your post"; 39 + } 40 + case "mention": { 41 + return "mentioned you"; 42 + } 43 + case "reply": { 44 + return "replied to you"; 45 + } 46 + case "quote": { 47 + return "quoted your post"; 48 + } 49 + case "follow": { 50 + return "followed you"; 51 + } 52 + default: { 53 + return "interacted with your post"; 54 + } 55 + } 56 + } 57 + 58 + function AuthorAvatar(props: { avatar?: string | null; label: string }) { 59 + return ( 60 + <span 61 + class="inline-flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-container-high text-xs font-semibold text-on-surface-variant" 62 + aria-hidden="true"> 63 + {props.avatar ? <img src={props.avatar} alt="" class="h-full w-full object-cover" /> : props.label} 64 + </span> 65 + ); 66 + } 67 + 68 + type NotificationItemProps = { notification: NotificationView }; 69 + 70 + export function NotificationItem(props: NotificationItemProps) { 71 + const style = createMemo(() => reasonStyle(props.notification.reason)); 72 + const name = createMemo(() => getDisplayName(props.notification.author)); 73 + const description = createMemo(() => reasonText(props.notification.reason)); 74 + const time = createMemo(() => formatRelativeTime(props.notification.indexedAt)); 75 + const avatarLabel = createMemo(() => getAvatarLabel(props.notification.author)); 76 + const postText = createMemo<string | null>(() => { 77 + const record = props.notification.record; 78 + const text = record["text"]; 79 + return typeof text === "string" && text.trim() ? text.trim() : null; 80 + }); 81 + const detail = createMemo(() => postText() ?? followDetail(props.notification)); 82 + 83 + return ( 84 + <article 85 + class="flex items-start gap-4 rounded-2xl px-4 py-4 transition-colors duration-150 hover:bg-surface-container-high" 86 + classList={{ "opacity-60": props.notification.isRead }} 87 + aria-label={`${name()} ${description()}`}> 88 + <div class="flex w-8 shrink-0 justify-center pt-0.5"> 89 + <Icon 90 + iconClass={style().iconClass} 91 + class={`text-base ${style().color}`} 92 + aria-hidden="true" 93 + name={`${name()} ${description()}`} /> 94 + </div> 95 + 96 + <AuthorAvatar avatar={props.notification.author.avatar} label={avatarLabel()} /> 97 + 98 + <div class="min-w-0 flex-1"> 99 + <p class="m-0 text-sm leading-relaxed text-on-surface"> 100 + <span class="font-semibold">{name()}</span> <span class="text-on-surface-variant">{description()}</span> 101 + </p> 102 + 103 + <Show when={detail()}> 104 + {(value) => <p class="mt-1 line-clamp-2 text-sm text-on-secondary-container">{value()}</p>} 105 + </Show> 106 + 107 + <p class="mt-2 text-xs text-on-surface-variant">{time()}</p> 108 + </div> 109 + 110 + <Show when={!props.notification.isRead}> 111 + <span class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="Unread" role="status" /> 112 + </Show> 113 + </article> 114 + ); 115 + } 116 + 117 + function followDetail(notification: NotificationView) { 118 + if (notification.reason !== "follow") { 119 + return null; 120 + } 121 + 122 + return `@${notification.author.handle}`; 123 + }
+95
src/components/notifications/NotificationsPanel.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { NotificationsPanel } from "./NotificationsPanel"; 4 + 5 + const listNotificationsMock = vi.hoisted(() => vi.fn()); 6 + const updateSeenMock = vi.hoisted(() => vi.fn()); 7 + const listenMock = vi.hoisted(() => vi.fn()); 8 + const warnMock = vi.hoisted(() => vi.fn()); 9 + 10 + vi.mock("$/lib/api/notifications", () => ({ listNotifications: listNotificationsMock, updateSeen: updateSeenMock })); 11 + vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 12 + vi.mock("@tauri-apps/plugin-log", () => ({ warn: warnMock })); 13 + 14 + function createNotification(reason: string, overrides: Record<string, unknown> = {}) { 15 + return { 16 + author: { did: `did:plc:${reason}`, displayName: `${reason} author`, handle: `${reason}.test` }, 17 + cid: `cid-${reason}`, 18 + indexedAt: "2026-03-29T12:00:00.000Z", 19 + isRead: false, 20 + reason, 21 + record: { text: `${reason} detail` }, 22 + uri: `at://did:plc:${reason}/app.bsky.notification/${reason}`, 23 + ...overrides, 24 + }; 25 + } 26 + 27 + describe("NotificationsPanel", () => { 28 + beforeEach(() => { 29 + vi.useFakeTimers(); 30 + vi.setSystemTime(new Date("2026-03-29T12:30:00.000Z")); 31 + listNotificationsMock.mockReset(); 32 + updateSeenMock.mockReset(); 33 + listenMock.mockReset(); 34 + warnMock.mockReset(); 35 + updateSeenMock.mockResolvedValue(void 0); 36 + listenMock.mockResolvedValue(() => {}); 37 + }); 38 + 39 + it("loads notifications, marks them seen, and switches between tabs", async () => { 40 + listNotificationsMock.mockResolvedValue({ 41 + cursor: null, 42 + notifications: [createNotification("mention"), createNotification("like")], 43 + seenAt: null, 44 + }); 45 + 46 + const onMarkSeen = vi.fn(); 47 + render(() => <NotificationsPanel onMarkSeen={onMarkSeen} />); 48 + 49 + await screen.findByLabelText("mention author mentioned you"); 50 + await waitFor(() => expect(updateSeenMock).toHaveBeenCalledOnce()); 51 + await waitFor(() => expect(onMarkSeen).toHaveBeenCalledOnce()); 52 + 53 + expect(screen.queryByLabelText("like author liked your post")).not.toBeInTheDocument(); 54 + 55 + fireEvent.click(screen.getByRole("button", { name: /activity/i })); 56 + 57 + expect(await screen.findByLabelText("like author liked your post")).toBeInTheDocument(); 58 + }); 59 + 60 + it("reloads when the unread-count event arrives", async () => { 61 + let handleUnreadUpdate: (() => void) | undefined; 62 + 63 + listNotificationsMock.mockResolvedValueOnce({ 64 + cursor: null, 65 + notifications: [createNotification("mention")], 66 + seenAt: null, 67 + }).mockResolvedValueOnce({ 68 + cursor: null, 69 + notifications: [createNotification("mention"), createNotification("reply")], 70 + seenAt: null, 71 + }); 72 + 73 + listenMock.mockImplementation((_event: string, callback: () => void) => { 74 + handleUnreadUpdate = callback; 75 + return Promise.resolve(() => {}); 76 + }); 77 + 78 + render(() => <NotificationsPanel onMarkSeen={vi.fn()} />); 79 + 80 + await screen.findByLabelText("mention author mentioned you"); 81 + 82 + handleUnreadUpdate?.(); 83 + 84 + await waitFor(() => expect(listNotificationsMock).toHaveBeenCalledTimes(2)); 85 + expect(await screen.findByLabelText("reply author replied to you")).toBeInTheDocument(); 86 + }); 87 + 88 + it("shows the error state when loading fails", async () => { 89 + listNotificationsMock.mockRejectedValue(new Error("notification fetch failed")); 90 + 91 + render(() => <NotificationsPanel onMarkSeen={vi.fn()} />); 92 + 93 + expect(await screen.findByText("notification fetch failed")).toBeInTheDocument(); 94 + }); 95 + });
+238
src/components/notifications/NotificationsPanel.tsx
··· 1 + import { listNotifications, updateSeen } from "$/lib/api/notifications"; 2 + import { NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; 3 + import type { ListNotificationsResponse, NotificationView } from "$/lib/types"; 4 + import { normalizeError } from "$/lib/utils/text"; 5 + import { listen } from "@tauri-apps/api/event"; 6 + import * as logger from "@tauri-apps/plugin-log"; 7 + import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 8 + import { Motion, Presence } from "solid-motionone"; 9 + import { NotificationItem } from "./NotificationItem"; 10 + 11 + type Tab = "mentions" | "activity"; 12 + 13 + const MENTION_REASONS = new Set(["mention", "reply", "quote"]); 14 + 15 + type NotificationsPanelProps = { onMarkSeen: () => void }; 16 + 17 + export function NotificationsPanel(props: NotificationsPanelProps) { 18 + const [tab, setTab] = createSignal<Tab>("mentions"); 19 + const [notifications, setNotifications] = createSignal<NotificationView[]>([]); 20 + const [loading, setLoading] = createSignal(true); 21 + const [error, setError] = createSignal<string | null>(null); 22 + 23 + const mentions = createMemo(() => notifications().filter((n) => MENTION_REASONS.has(n.reason))); 24 + const activity = createMemo(() => notifications().filter((n) => !MENTION_REASONS.has(n.reason))); 25 + const unreadMentions = createMemo(() => mentions().filter((n) => !n.isRead).length); 26 + const unreadActivity = createMemo(() => activity().filter((n) => !n.isRead).length); 27 + 28 + async function load() { 29 + setLoading(true); 30 + setError(null); 31 + try { 32 + const response: ListNotificationsResponse = await listNotifications(); 33 + setNotifications(response.notifications); 34 + } catch (err) { 35 + setError(normalizeError(err)); 36 + } finally { 37 + setLoading(false); 38 + } 39 + } 40 + 41 + async function markSeen() { 42 + try { 43 + await updateSeen(); 44 + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); 45 + props.onMarkSeen(); 46 + } catch (err) { 47 + const error = normalizeError(err); 48 + logger.warn("failed to mark notifications as seen", { keyValues: { error } }); 49 + } 50 + } 51 + 52 + onMount(() => { 53 + void load(); 54 + void markSeen(); 55 + 56 + let unlisten: (() => void) | undefined; 57 + void listen<number>(NOTIFICATIONS_UNREAD_COUNT_EVENT, () => { 58 + void load(); 59 + }).then((dispose) => { 60 + unlisten = dispose; 61 + }); 62 + 63 + onCleanup(() => unlisten?.()); 64 + }); 65 + 66 + return ( 67 + <article class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 68 + <NotificationsHeader 69 + activeTab={tab()} 70 + unreadActivity={unreadActivity()} 71 + unreadMentions={unreadMentions()} 72 + onMarkSeen={() => void markSeen()} 73 + onSelectTab={setTab} /> 74 + <NotificationsViewport 75 + activity={activity()} 76 + error={error()} 77 + loading={loading()} 78 + mentions={mentions()} 79 + tab={tab()} /> 80 + </article> 81 + ); 82 + } 83 + 84 + function NotificationsHeader( 85 + props: { 86 + activeTab: Tab; 87 + unreadActivity: number; 88 + unreadMentions: number; 89 + onMarkSeen: () => void; 90 + onSelectTab: (tab: Tab) => void; 91 + }, 92 + ) { 93 + return ( 94 + <header class="grid gap-5 px-6 pb-4 pt-6"> 95 + <div class="flex items-center justify-between gap-4"> 96 + <div class="grid gap-1"> 97 + <p class="overline-copy text-xs text-on-surface-variant">Inbox</p> 98 + <h1 class="m-0 text-xl font-semibold tracking-tight text-on-surface">Notifications</h1> 99 + </div> 100 + <button 101 + type="button" 102 + class="inline-flex h-10 items-center gap-2 rounded-full border-0 bg-surface-container-high px-4 text-sm font-medium text-on-surface-variant transition duration-150 hover:-translate-y-px hover:text-on-surface" 103 + onClick={() => props.onMarkSeen()} 104 + title="Mark all as read"> 105 + <span class="flex items-center" aria-hidden="true"> 106 + <i class="i-ri-check-double-line" /> 107 + </span> 108 + Mark all read 109 + </button> 110 + </div> 111 + 112 + <nav class="flex flex-wrap gap-2" aria-label="Notification tabs"> 113 + <TabButton 114 + active={props.activeTab === "mentions"} 115 + badge={props.unreadMentions} 116 + label="Mentions" 117 + onClick={() => props.onSelectTab("mentions")} /> 118 + <TabButton 119 + active={props.activeTab === "activity"} 120 + badge={props.unreadActivity} 121 + label="Activity" 122 + onClick={() => props.onSelectTab("activity")} /> 123 + </nav> 124 + </header> 125 + ); 126 + } 127 + 128 + function NotificationsViewport( 129 + props: { 130 + activity: NotificationView[]; 131 + error: string | null; 132 + loading: boolean; 133 + mentions: NotificationView[]; 134 + tab: Tab; 135 + }, 136 + ) { 137 + const activeItems = createMemo(() => (props.tab === "mentions" ? props.mentions : props.activity)); 138 + const emptyLabel = createMemo(() => (props.tab === "mentions" ? "No mentions yet" : "No activity yet")); 139 + const ariaLabel = createMemo(() => (props.tab === "mentions" ? "Mentions" : "Activity")); 140 + 141 + return ( 142 + <div class="min-h-0 overflow-y-auto px-3 pb-3"> 143 + <Show when={props.loading} fallback={<NotificationsState error={props.error} loading={false} />}> 144 + <div class="grid gap-2 py-1"> 145 + <For each={Array.from({ length: 5 })}>{() => <NotificationSkeleton />}</For> 146 + </div> 147 + </Show> 148 + 149 + <Show when={!props.loading && !props.error}> 150 + <Presence> 151 + <Show when={props.tab === "mentions"} keyed> 152 + <NotificationList ariaLabel={ariaLabel()} emptyLabel={emptyLabel()} items={activeItems()} /> 153 + </Show> 154 + <Show when={props.tab === "activity"} keyed> 155 + <NotificationList ariaLabel={ariaLabel()} emptyLabel={emptyLabel()} items={activeItems()} /> 156 + </Show> 157 + </Presence> 158 + </Show> 159 + </div> 160 + ); 161 + } 162 + 163 + function NotificationsState(props: { error: string | null; loading: boolean }) { 164 + return ( 165 + <Show when={!props.loading && props.error}> 166 + {(message) => <div class="grid place-items-center px-6 py-16 text-sm text-on-surface-variant">{message()}</div>} 167 + </Show> 168 + ); 169 + } 170 + 171 + function NotificationList(props: { ariaLabel: string; emptyLabel: string; items: NotificationView[] }) { 172 + return ( 173 + <Motion.div 174 + class="grid gap-2" 175 + initial={{ opacity: 0 }} 176 + animate={{ opacity: 1 }} 177 + exit={{ opacity: 0 }} 178 + transition={{ duration: 0.15 }}> 179 + <Show when={props.items.length > 0} fallback={<EmptyState label={props.emptyLabel} />}> 180 + <div role="list" aria-label={props.ariaLabel} class="grid gap-2"> 181 + <For each={props.items}> 182 + {(notification, index) => ( 183 + <Motion.div 184 + initial={{ opacity: 0, y: -6 }} 185 + animate={{ opacity: 1, y: 0 }} 186 + transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 187 + role="listitem"> 188 + <NotificationItem notification={notification} /> 189 + </Motion.div> 190 + )} 191 + </For> 192 + </div> 193 + </Show> 194 + </Motion.div> 195 + ); 196 + } 197 + 198 + function TabButton(props: { active: boolean; badge: number; label: string; onClick: () => void }) { 199 + return ( 200 + <button 201 + type="button" 202 + aria-pressed={props.active} 203 + class="inline-flex items-center gap-2 rounded-full border-0 px-4 py-2.5 text-sm font-medium transition duration-150" 204 + classList={{ 205 + "bg-surface text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.18)]": props.active, 206 + "bg-transparent text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface": !props.active, 207 + }} 208 + onClick={() => props.onClick()}> 209 + {props.label} 210 + <Show when={props.badge > 0}> 211 + <span class="min-w-5 rounded-full bg-white/10 px-1.5 py-0.5 text-center text-[0.7rem] leading-none"> 212 + <Show when={props.badge > 99} fallback={props.badge}>{"99+"}</Show> 213 + </span> 214 + </Show> 215 + </button> 216 + ); 217 + } 218 + 219 + function EmptyState(props: { label: string }) { 220 + return ( 221 + <div class="grid place-items-center rounded-3xl bg-surface px-6 py-16 text-center text-sm text-on-surface-variant"> 222 + {props.label} 223 + </div> 224 + ); 225 + } 226 + 227 + function NotificationSkeleton() { 228 + return ( 229 + <div class="flex animate-pulse items-start gap-4 rounded-2xl bg-surface px-4 py-4" aria-hidden="true"> 230 + <div class="mt-1 h-5 w-5 shrink-0 rounded-full bg-white/5" /> 231 + <div class="h-8 w-8 shrink-0 rounded-full bg-white/5" /> 232 + <div class="min-w-0 flex-1 space-y-2"> 233 + <div class="h-4 w-48 rounded-full bg-white/5" /> 234 + <div class="h-3 w-36 rounded-full bg-white/5" /> 235 + </div> 236 + </div> 237 + ); 238 + }
+40
src/lib/api/notifications.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { parseListNotificationsResponse } from "./notifications"; 3 + 4 + function createNotification() { 5 + return { 6 + author: { did: "did:plc:alice", handle: "alice.test" }, 7 + cid: "cid-1", 8 + indexedAt: "2026-03-29T12:00:00.000Z", 9 + isRead: false, 10 + reason: "mention", 11 + record: { text: "hello" }, 12 + uri: "at://did:plc:alice/app.bsky.notification/1", 13 + }; 14 + } 15 + 16 + describe("parseListNotificationsResponse", () => { 17 + it("returns a typed notifications payload", () => { 18 + const response = parseListNotificationsResponse({ 19 + cursor: "cursor-1", 20 + notifications: [createNotification()], 21 + seenAt: "2026-03-29T12:00:00.000Z", 22 + }); 23 + 24 + expect(response.cursor).toBe("cursor-1"); 25 + expect(response.notifications).toHaveLength(1); 26 + expect(response.seenAt).toBe("2026-03-29T12:00:00.000Z"); 27 + }); 28 + 29 + it("rejects malformed notification entries", () => { 30 + expect(() => parseListNotificationsResponse({ notifications: [{ nope: true }] })).toThrow( 31 + "notifications response payload is invalid", 32 + ); 33 + }); 34 + 35 + it("rejects invalid cursor values", () => { 36 + expect(() => parseListNotificationsResponse({ cursor: 42, notifications: [createNotification()] })).toThrow( 37 + "notifications response cursor is invalid", 38 + ); 39 + }); 40 + });
+63
src/lib/api/notifications.ts
··· 1 + import type { ListNotificationsResponse, NotificationView } from "$/lib/types"; 2 + import { invoke } from "@tauri-apps/api/core"; 3 + 4 + function asRecord(value: unknown): Record<string, unknown> | null { 5 + if (!value || typeof value !== "object" || Array.isArray(value)) { 6 + return null; 7 + } 8 + 9 + return value as Record<string, unknown>; 10 + } 11 + 12 + function isNotificationView(value: unknown): value is NotificationView { 13 + const record = asRecord(value); 14 + const author = asRecord(record?.author); 15 + const notificationRecord = asRecord(record?.record); 16 + 17 + return !!record 18 + && !!author 19 + && !!notificationRecord 20 + && typeof record.uri === "string" 21 + && typeof record.cid === "string" 22 + && typeof author.did === "string" 23 + && typeof author.handle === "string" 24 + && typeof record.reason === "string" 25 + && typeof record.isRead === "boolean" 26 + && typeof record.indexedAt === "string"; 27 + } 28 + 29 + export function parseListNotificationsResponse(value: unknown): ListNotificationsResponse { 30 + const record = asRecord(value); 31 + const notifications = record?.notifications; 32 + const seenAt = record?.seenAt; 33 + 34 + if (!record || !Array.isArray(notifications) || !notifications.every((item) => isNotificationView(item))) { 35 + throw new Error("notifications response payload is invalid"); 36 + } 37 + 38 + if (record.cursor !== undefined && record.cursor !== null && typeof record.cursor !== "string") { 39 + throw new Error("notifications response cursor is invalid"); 40 + } 41 + 42 + if (seenAt !== undefined && seenAt !== null && typeof seenAt !== "string") { 43 + throw new Error("notifications response seenAt is invalid"); 44 + } 45 + 46 + return { 47 + cursor: (record.cursor as string | null | undefined) ?? null, 48 + notifications, 49 + seenAt: (seenAt as string | null | undefined) ?? null, 50 + }; 51 + } 52 + 53 + export function listNotifications(cursor?: string | null) { 54 + return invoke("list_notifications", { cursor: cursor ?? null }).then(parseListNotificationsResponse); 55 + } 56 + 57 + export function updateSeen() { 58 + return invoke<void>("update_seen"); 59 + } 60 + 61 + export function getUnreadCount() { 62 + return invoke<number>("get_unread_count"); 63 + }
+2
src/lib/constants/events.ts
··· 1 1 export const POST_CREATED_EVENT = "composer:post-created"; 2 2 3 3 export const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; 4 + 5 + export const NOTIFICATIONS_UNREAD_COUNT_EVENT = "notifications:unread-count";
+29
src/lib/types.ts
··· 164 164 165 165 export type ThreadResponse = { thread: ThreadNode }; 166 166 167 + export type NotificationReason = 168 + | "like" 169 + | "repost" 170 + | "follow" 171 + | "mention" 172 + | "reply" 173 + | "quote" 174 + | "starterpack-joined" 175 + | "verified" 176 + | "unverified" 177 + | string; 178 + 179 + export type NotificationView = { 180 + uri: string; 181 + cid: string; 182 + author: ProfileViewBasic; 183 + reason: NotificationReason; 184 + reasonSubject?: string | null; 185 + record: Record<string, unknown>; 186 + isRead: boolean; 187 + indexedAt: string; 188 + }; 189 + 190 + export type ListNotificationsResponse = { 191 + cursor?: string | null; 192 + notifications: NotificationView[]; 193 + seenAt?: string | null; 194 + }; 195 + 167 196 export type StrongRefInput = { cid: string; uri: string }; 168 197 169 198 export type ReplyRefInput = { parent: StrongRefInput; root: StrongRefInput };
+8
src/lib/utils/text.ts
··· 15 15 16 16 return value.toString(); 17 17 } 18 + 19 + export function normalizeError(err: unknown): string { 20 + if (err instanceof Error) { 21 + return err.message; 22 + } else { 23 + return String(err); 24 + } 25 + }
+14 -1
src/router.test.tsx
··· 14 14 const renderComposer = vi.fn((currentSession: ActiveSession) => ( 15 15 <div data-testid="composer-view">{currentSession.handle}</div> 16 16 )); 17 + const renderNotifications = vi.fn((currentSession: ActiveSession) => ( 18 + <div data-testid="notifications-view">{currentSession.handle}</div> 19 + )); 17 20 const renderTimeline = vi.fn((currentSession: ActiveSession, context: { threadUri: string | null }) => ( 18 21 <div data-testid="timeline-view"> 19 22 <span>{currentSession.handle}</span> ··· 27 30 hasSession 28 31 renderAuth={() => <div>Auth</div>} 29 32 renderComposer={renderComposer} 33 + renderNotifications={renderNotifications} 30 34 renderShell={Shell} 31 35 renderTimeline={renderTimeline} 32 36 session={session} /> 33 37 )); 34 38 35 - return { renderComposer, renderTimeline }; 39 + return { renderComposer, renderNotifications, renderTimeline }; 36 40 } 37 41 38 42 describe("AppRouter", () => { ··· 62 66 await screen.findByTestId("composer-view"); 63 67 64 68 expect(renderComposer).toHaveBeenCalledOnce(); 69 + expect(screen.getByText(session.handle)).toBeInTheDocument(); 70 + }); 71 + 72 + it("renders the notifications route inside the protected shell", async () => { 73 + const { renderNotifications } = renderRouter("#/notifications"); 74 + 75 + await screen.findByTestId("notifications-view"); 76 + 77 + expect(renderNotifications).toHaveBeenCalledOnce(); 65 78 expect(screen.getByText(session.handle)).toBeInTheDocument(); 66 79 }); 67 80 });
+2 -6
src/router.tsx
··· 17 17 onLocationChange?: () => void; 18 18 renderAuth: () => JSX.Element; 19 19 renderComposer: (session: ActiveSession) => JSX.Element; 20 + renderNotifications: (session: ActiveSession) => JSX.Element; 20 21 renderShell: Component<ParentProps>; 21 22 renderTimeline: ( 22 23 session: ActiveSession, ··· 96 97 97 98 const NotificationsRoute = () => ( 98 99 <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 99 - {() => ( 100 - <FeaturePlaceholder 101 - eyebrow="Notifications" 102 - title="Notification routing is gated." 103 - description="The notifications surface can now be added as an authenticated route without changing the shell again." /> 104 - )} 100 + {(session) => props.renderNotifications(session)} 105 101 </ProtectedRouteView> 106 102 ); 107 103