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.

fix: latest session retrieval

+287 -45
+133 -1
src-tauri/src/auth.rs
··· 146 146 .map_err(AppError::from) 147 147 } 148 148 149 + pub fn get_latest_session_id(&self, did: &str) -> Result<Option<String>, AppError> { 150 + let connection = self.lock_connection()?; 151 + connection 152 + .query_row( 153 + " 154 + SELECT session_id 155 + FROM oauth_sessions 156 + WHERE did = ?1 157 + ORDER BY updated_at DESC, created_at DESC 158 + LIMIT 1 159 + ", 160 + params![did], 161 + |row| row.get::<_, String>(0), 162 + ) 163 + .optional() 164 + .map_err(AppError::from) 165 + } 166 + 167 + pub fn update_account_session_id(&self, did: &str, session_id: &str) -> Result<(), AppError> { 168 + let connection = self.lock_connection()?; 169 + let rows_updated = connection.execute( 170 + "UPDATE accounts SET session_id = ?2 WHERE did = ?1", 171 + params![did, session_id], 172 + )?; 173 + 174 + if rows_updated == 0 { 175 + return Err(AppError::Validation(format!( 176 + "cannot update session_id for unknown account did: {did}" 177 + ))); 178 + } 179 + 180 + Ok(()) 181 + } 182 + 149 183 pub fn upsert_account( 150 184 &self, account: &AccountSummary, session_id: &str, make_active: bool, 151 185 ) -> Result<(), AppError> { ··· 604 638 handle TEXT, 605 639 pds_url TEXT, 606 640 session_id TEXT, 607 - active INTEGER NOT NULL DEFAULT 0 641 + active INTEGER NOT NULL DEFAULT 0, 642 + avatar TEXT 608 643 ); 609 644 610 645 CREATE TABLE oauth_sessions ( ··· 688 723 assert_eq!(payload["handle"], "alice.bsky.social"); 689 724 assert_eq!(payload["displayName"], "Alice"); 690 725 assert_eq!(payload["avatar"], "https://cdn.example/alice.jpg"); 726 + } 727 + 728 + #[test] 729 + fn latest_session_id_prefers_most_recent_persisted_session() { 730 + let store = auth_store_with_schema( 731 + " 732 + CREATE TABLE accounts ( 733 + did TEXT PRIMARY KEY, 734 + handle TEXT, 735 + pds_url TEXT, 736 + session_id TEXT, 737 + active INTEGER NOT NULL DEFAULT 0, 738 + avatar TEXT 739 + ); 740 + 741 + CREATE TABLE oauth_sessions ( 742 + did TEXT NOT NULL, 743 + session_id TEXT NOT NULL, 744 + session_json TEXT NOT NULL, 745 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 746 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 747 + PRIMARY KEY (did, session_id) 748 + ); 749 + ", 750 + ); 751 + 752 + let connection = store.lock_connection().expect("connection should lock"); 753 + connection 754 + .execute( 755 + "INSERT INTO accounts(did, handle, session_id) VALUES (?1, ?2, ?3)", 756 + params!["did:plc:alice", "alice.test", "session-old"], 757 + ) 758 + .expect("account should insert"); 759 + connection 760 + .execute( 761 + "INSERT INTO oauth_sessions(did, session_id, session_json, updated_at) VALUES (?1, ?2, ?3, '2026-03-29 10:00:00')", 762 + params!["did:plc:alice", "session-old", "{}"], 763 + ) 764 + .expect("old oauth session should insert"); 765 + connection 766 + .execute( 767 + "INSERT INTO oauth_sessions(did, session_id, session_json, updated_at) VALUES (?1, ?2, ?3, '2026-03-29 11:00:00')", 768 + params!["did:plc:alice", "session-new", "{}"], 769 + ) 770 + .expect("new oauth session should insert"); 771 + drop(connection); 772 + 773 + let latest = store 774 + .get_latest_session_id("did:plc:alice") 775 + .expect("latest session lookup should succeed"); 776 + 777 + assert_eq!(latest.as_deref(), Some("session-new")); 778 + } 779 + 780 + #[test] 781 + fn update_account_session_id_repoints_stale_account_record() { 782 + let store = auth_store_with_schema( 783 + " 784 + CREATE TABLE accounts ( 785 + did TEXT PRIMARY KEY, 786 + handle TEXT, 787 + pds_url TEXT, 788 + session_id TEXT, 789 + active INTEGER NOT NULL DEFAULT 0, 790 + avatar TEXT 791 + ); 792 + 793 + CREATE TABLE oauth_sessions ( 794 + did TEXT NOT NULL, 795 + session_id TEXT NOT NULL, 796 + session_json TEXT NOT NULL, 797 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 798 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 799 + PRIMARY KEY (did, session_id) 800 + ); 801 + ", 802 + ); 803 + 804 + let connection = store.lock_connection().expect("connection should lock"); 805 + connection 806 + .execute( 807 + "INSERT INTO accounts(did, handle, session_id) VALUES (?1, ?2, ?3)", 808 + params!["did:plc:alice", "alice.test", "session-old"], 809 + ) 810 + .expect("account should insert"); 811 + drop(connection); 812 + 813 + store 814 + .update_account_session_id("did:plc:alice", "session-new") 815 + .expect("session id update should succeed"); 816 + 817 + let updated = store 818 + .get_account("did:plc:alice") 819 + .expect("account lookup should succeed") 820 + .expect("account should remain present"); 821 + 822 + assert_eq!(updated.session_id.as_deref(), Some("session-new")); 691 823 } 692 824 }
+39 -9
src-tauri/src/state.rs
··· 207 207 return Ok(existing); 208 208 } 209 209 210 - let session_id = account.session_id.as_deref().ok_or_else(|| { 211 - AppError::Validation(format!("account {} does not have a stored oauth session", account.did)) 212 - })?; 213 - 214 210 let did = Did::new(&account.did)?; 211 + let session_id = self.resolve_restorable_session_id(account, &did).await?; 215 212 let session = if refresh { 216 213 log::info!("restoring session with token refresh for {}", account.handle); 217 - match self.oauth_client.restore(&did, session_id).await { 214 + match self.oauth_client.restore(&did, &session_id).await { 218 215 Ok(session) => Arc::new(session), 219 216 Err(error) if should_fallback_to_persisted_session(&error) => { 220 217 log::warn!( ··· 222 219 account.handle, 223 220 error 224 221 ); 225 - self.restore_persisted_session(account, &did, session_id).await? 222 + self.restore_persisted_session(account, &did, &session_id).await? 226 223 } 227 224 Err(error) => return Err(AppError::from(error)), 228 225 } 229 226 } else { 230 227 log::debug!("restoring session from persisted data for {}", account.handle); 231 - self.restore_persisted_session(account, &did, session_id).await? 228 + self.restore_persisted_session(account, &did, &session_id).await? 232 229 }; 233 230 234 231 self.sessions ··· 238 235 239 236 log::info!("session restored successfully for {}", account.handle); 240 237 Ok(session) 238 + } 239 + 240 + async fn resolve_restorable_session_id( 241 + &self, account: &StoredAccount, did: &Did<'_>, 242 + ) -> Result<String, AppError> { 243 + let configured_session_id = account.session_id.as_deref().ok_or_else(|| { 244 + AppError::Validation(format!("account {} does not have a stored oauth session", account.did)) 245 + })?; 246 + 247 + if self 248 + .auth_store 249 + .get_session(did, configured_session_id) 250 + .await? 251 + .is_some() 252 + { 253 + return Ok(configured_session_id.to_string()); 254 + } 255 + 256 + let fallback_session_id = self.auth_store.get_latest_session_id(&account.did)?.ok_or_else(|| { 257 + AppError::Validation(format!("missing persisted oauth session for account {}", account.did)) 258 + })?; 259 + 260 + log::warn!( 261 + "account {} referenced missing session {}; falling back to persisted session {}", 262 + account.handle, 263 + configured_session_id, 264 + fallback_session_id 265 + ); 266 + 267 + self.auth_store 268 + .update_account_session_id(&account.did, &fallback_session_id)?; 269 + 270 + Ok(fallback_session_id) 241 271 } 242 272 243 273 async fn restore_persisted_session( ··· 343 373 return Ok(()); 344 374 }; 345 375 346 - let session_id = account.session_id.as_deref().unwrap(); 347 376 let did = Did::new(&account.did)?; 377 + let session_id = self.resolve_restorable_session_id(account, &did).await?; 348 378 349 - match self.oauth_client.restore(&did, session_id).await { 379 + match self.oauth_client.restore(&did, &session_id).await { 350 380 Ok(session) => { 351 381 self.sessions 352 382 .write()
+9 -2
src/App.tsx
··· 18 18 19 19 const COMPOSER_WINDOW_LABEL = "composer"; 20 20 21 - function AppShell(props: ParentProps) { 21 + type AppShellProps = ParentProps<{ fullWidth?: boolean }>; 22 + 23 + function AppShell(props: AppShellProps) { 22 24 const session = useAppSession(); 23 25 const shell = useAppShellUi(); 24 26 ··· 30 32 <AppRail /> 31 33 32 34 <section 33 - class="m-5 grid min-h-0 overflow-hidden gap-6 rounded-2xl bg-surface p-6 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-6 max-[1180px]:m-0 max-[1180px]:min-h-[calc(100vh-4.75rem)] max-[1180px]:rounded-none max-[1180px]:p-5 max-[1180px]:overflow-visible max-[900px]:gap-5 max-[900px]:p-4 max-[640px]:gap-4 max-[640px]:p-3" 35 + class="grid min-h-0 overflow-hidden bg-surface max-[1180px]:min-h-[calc(100vh-4.75rem)] max-[1180px]:overflow-visible" 36 + classList={{ 37 + "m-5 gap-6 rounded-2xl p-6 shadow-[0_24px_40px_rgba(125,175,255,0.05)] max-[1360px]:p-6 max-[1180px]:m-0 max-[1180px]:rounded-none max-[1180px]:p-5 max-[900px]:gap-5 max-[900px]:p-4 max-[640px]:gap-4 max-[640px]:p-3": 38 + !props.fullWidth, 39 + "max-[1180px]:m-0 max-[1180px]:rounded-none": props.fullWidth, 40 + }} 34 41 aria-busy={session.bootstrapping}> 35 42 {props.children} 36 43 </section>
+1 -1
src/components/explorer/ExplorerPanel.test.tsx
··· 71 71 expect(await screen.findByRole("button", { name: /app\.bsky\.feed\.like/u })).toBeInTheDocument(); 72 72 expect(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u })).toBeInTheDocument(); 73 73 expect(screen.queryByText("0 records")).not.toBeInTheDocument(); 74 - expect(screen.getAllByText("Count unavailable")).toHaveLength(2); 74 + expect(screen.queryByText("Count unavailable")).not.toBeInTheDocument(); 75 75 }); 76 76 77 77 it("loads additional collection pages", async () => {
+3 -3
src/components/explorer/ExplorerPanel.tsx
··· 49 49 } 50 50 return null; 51 51 } 52 - function extractCollections(repoData: Record<string, unknown>): Array<{ nsid: string; count: number | null }> { 53 - const collections: Array<{ nsid: string; count: number | null }> = []; 52 + function extractCollections(repoData: Record<string, unknown>): Array<{ nsid: string }> { 53 + const collections: Array<{ nsid: string }> = []; 54 54 const collectionsData = repoData.collections; 55 55 56 56 if (Array.isArray(collectionsData)) { 57 57 for (const collection of collectionsData) { 58 58 if (typeof collection === "string") { 59 - collections.push({ nsid: collection, count: null }); 59 + collections.push({ nsid: collection }); 60 60 } 61 61 } 62 62 }
+1 -1
src/components/explorer/types.ts
··· 6 6 7 7 type PDSRepoData = { did: string; head: string; rev: string; active: boolean; status: string | null }; 8 8 9 - type RepoViewCollection = { nsid: string; count: number | null }; 9 + type RepoViewCollection = { nsid: string }; 10 10 11 11 type RepoViewData = { collections: Array<RepoViewCollection>; handle: string; did: string; pdsUrl: string | null }; 12 12
+2 -7
src/components/explorer/views/RepoView.tsx
··· 5 5 did: string; 6 6 handle: string; 7 7 pdsUrl: string | null; 8 - collections: Array<{ nsid: string; count: number | null }>; 8 + collections: Array<{ nsid: string }>; 9 9 onCollectionClick: (collection: string) => void; 10 10 onPdsClick: () => void; 11 11 }; ··· 53 53 <Icon kind="folder" class="text-on-surface-variant" /> 54 54 <span class="text-sm">{collection.nsid}</span> 55 55 </div> 56 - <div class="flex items-center gap-3"> 57 - <span class="text-xs text-on-surface-variant"> 58 - {collection.count === null ? "Count unavailable" : `${collection.count} records`} 59 - </span> 60 - <ArrowIcon direction="right" class="text-on-surface-variant" /> 61 - </div> 56 + <ArrowIcon direction="right" class="text-on-surface-variant" /> 62 57 </button> 63 58 )} 64 59 </For>
+21
src/components/notifications/NotificationsPanel.test.tsx
··· 104 104 )); 105 105 106 106 expect(await screen.findByText("notification fetch failed")).toBeInTheDocument(); 107 + expect(updateSeenMock).not.toHaveBeenCalled(); 108 + expect(warnMock).not.toHaveBeenCalled(); 109 + }); 110 + 111 + it("does not warn when automatic mark-seen fails after a successful load", async () => { 112 + listNotificationsMock.mockResolvedValue({ 113 + cursor: null, 114 + notifications: [createNotification("mention")], 115 + seenAt: null, 116 + }); 117 + updateSeenMock.mockRejectedValue(new Error("transport error")); 118 + 119 + render(() => ( 120 + <AppTestProviders> 121 + <NotificationsPanel /> 122 + </AppTestProviders> 123 + )); 124 + 125 + expect(await screen.findByLabelText("mention author mentioned you")).toBeInTheDocument(); 126 + await waitFor(() => expect(updateSeenMock).toHaveBeenCalledOnce()); 127 + expect(warnMock).not.toHaveBeenCalled(); 107 128 }); 108 129 });
+49 -17
src/components/notifications/NotificationsPanel.tsx
··· 14 14 15 15 const MENTION_REASONS = new Set(["mention", "reply", "quote"]); 16 16 17 + function hasUnreadNotifications(items: NotificationView[]) { 18 + return items.some((notification) => !notification.isRead); 19 + } 20 + 17 21 export function NotificationsPanel() { 18 22 const session = useAppSession(); 19 23 // TODO: NotificationsStore via createStore ··· 21 25 const [notifications, setNotifications] = createSignal<NotificationView[]>([]); 22 26 const [loading, setLoading] = createSignal(true); 23 27 const [error, setError] = createSignal<string | null>(null); 28 + let loadRequestId = 0; 29 + let markSeenPending = false; 24 30 25 31 const mentions = createMemo(() => notifications().filter((n) => MENTION_REASONS.has(n.reason))); 26 32 const activity = createMemo(() => notifications().filter((n) => !MENTION_REASONS.has(n.reason))); 27 33 const unreadMentions = createMemo(() => mentions().filter((n) => !n.isRead).length); 28 34 const unreadActivity = createMemo(() => activity().filter((n) => !n.isRead).length); 29 35 30 - async function load() { 36 + async function markSeen(options?: { notifications?: NotificationView[]; silent?: boolean }) { 37 + const items = options?.notifications ?? notifications(); 38 + if (!hasUnreadNotifications(items) || markSeenPending) { 39 + return; 40 + } 41 + 42 + markSeenPending = true; 43 + 44 + try { 45 + await updateSeen(); 46 + setNotifications((prev) => prev.map((notification) => ({ ...notification, isRead: true }))); 47 + session.markNotificationsSeen(); 48 + } catch (err) { 49 + const error = normalizeError(err); 50 + if (!options?.silent) { 51 + logger.warn("failed to mark notifications as seen", { keyValues: { error } }); 52 + } 53 + } finally { 54 + markSeenPending = false; 55 + } 56 + } 57 + 58 + async function load(options?: { markSeen?: boolean }) { 59 + const requestId = ++loadRequestId; 31 60 setLoading(true); 32 61 setError(null); 62 + 33 63 try { 34 64 const response: ListNotificationsResponse = await listNotifications(); 65 + if (requestId !== loadRequestId) { 66 + return; 67 + } 68 + 35 69 setNotifications(response.notifications); 70 + 71 + if (options?.markSeen) { 72 + await markSeen({ notifications: response.notifications, silent: true }); 73 + } 36 74 } catch (err) { 37 - setError(normalizeError(err)); 75 + if (requestId === loadRequestId) { 76 + setError(normalizeError(err)); 77 + } 38 78 } finally { 39 - setLoading(false); 79 + if (requestId === loadRequestId) { 80 + setLoading(false); 81 + } 40 82 } 41 83 } 42 84 43 - async function markSeen() { 44 - try { 45 - await updateSeen(); 46 - setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); 47 - session.markNotificationsSeen(); 48 - } catch (err) { 49 - const error = normalizeError(err); 50 - logger.warn("failed to mark notifications as seen", { keyValues: { error } }); 51 - } 85 + function reloadNotifications() { 86 + void load({ markSeen: true }); 52 87 } 53 88 54 89 onMount(() => { 55 - void load(); 56 - void markSeen(); 90 + reloadNotifications(); 57 91 58 92 let unlisten: (() => void) | undefined; 59 - void listen<number>(NOTIFICATIONS_UNREAD_COUNT_EVENT, () => { 60 - void load(); 61 - }).then((dispose) => { 93 + void listen<number>(NOTIFICATIONS_UNREAD_COUNT_EVENT, reloadNotifications).then((dispose) => { 62 94 unlisten = dispose; 63 95 }); 64 96
+22 -2
src/router.test.tsx
··· 1 1 import { AppTestProviders } from "$/test/providers"; 2 2 import { render, screen } from "@solidjs/testing-library"; 3 3 import type { Component, ParentProps } from "solid-js"; 4 - import { describe, expect, it, vi } from "vitest"; 4 + import { beforeEach, describe, expect, it, vi } from "vitest"; 5 5 import { buildThreadRoute } from "./lib/feeds"; 6 6 import { AppRouter } from "./router"; 7 7 8 - const Shell: Component<ParentProps> = (props) => <div>{props.children}</div>; 8 + const listenMock = vi.hoisted(() => vi.fn()); 9 + 10 + vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 11 + 12 + const Shell: Component<ParentProps<{ fullWidth?: boolean }>> = (props) => ( 13 + <div data-testid="shell" data-full-width={props.fullWidth ? "true" : "false"}>{props.children}</div> 14 + ); 9 15 10 16 function renderRouter(hash: string) { 11 17 globalThis.location.hash = hash; ··· 39 45 } 40 46 41 47 describe("AppRouter", () => { 48 + beforeEach(() => { 49 + listenMock.mockReset(); 50 + listenMock.mockResolvedValue(() => {}); 51 + }); 52 + 42 53 it("renders the timeline route without a thread uri", async () => { 43 54 const { renderTimeline } = renderRouter("#/timeline"); 44 55 ··· 75 86 76 87 expect(renderNotifications).toHaveBeenCalledOnce(); 77 88 expect(screen.getByText("notifications")).toBeInTheDocument(); 89 + expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false"); 90 + }); 91 + 92 + it("renders the explorer route inside the full-width shell", async () => { 93 + renderRouter("#/explorer"); 94 + 95 + await screen.findByTestId("shell"); 96 + 97 + expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "true"); 78 98 }); 79 99 });
+7 -2
src/router.tsx
··· 16 16 import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 17 17 18 18 type TTimelineRouteProps = { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } }; 19 + type AppShellProps = ParentProps<{ fullWidth?: boolean }>; 19 20 20 21 type AppRouterProps = { 21 22 renderAuth: () => JSX.Element; 22 23 renderComposer: () => JSX.Element; 23 24 renderNotifications: () => JSX.Element; 24 - renderShell: Component<ParentProps>; 25 + renderShell: Component<AppShellProps>; 25 26 renderTimeline: Component<TTimelineRouteProps>; 26 27 }; 27 28 ··· 42 43 } 43 44 }); 44 45 46 + const fullWidthShell = () => location.pathname === "/explorer"; 47 + 45 48 return ( 46 - <Show when={standaloneComposerRoute()} fallback={<props.renderShell>{routeProps.children}</props.renderShell>}> 49 + <Show 50 + when={standaloneComposerRoute()} 51 + fallback={<props.renderShell fullWidth={fullWidthShell()}>{routeProps.children}</props.renderShell>}> 47 52 {routeProps.children} 48 53 </Show> 49 54 );