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.

refactor: use context

+1030 -821
+72 -358
src/App.tsx
··· 1 - import { 2 - getAppBootstrap, 3 - login as loginRequest, 4 - logout as logoutRequest, 5 - switchAccount as switchAccountRequest, 6 - } from "$/lib/api/app"; 7 - import { getUnreadCount } from "$/lib/api/notifications"; 8 - import { listen } from "@tauri-apps/api/event"; 9 1 import { getCurrentWindow } from "@tauri-apps/api/window"; 10 - import { createEffect, createMemo, onCleanup, onMount, Show, startTransition } from "solid-js"; 11 - import { createStore } from "solid-js/store"; 12 2 import "@fontsource-variable/google-sans"; 3 + import type { ParentProps } from "solid-js"; 4 + import { Show } from "solid-js"; 13 5 import "./App.css"; 14 - import type { ParentProps } from "solid-js"; 15 6 import { AccountLedger } from "./components/account/AccountLedger"; 16 7 import { AppRail } from "./components/AppRail"; 17 8 import { ComposerWindow } from "./components/feeds/ComposerWindow"; ··· 21 12 import { HeaderPanel } from "./components/panels/Header"; 22 13 import { SessionSpotlight } from "./components/Session"; 23 14 import { ErrorToast } from "./components/shared/ErrorToast"; 24 - import { ACCOUNT_SWITCH_EVENT, NOTIFICATIONS_UNREAD_COUNT_EVENT } from "./lib/constants/events"; 25 - import type { AccountSummary, ActiveSession } from "./lib/types"; 15 + import { AppSessionProvider, useAppSession } from "./contexts/app-session"; 16 + import { AppShellUiProvider, useAppShellUi } from "./contexts/app-shell-ui"; 26 17 import { AppRouter } from "./router"; 27 18 28 19 const COMPOSER_WINDOW_LABEL = "composer"; 29 - const RAIL_COLLAPSED_STORAGE_KEY = "lazurite:rail-collapsed"; 30 20 31 - type AppState = { 32 - accounts: AccountSummary[]; 33 - activeSession: ActiveSession | null; 34 - bootstrapping: boolean; 35 - errorMessage: string | null; 36 - loggingIn: boolean; 37 - loginValue: string; 38 - logoutDid: string | null; 39 - narrowViewport: boolean; 40 - railCollapsed: boolean; 41 - reauthNeeded: boolean; 42 - shakeCount: number; 43 - showSwitcher: boolean; 44 - switchingDid: string | null; 45 - unreadNotifications: number; 46 - }; 21 + function AppShell(props: ParentProps) { 22 + const session = useAppSession(); 23 + const shell = useAppShellUi(); 47 24 48 - function createInitialAppState(): AppState { 49 - return { 50 - accounts: [], 51 - activeSession: null, 52 - bootstrapping: true, 53 - errorMessage: null, 54 - loggingIn: false, 55 - loginValue: "", 56 - logoutDid: null, 57 - narrowViewport: false, 58 - railCollapsed: false, 59 - reauthNeeded: false, 60 - shakeCount: 0, 61 - showSwitcher: false, 62 - switchingDid: null, 63 - unreadNotifications: 0, 64 - }; 65 - } 25 + return ( 26 + <> 27 + <main 28 + class="grid h-screen min-h-screen overflow-hidden grid-cols-(--app-rail-cols) transition-[grid-template-columns] duration-300 ease-out max-[1180px]:h-auto max-[1180px]:min-h-screen max-[1180px]:grid-cols-1 max-[1180px]:overflow-visible" 29 + style={{ "--app-rail-cols": shell.railColumns }}> 30 + <AppRail /> 66 31 67 - function App() { 68 - const [app, setApp] = createStore<AppState>(createInitialAppState()); 32 + <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" 34 + aria-busy={session.bootstrapping}> 35 + {props.children} 36 + </section> 37 + </main> 69 38 70 - const standaloneComposerWindow = isComposerWindow(); 71 - const activeAccount = createMemo(() => 72 - app.accounts.find((account) => account.did === app.activeSession?.did) ?? null 39 + <ErrorToast message={session.errorMessage} onDismiss={session.clearError} /> 40 + </> 73 41 ); 74 - const primaryAccount = createMemo(() => activeAccount() ?? app.accounts[0] ?? null); 75 - const hasSession = createMemo(() => !!app.activeSession); 76 - const railCompact = createMemo(() => app.railCollapsed && !app.narrowViewport); 77 - const railCondensed = createMemo(() => railCompact() || app.narrowViewport); 78 - const railColumns = createMemo(() => (railCompact() ? "5.75rem minmax(0,1fr)" : "16rem minmax(0,1fr)")); 79 - const metaLabel = createMemo(() => { 80 - if (app.bootstrapping) { 81 - return "reconnecting"; 82 - } 83 - 84 - if (app.activeSession) { 85 - return "connected"; 86 - } 87 - 88 - return "ready"; 89 - }); 90 - 91 - async function loadBootstrap() { 92 - setApp("bootstrapping", true); 93 - 94 - try { 95 - const payload = await getAppBootstrap(); 96 - startTransition(() => { 97 - setApp("activeSession", payload.activeSession); 98 - setApp("accounts", payload.accountList); 99 - setApp("reauthNeeded", payload.accountList.length > 0 && !payload.activeSession); 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 - } 111 - } catch (error) { 112 - setApp("errorMessage", `Failed to load app bootstrap: ${String(error)}`); 113 - } finally { 114 - setApp("bootstrapping", false); 115 - } 116 - } 117 - 118 - function closeSwitcher() { 119 - if (app.showSwitcher) { 120 - setApp("showSwitcher", false); 121 - } 122 - } 123 - 124 - function triggerShake() { 125 - setApp("shakeCount", (count) => count + 1); 126 - } 127 - 128 - function markPotentialExpiry(error: unknown) { 129 - const message = String(error).toLowerCase(); 130 - if (message.includes("refresh failed permanently") || message.includes("session does not exist")) { 131 - setApp("reauthNeeded", true); 132 - } 133 - } 134 - 135 - async function submitLogin(identifier = app.loginValue) { 136 - const trimmed = identifier.trim(); 137 - if (!validateIdentifier(trimmed)) { 138 - triggerShake(); 139 - setApp("errorMessage", "Please enter a valid handle or DID."); 140 - return; 141 - } 142 - 143 - setApp("loggingIn", true); 144 - try { 145 - await loginRequest(trimmed); 146 - setApp("loginValue", ""); 147 - closeSwitcher(); 148 - await loadBootstrap(); 149 - } catch (error) { 150 - markPotentialExpiry(error); 151 - setApp("errorMessage", `Authentication failed: ${String(error)}`); 152 - } finally { 153 - setApp("loggingIn", false); 154 - } 155 - } 156 - 157 - async function switchAccount(did: string) { 158 - setApp("switchingDid", did); 159 - try { 160 - await switchAccountRequest(did); 161 - closeSwitcher(); 162 - await loadBootstrap(); 163 - } catch (error) { 164 - markPotentialExpiry(error); 165 - setApp("errorMessage", `Failed to switch account: ${String(error)}`); 166 - } finally { 167 - setApp("switchingDid", null); 168 - } 169 - } 170 - 171 - async function logout(did: string) { 172 - setApp("logoutDid", did); 173 - try { 174 - await logoutRequest(did); 175 - closeSwitcher(); 176 - await loadBootstrap(); 177 - } catch (error) { 178 - markPotentialExpiry(error); 179 - setApp("errorMessage", `Failed to logout account: ${String(error)}`); 180 - } finally { 181 - setApp("logoutDid", null); 182 - } 183 - } 184 - 185 - async function reauthorizePrimaryAccount() { 186 - const account = primaryAccount(); 187 - if (!account) { 188 - return; 189 - } 190 - 191 - await submitLogin(account.handle || account.did); 192 - } 193 - 194 - onMount(() => { 195 - let unlisten: (() => void) | undefined; 196 - const media = globalThis.matchMedia("(max-width: 1180px)"); 197 - const syncViewport = () => setApp("narrowViewport", media.matches); 198 - 199 - const stored = globalThis.localStorage.getItem(RAIL_COLLAPSED_STORAGE_KEY); 200 - if (stored === "true") { 201 - setApp("railCollapsed", true); 202 - } 203 - 204 - syncViewport(); 205 - media.addEventListener("change", syncViewport); 206 - 207 - void loadBootstrap(); 208 - 209 - void listen<ActiveSession | null>(ACCOUNT_SWITCH_EVENT, () => { 210 - void loadBootstrap(); 211 - }).then((dispose) => { 212 - unlisten = dispose; 213 - }); 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 - 222 - onCleanup(() => { 223 - unlisten?.(); 224 - unlistenUnread?.(); 225 - media.removeEventListener("change", syncViewport); 226 - }); 227 - }); 228 - 229 - createEffect(() => { 230 - globalThis.localStorage.setItem(RAIL_COLLAPSED_STORAGE_KEY, app.railCollapsed ? "true" : "false"); 231 - }); 232 - 233 - function AppShell(props: ParentProps) { 234 - return ( 235 - <> 236 - <main 237 - class="grid h-screen min-h-screen overflow-hidden grid-cols-(--app-rail-cols) transition-[grid-template-columns] duration-300 ease-out max-[1180px]:h-auto max-[1180px]:min-h-screen max-[1180px]:grid-cols-1 max-[1180px]:overflow-visible" 238 - style={{ "--app-rail-cols": railColumns() }}> 239 - <AppRail 240 - activeAccount={activeAccount()} 241 - activeSession={app.activeSession} 242 - accounts={app.accounts} 243 - collapsed={railCondensed()} 244 - hasSession={hasSession()} 245 - logoutDid={app.logoutDid} 246 - narrow={app.narrowViewport} 247 - openSwitcher={app.showSwitcher} 248 - unreadNotifications={app.unreadNotifications} 249 - onCloseSwitcher={closeSwitcher} 250 - switchingDid={app.switchingDid} 251 - onLogout={(did) => void logout(did)} 252 - onSwitch={(did) => void switchAccount(did)} 253 - onToggleCollapse={() => setApp("railCollapsed", (collapsed) => !collapsed)} 254 - onToggleSwitcher={() => setApp("showSwitcher", (open) => !open)} /> 42 + } 255 43 256 - <section 257 - 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" 258 - aria-busy={app.bootstrapping}> 259 - {props.children} 260 - </section> 261 - </main> 262 - 263 - <ErrorToast message={app.errorMessage} onDismiss={() => setApp("errorMessage", null)} /> 264 - </> 265 - ); 266 - } 44 + function AppContent() { 45 + const session = useAppSession(); 46 + const standaloneComposerWindow = isComposerWindow(); 267 47 268 48 return ( 269 49 <Show 270 - when={!standaloneComposerWindow} 50 + when={standaloneComposerWindow} 271 51 fallback={ 272 - <> 273 - <Show when={!app.bootstrapping} fallback={<ComposerBootState />}> 52 + <AppRouter 53 + renderAuth={() => <AuthWorkspace />} 54 + renderComposer={() => <ComposerWindow />} 55 + renderNotifications={() => <NotificationsPanel />} 56 + renderShell={AppShell} 57 + renderTimeline={({ context }) => ( 58 + <FeedWorkspace onThreadRouteChange={context.onThreadRouteChange} threadUri={context.threadUri} /> 59 + )} /> 60 + }> 61 + <> 62 + <Show 63 + when={session.bootstrapping} 64 + fallback={ 274 65 <Show 275 - when={app.activeSession} 66 + when={session.activeSession} 276 67 keyed 277 68 fallback={ 278 69 <div class="grid min-h-screen place-items-center bg-surface-container-lowest p-6"> 279 70 <div class="w-full max-w-md"> 280 71 <LoginPanel 281 - value={app.loginValue} 282 - pending={app.loggingIn} 283 - shakeCount={app.shakeCount} 284 - onInput={(value) => setApp("loginValue", value)} 285 - onSubmit={() => void submitLogin()} /> 72 + value={session.loginValue} 73 + pending={session.loggingIn} 74 + shakeCount={session.shakeCount} 75 + onInput={session.setLoginValue} 76 + onSubmit={() => void session.submitLogin()} /> 286 77 </div> 287 78 </div> 288 79 }> 289 - {(session) => ( 290 - <ComposerWindow 291 - activeAvatar={activeAccount()?.avatar} 292 - activeHandle={session.handle} 293 - onError={(message) => setApp("errorMessage", message)} /> 294 - )} 80 + <ComposerWindow /> 295 81 </Show> 296 - </Show> 82 + }> 83 + <ComposerBootState /> 84 + </Show> 297 85 298 - <ErrorToast 299 - message={app.errorMessage} 300 - onDismiss={() => setApp("errorMessage", null)} /> 301 - </> 302 - }> 303 - <AppRouter 304 - bootstrapping={app.bootstrapping} 305 - hasSession={hasSession()} 306 - session={app.activeSession} 307 - onLocationChange={() => setApp("showSwitcher", false)} 308 - renderAuth={() => ( 309 - <AuthWorkspace 310 - accounts={app.accounts} 311 - activeAccount={activeAccount()} 312 - activeSession={app.activeSession} 313 - activeDid={app.activeSession?.did ?? null} 314 - bootstrapping={app.bootstrapping} 315 - loggingIn={app.loggingIn} 316 - loginValue={app.loginValue} 317 - logoutDid={app.logoutDid} 318 - metaLabel={metaLabel()} 319 - reauthNeeded={app.reauthNeeded} 320 - shakeCount={app.shakeCount} 321 - switchingDid={app.switchingDid} 322 - onInput={(value) => setApp("loginValue", value)} 323 - onLogout={(did) => void logout(did)} 324 - onReauth={() => void reauthorizePrimaryAccount()} 325 - onSubmit={() => void submitLogin()} 326 - onSwitch={(did) => void switchAccount(did)} /> 327 - )} 328 - renderShell={AppShell} 329 - renderComposer={(session) => ( 330 - <ComposerWindow 331 - activeAvatar={activeAccount()?.avatar} 332 - activeHandle={session.handle} 333 - onError={(message) => setApp("errorMessage", message)} /> 334 - )} 335 - renderTimeline={({ session, context }) => ( 336 - <FeedWorkspace 337 - activeAvatar={activeAccount()?.avatar} 338 - activeSession={session} 339 - onError={(message) => setApp("errorMessage", message)} 340 - onThreadRouteChange={context.onThreadRouteChange} 341 - threadUri={context.threadUri} /> 342 - )} 343 - renderNotifications={() => <NotificationsPanel onMarkSeen={() => setApp("unreadNotifications", 0)} />} /> 86 + <ErrorToast message={session.errorMessage} onDismiss={session.clearError} /> 87 + </> 344 88 </Show> 89 + ); 90 + } 91 + 92 + function App() { 93 + return ( 94 + <AppSessionProvider> 95 + <AppShellUiProvider> 96 + <AppContent /> 97 + </AppShellUiProvider> 98 + </AppSessionProvider> 345 99 ); 346 100 } 347 101 ··· 364 118 ); 365 119 } 366 120 367 - function AuthWorkspace( 368 - props: { 369 - accounts: AccountSummary[]; 370 - activeAccount: AccountSummary | null; 371 - activeSession: ActiveSession | null; 372 - activeDid: string | null; 373 - bootstrapping: boolean; 374 - loggingIn: boolean; 375 - loginValue: string; 376 - logoutDid: string | null; 377 - metaLabel: string; 378 - reauthNeeded: boolean; 379 - shakeCount: number; 380 - switchingDid: string | null; 381 - onInput: (value: string) => void; 382 - onLogout: (did: string) => void; 383 - onReauth: () => void; 384 - onSubmit: () => void; 385 - onSwitch: (did: string) => void; 386 - }, 387 - ) { 388 - const hasAccounts = () => props.accounts.length > 0; 389 - const displayAccount = () => props.activeAccount ?? (props.reauthNeeded ? props.accounts[0] ?? null : null); 121 + function AuthWorkspace() { 122 + const session = useAppSession(); 123 + const hasAccounts = () => session.accounts.length > 0; 390 124 391 125 return ( 392 126 <Show ··· 395 129 <div class="grid place-items-center py-8"> 396 130 <div class="w-full max-w-md"> 397 131 <LoginPanel 398 - value={props.loginValue} 399 - pending={props.loggingIn} 400 - shakeCount={props.shakeCount} 401 - onInput={props.onInput} 402 - onSubmit={props.onSubmit} /> 132 + value={session.loginValue} 133 + pending={session.loggingIn} 134 + shakeCount={session.shakeCount} 135 + onInput={session.setLoginValue} 136 + onSubmit={() => void session.submitLogin()} /> 403 137 </div> 404 138 </div> 405 139 }> 406 140 <> 407 - <HeaderPanel metaLabel={props.metaLabel} /> 408 - <SessionSpotlight 409 - activeSession={props.activeSession} 410 - activeAccount={displayAccount()} 411 - bootstrapping={props.bootstrapping} 412 - reauthNeeded={props.reauthNeeded} 413 - onReauth={props.onReauth} /> 414 - <AccountLedger 415 - accounts={props.accounts} 416 - activeDid={props.activeDid} 417 - busyDid={props.switchingDid} 418 - logoutDid={props.logoutDid} 419 - onSwitch={props.onSwitch} 420 - onLogout={props.onLogout} /> 141 + <HeaderPanel metaLabel={session.metaLabel} /> 142 + <SessionSpotlight /> 143 + <AccountLedger /> 421 144 </> 422 145 </Show> 423 146 ); 424 - } 425 - 426 - function validateIdentifier(value: string) { 427 - const trimmed = value.trim(); 428 - const handlePattern = /^@?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; 429 - const didPattern = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 430 - const urlPattern = /^https?:\/\/\S+$/i; 431 - 432 - return handlePattern.test(trimmed) || didPattern.test(trimmed) || urlPattern.test(trimmed); 433 147 } 434 148 435 149 export default App;
+15 -38
src/components/AppRail.tsx
··· 1 - import type { AccountSummary, ActiveSession } from "$/lib/types"; 1 + import { useAppSession } from "$/contexts/app-session"; 2 + import { useAppShellUi } from "$/contexts/app-shell-ui"; 2 3 import { Show } from "solid-js"; 3 4 import { AccountSwitcher } from "./account/AccountSwitcher"; 4 5 import { RailButton } from "./RailButton"; ··· 46 47 ); 47 48 } 48 49 49 - export function AppRail( 50 - props: { 51 - activeAccount: AccountSummary | null; 52 - activeSession: ActiveSession | null; 53 - accounts: AccountSummary[]; 54 - collapsed: boolean; 55 - hasSession: boolean; 56 - logoutDid: string | null; 57 - narrow: boolean; 58 - openSwitcher: boolean; 59 - unreadNotifications: number; 60 - onCloseSwitcher: () => void; 61 - switchingDid: string | null; 62 - onLogout: (did: string) => void; 63 - onSwitch: (did: string) => void; 64 - onToggleCollapse: () => void; 65 - onToggleSwitcher: () => void; 66 - }, 67 - ) { 50 + export function AppRail() { 51 + const session = useAppSession(); 52 + const shell = useAppShellUi(); 53 + 68 54 return ( 69 55 <aside 70 56 class="flex min-h-screen min-w-0 flex-col gap-6 overflow-visible bg-surface-container-lowest px-6 pb-6 pt-6 transition-[padding,gap] duration-300 ease-out max-[1180px]:grid max-[1180px]:min-h-0 max-[1180px]:grid-cols-[auto_minmax(0,1fr)_auto] max-[1180px]:items-center max-[1180px]:gap-x-4 max-[1180px]:gap-y-3 max-[1180px]:p-4" 71 - classList={{ "items-center px-4": props.collapsed && !props.narrow, "gap-5": props.collapsed && !props.narrow }} 57 + classList={{ 58 + "items-center px-4": shell.railCondensed && !shell.narrowViewport, 59 + "gap-5": shell.railCondensed && !shell.narrowViewport, 60 + }} 72 61 aria-label="Primary navigation"> 73 - <RailHeader collapsed={props.collapsed} onToggleCollapse={props.onToggleCollapse} /> 62 + <RailHeader collapsed={shell.railCondensed} onToggleCollapse={shell.toggleRailCollapsed} /> 74 63 <RailNavigation 75 - collapsed={props.collapsed} 76 - hasSession={props.hasSession} 77 - unreadNotifications={props.unreadNotifications} /> 78 - <AccountSwitcher 79 - activeAccount={props.activeAccount} 80 - activeSession={props.activeSession} 81 - accounts={props.accounts} 82 - busyDid={props.switchingDid} 83 - compact={props.collapsed || props.narrow} 84 - logoutDid={props.logoutDid} 85 - narrow={props.narrow} 86 - open={props.openSwitcher} 87 - onClose={props.onCloseSwitcher} 88 - onToggle={props.onToggleSwitcher} 89 - onSwitch={props.onSwitch} 90 - onLogout={props.onLogout} /> 64 + collapsed={shell.railCondensed} 65 + hasSession={session.hasSession} 66 + unreadNotifications={session.unreadNotifications} /> 67 + <AccountSwitcher /> 91 68 </aside> 92 69 ); 93 70 }
+24 -27
src/components/Session.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 1 2 import { render, screen } from "@solidjs/testing-library"; 2 - import { describe, expect, it, vi } from "vitest"; 3 + import { describe, expect, it } from "vitest"; 3 4 import { SessionEmptyState, SessionSpotlight } from "./Session"; 4 5 5 6 describe("SessionEmptyState", () => { 6 7 it("renders empty state copy", () => { 7 8 render(() => <SessionEmptyState />); 8 - 9 9 expect(screen.getByText("No account connected yet.")).toBeInTheDocument(); 10 10 expect(screen.getByText("Connect your Bluesky account to start exploring.")).toBeInTheDocument(); 11 11 }); ··· 14 14 describe("SessionSpotlight", () => { 15 15 it("renders 'Your account' label", () => { 16 16 render(() => ( 17 - <SessionSpotlight 18 - activeSession={null} 19 - activeAccount={null} 20 - bootstrapping={false} 21 - reauthNeeded={false} 22 - onReauth={vi.fn()} /> 17 + <AppTestProviders session={{ activeSession: null, activeAccount: null, hasSession: false }}> 18 + <SessionSpotlight /> 19 + </AppTestProviders> 23 20 )); 24 21 25 22 expect(screen.getByText("Your account")).toBeInTheDocument(); ··· 27 24 28 25 it("shows Ready status when no session and not bootstrapping", () => { 29 26 render(() => ( 30 - <SessionSpotlight 31 - activeSession={null} 32 - activeAccount={null} 33 - bootstrapping={false} 34 - reauthNeeded={false} 35 - onReauth={vi.fn()} /> 27 + <AppTestProviders session={{ activeSession: null, activeAccount: null, hasSession: false }}> 28 + <SessionSpotlight /> 29 + </AppTestProviders> 36 30 )); 37 31 38 32 expect(screen.getByText("Ready")).toBeInTheDocument(); 39 33 }); 40 34 41 35 it("shows expired account state when reauth is needed", () => { 36 + const account = { active: false, did: "did:plc:alice", handle: "alice.test", pdsUrl: "https://pds.example.com" }; 37 + 42 38 render(() => ( 43 - <SessionSpotlight 44 - activeSession={null} 45 - activeAccount={{ active: false, did: "did:plc:alice", handle: "alice.test", pdsUrl: "https://pds.example.com" }} 46 - bootstrapping={false} 47 - reauthNeeded 48 - onReauth={vi.fn()} /> 39 + <AppTestProviders 40 + session={{ 41 + accounts: [account], 42 + activeAccount: null, 43 + activeSession: null, 44 + hasSession: false, 45 + primaryAccount: account, 46 + reauthNeeded: true, 47 + }}> 48 + <SessionSpotlight /> 49 + </AppTestProviders> 49 50 )); 50 51 51 52 expect(screen.getByText("Expired")).toBeInTheDocument(); ··· 55 56 56 57 it("shows Reconnecting status when bootstrapping", () => { 57 58 render(() => ( 58 - <SessionSpotlight 59 - activeSession={null} 60 - activeAccount={null} 61 - bootstrapping 62 - reauthNeeded={false} 63 - onReauth={vi.fn()} /> 59 + <AppTestProviders session={{ activeSession: null, activeAccount: null, bootstrapping: true, hasSession: false }}> 60 + <SessionSpotlight /> 61 + </AppTestProviders> 64 62 )); 65 - 66 63 expect(screen.getByText("Reconnecting")).toBeInTheDocument(); 67 64 }); 68 65 });
+17 -21
src/components/Session.tsx
··· 1 + import { useAppSession } from "$/contexts/app-session"; 1 2 import type { AccountSummary, ActiveSession } from "$/lib/types"; 2 3 import { createMemo, Show } from "solid-js"; 3 4 import { Presence } from "solid-motionone"; ··· 44 45 ); 45 46 } 46 47 47 - type SessionSpotlightProps = { 48 - activeSession: ActiveSession | null; 49 - activeAccount: AccountSummary | null; 50 - bootstrapping: boolean; 51 - reauthNeeded: boolean; 52 - onReauth: () => void; 53 - }; 54 - 55 - export function SessionSpotlight(props: SessionSpotlightProps) { 56 - const bootstrapping = () => props.bootstrapping; 57 - const activeSession = () => props.activeSession; 48 + export function SessionSpotlight() { 49 + const session = useAppSession(); 50 + const displayAccount = createMemo(() => 51 + session.activeAccount ?? (session.reauthNeeded ? session.primaryAccount : null) 52 + ); 58 53 const label = createMemo(() => { 59 - if (bootstrapping()) { 54 + if (session.bootstrapping) { 60 55 return "Reconnecting"; 61 56 } 62 57 63 - if (activeSession()) { 58 + if (session.activeSession) { 64 59 return "Connected"; 65 60 } 66 61 67 - if (props.reauthNeeded && props.activeAccount) { 62 + if (session.reauthNeeded && displayAccount()) { 68 63 return "Expired"; 69 64 } 70 65 71 66 return "Ready"; 72 67 }); 68 + 73 69 return ( 74 70 <article class="panel-surface grid gap-5 p-5"> 75 71 <div class="flex items-baseline justify-between gap-3"> ··· 78 74 </div> 79 75 80 76 <SessionBody 81 - activeSession={props.activeSession} 82 - activeAccount={props.activeAccount} 83 - bootstrapping={props.bootstrapping} 84 - reauthNeeded={props.reauthNeeded} /> 77 + activeAccount={displayAccount()} 78 + activeSession={session.activeSession} 79 + bootstrapping={session.bootstrapping} 80 + reauthNeeded={session.reauthNeeded} /> 85 81 86 82 <Presence> 87 - <Show when={props.reauthNeeded}> 88 - <ReauthBanner onReauth={props.onReauth} /> 83 + <Show when={session.reauthNeeded}> 84 + <ReauthBanner onReauth={() => void session.reauthorizePrimaryAccount()} /> 89 85 </Show> 90 86 </Presence> 91 87 </article> ··· 109 105 {(account) => <SessionExpiredState account={account()} />} 110 106 </Show> 111 107 }> 112 - {(session) => <SessionProfile session={session()} activeAccount={props.activeAccount} />} 108 + {(currentSession) => <SessionProfile session={currentSession()} activeAccount={props.activeAccount} />} 113 109 </Show> 114 110 </Show> 115 111 );
+11 -17
src/components/account/AccountLedger.tsx
··· 1 1 import { AvatarBadge } from "$/components/AvatarBadge"; 2 + import { useAppSession } from "$/contexts/app-session"; 2 3 import type { AccountSummary } from "$/lib/types"; 3 4 import { For, Show } from "solid-js"; 4 5 import { Motion } from "solid-motionone"; 5 6 import { AccountSwitchButton, LogoutButton } from "./AccountButtons"; 6 7 7 - type AccountLedgerProps = { 8 - accounts: AccountSummary[]; 9 - activeDid: string | null; 10 - busyDid: string | null; 11 - logoutDid: string | null; 12 - onSwitch: (did: string) => void; 13 - onLogout: (did: string) => void; 14 - }; 8 + export function AccountLedger() { 9 + const session = useAppSession(); 15 10 16 - export function AccountLedger(props: AccountLedgerProps) { 17 11 return ( 18 12 <article class="panel-surface grid gap-5 p-5"> 19 13 <div class="flex items-baseline justify-between gap-3"> 20 14 <p class="overline-copy text-[0.75rem] text-on-surface-variant">Accounts</p> 21 - <p class="m-0 text-xs leading-[1.55] text-on-surface-variant">{props.accounts.length} added</p> 15 + <p class="m-0 text-xs leading-[1.55] text-on-surface-variant">{session.accounts.length} added</p> 22 16 </div> 23 17 24 18 <Show 25 - when={props.accounts.length > 0} 19 + when={session.accounts.length > 0} 26 20 fallback={ 27 21 <p class="overline-copy text-xs text-on-surface-variant">Your accounts will appear here once you sign in.</p> 28 22 }> 29 23 <div class="grid gap-3" role="list"> 30 - <For each={props.accounts}> 24 + <For each={session.accounts}> 31 25 {(account) => ( 32 26 <AccountLedgerCard 33 27 account={account} 34 - activeDid={props.activeDid} 35 - busyDid={props.busyDid} 36 - logoutDid={props.logoutDid} 37 - onSwitch={props.onSwitch} 38 - onLogout={props.onLogout} /> 28 + activeDid={session.activeDid} 29 + busyDid={session.switchingDid} 30 + logoutDid={session.logoutDid} 31 + onSwitch={(did) => void session.switchAccount(did)} 32 + onLogout={(did) => void session.logout(did)} /> 39 33 )} 40 34 </For> 41 35 </div>
+24 -26
src/components/account/AccountSwitcher.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 1 2 import { fireEvent, render, screen } from "@solidjs/testing-library"; 2 3 import { describe, expect, it, vi } from "vitest"; 3 4 import { AccountSwitcher } from "./AccountSwitcher"; ··· 13 14 describe("AccountSwitcher", () => { 14 15 it("renders the stored account when no active session exists", () => { 15 16 render(() => ( 16 - <AccountSwitcher 17 - activeAccount={null} 18 - activeSession={null} 19 - accounts={[ACCOUNT]} 20 - busyDid={null} 21 - logoutDid={null} 22 - open={false} 23 - onClose={vi.fn()} 24 - onLogout={vi.fn()} 25 - onSwitch={vi.fn()} 26 - onToggle={vi.fn()} /> 17 + <AppTestProviders 18 + session={{ 19 + accounts: [ACCOUNT], 20 + activeAccount: null, 21 + activeSession: null, 22 + hasSession: false, 23 + primaryAccount: ACCOUNT, 24 + }}> 25 + <AccountSwitcher /> 26 + </AppTestProviders> 27 27 )); 28 28 29 29 expect(screen.getByText("alice.test")).toBeInTheDocument(); ··· 31 31 }); 32 32 33 33 it("closes the menu on outside pointerdown instead of toggling", () => { 34 - const onClose = vi.fn(); 35 - const onToggle = vi.fn(); 34 + const closeSwitcher = vi.fn(); 35 + const toggleSwitcher = vi.fn(); 36 36 37 37 render(() => ( 38 38 <> 39 - <AccountSwitcher 40 - activeAccount={ACCOUNT} 41 - activeSession={{ did: ACCOUNT.did, handle: ACCOUNT.handle }} 42 - accounts={[ACCOUNT]} 43 - busyDid={null} 44 - logoutDid={null} 45 - open 46 - onClose={onClose} 47 - onLogout={vi.fn()} 48 - onSwitch={vi.fn()} 49 - onToggle={onToggle} /> 39 + <AppTestProviders 40 + session={{ 41 + accounts: [ACCOUNT], 42 + activeAccount: ACCOUNT, 43 + activeSession: { did: ACCOUNT.did, handle: ACCOUNT.handle }, 44 + }} 45 + shell={{ closeSwitcher, showSwitcher: true, toggleSwitcher }}> 46 + <AccountSwitcher /> 47 + </AppTestProviders> 50 48 <div data-testid="outside">outside</div> 51 49 </> 52 50 )); 53 51 54 52 fireEvent.pointerDown(screen.getByTestId("outside")); 55 53 56 - expect(onClose).toHaveBeenCalledTimes(1); 57 - expect(onToggle).not.toHaveBeenCalled(); 54 + expect(closeSwitcher).toHaveBeenCalledTimes(1); 55 + expect(toggleSwitcher).not.toHaveBeenCalled(); 58 56 }); 59 57 });
+46 -47
src/components/account/AccountSwitcher.tsx
··· 1 1 import { ArrowIcon } from "$/components/shared/Icon"; 2 - import type { AccountSummary, ActiveSession } from "$/lib/types"; 2 + import { useAppSession } from "$/contexts/app-session"; 3 + import { useAppShellUi } from "$/contexts/app-shell-ui"; 3 4 import { createMemo, onCleanup, onMount, Show } from "solid-js"; 4 5 import { SwitcherIdentity } from "./AccountSwitcherIdentity"; 5 6 import { AccountSwitcherMenuList } from "./AccountSwitcherMenuList"; 6 7 7 - type AccountSwitcherProps = { 8 - activeAccount: AccountSummary | null; 9 - activeSession: ActiveSession | null; 10 - accounts: AccountSummary[]; 11 - busyDid: string | null; 12 - compact?: boolean; 13 - logoutDid: string | null; 14 - narrow?: boolean; 15 - open: boolean; 16 - onClose: () => void; 17 - onToggle: () => void; 18 - onSwitch: (did: string) => void; 19 - onLogout: (did: string) => void; 20 - }; 21 - 22 - export function AccountSwitcher(props: AccountSwitcherProps) { 23 - const isOpen = () => props.open; 24 - const previewAccount = createMemo(() => props.activeAccount ?? props.accounts[0] ?? null); 8 + export function AccountSwitcher() { 9 + const session = useAppSession(); 10 + const shell = useAppShellUi(); 11 + const previewAccount = createMemo(() => session.activeAccount ?? session.accounts[0] ?? null); 25 12 const identity = createMemo(() => { 26 - if (props.activeSession) { 13 + if (session.activeSession) { 27 14 return { 28 - avatar: props.activeAccount?.avatar ?? null, 29 - label: props.activeSession.handle, 15 + avatar: session.activeAvatar, 16 + label: session.activeSession.handle, 30 17 meta: "Current account", 31 - name: props.activeSession.handle, 18 + name: session.activeSession.handle, 32 19 tone: "primary" as const, 33 20 }; 34 21 } ··· 48 35 }); 49 36 let container: HTMLDivElement | undefined; 50 37 38 + const compact = () => shell.railCondensed; 39 + 51 40 onMount(() => { 52 41 const pointerListener = { 53 42 handleEvent(event: Event) { 54 - if (!isOpen()) { 43 + if (!shell.showSwitcher) { 55 44 return; 56 45 } 57 46 ··· 59 48 return; 60 49 } 61 50 62 - props.onClose(); 51 + shell.closeSwitcher(); 63 52 }, 64 53 }; 65 54 ··· 67 56 onCleanup(() => globalThis.removeEventListener("pointerdown", pointerListener)); 68 57 }); 69 58 59 + async function handleSwitch(did: string) { 60 + shell.closeSwitcher(); 61 + await session.switchAccount(did); 62 + } 63 + 64 + async function handleLogout(did: string) { 65 + shell.closeSwitcher(); 66 + await session.logout(did); 67 + } 68 + 70 69 return ( 71 70 <div 72 71 class="relative mt-auto w-full transition-[width,max-width] duration-300 ease-out max-[1180px]:mt-0 max-[1180px]:max-w-none" 73 72 classList={{ 74 - "z-40": props.open, 75 - "w-auto": !!props.compact, 76 - "max-[1180px]:col-start-3 max-[1180px]:row-start-1 max-[1180px]:justify-self-end": !!props.narrow, 77 - "max-[1180px]:col-span-full max-[1180px]:justify-self-stretch": !props.narrow, 73 + "z-40": shell.showSwitcher, 74 + "w-auto": compact(), 75 + "max-[1180px]:col-start-3 max-[1180px]:row-start-1 max-[1180px]:justify-self-end": shell.narrowViewport, 76 + "max-[1180px]:col-span-full max-[1180px]:justify-self-stretch": !shell.narrowViewport, 78 77 }} 79 78 ref={(element) => { 80 79 container = element; ··· 82 81 <button 83 82 class="relative w-full min-w-0 cursor-pointer border-0 bg-white/4 text-on-surface shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 ease-out hover:-translate-y-px hover:bg-white/8" 84 83 classList={{ 85 - "rounded-xl py-[0.95rem] pr-10 pl-4": !props.compact, 86 - "grid h-14 w-14 place-items-center overflow-visible rounded-full p-0": !!props.compact, 84 + "rounded-xl py-[0.95rem] pr-10 pl-4": !compact(), 85 + "grid h-14 w-14 place-items-center overflow-visible rounded-full p-0": compact(), 87 86 }} 88 87 type="button" 89 88 aria-haspopup="menu" 90 - aria-expanded={props.open} 91 - aria-label={props.activeSession ? `Current account ${props.activeSession.handle}` : identity().name} 92 - onClick={() => props.onToggle()}> 89 + aria-expanded={shell.showSwitcher} 90 + aria-label={session.activeSession ? `Current account ${session.activeSession.handle}` : identity().name} 91 + onClick={shell.toggleSwitcher}> 93 92 <SwitcherIdentity 94 93 avatar={identity().avatar} 95 - compact={props.compact} 94 + compact={compact()} 96 95 label={identity().label} 97 96 meta={identity().meta} 98 97 name={identity().name} ··· 100 99 <span 101 100 class="absolute flex items-center justify-center text-on-surface-variant" 102 101 classList={{ 103 - "right-[0.95rem] top-1/2 -translate-y-1/2": !props.compact, 102 + "right-[0.95rem] top-1/2 -translate-y-1/2": !compact(), 104 103 "bottom-0 right-0 h-5 w-5 translate-x-[8%] translate-y-[8%] rounded-full bg-surface-container text-[0.7rem] leading-none shadow-[0_0_0_2px_rgba(8,8,8,0.9),inset_0_0_0_1px_rgba(255,255,255,0.05)]": 105 - !!props.compact, 104 + compact(), 106 105 }} 107 106 aria-hidden="true"> 108 - <Show when={props.open} fallback={<ArrowIcon direction="down" />}> 107 + <Show when={shell.showSwitcher} fallback={<ArrowIcon direction="down" />}> 109 108 <ArrowIcon direction="up" /> 110 109 </Show> 111 110 </span> 112 111 </button> 113 112 114 - <Show when={props.open}> 113 + <Show when={shell.showSwitcher}> 115 114 <div 116 115 class="absolute z-50 rounded-2xl bg-(--surface-container-highest) p-4 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px] max-[1180px]:bottom-auto max-[1180px]:top-[calc(100%+0.75rem)]" 117 116 classList={{ 118 - "inset-x-0 bottom-[calc(100%+0.75rem)]": !props.compact, 119 - "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": !!props.compact && !props.narrow, 120 - "right-0 w-[19rem]": !!props.compact && !!props.narrow, 117 + "inset-x-0 bottom-[calc(100%+0.75rem)]": !compact(), 118 + "bottom-0 left-[calc(100%+0.85rem)] w-[19rem]": compact() && !shell.narrowViewport, 119 + "right-0 w-[19rem]": compact() && shell.narrowViewport, 121 120 }} 122 121 role="menu"> 123 122 <p class="overline-copy text-[0.68rem] text-on-surface-variant">Accounts</p> 124 123 <AccountSwitcherMenuList 125 - accounts={props.accounts} 126 - busyDid={props.busyDid} 127 - logoutDid={props.logoutDid} 128 - onSwitch={props.onSwitch} 129 - onLogout={props.onLogout} /> 124 + accounts={session.accounts} 125 + busyDid={session.switchingDid} 126 + logoutDid={session.logoutDid} 127 + onSwitch={(did) => void handleSwitch(did)} 128 + onLogout={(did) => void handleLogout(did)} /> 130 129 </div> 131 130 </Show> 132 131 </div>
+6 -6
src/components/feeds/ComposerWindow.tsx
··· 1 + import { useAppSession } from "$/contexts/app-session"; 1 2 import { createPost } from "$/lib/api/feeds"; 2 3 import { POST_CREATED_EVENT } from "$/lib/constants/events"; 3 4 import { emitTo } from "@tauri-apps/api/event"; ··· 5 6 import { createSignal } from "solid-js"; 6 7 import { ComposerSurface } from "./FeedComposer"; 7 8 8 - type ComposerWindowProps = { activeAvatar?: string | null; activeHandle: string; onError: (message: string) => void }; 9 - 10 9 async function closeWindow() { 11 10 await getCurrentWindow().close(); 12 11 } 13 12 14 - export function ComposerWindow(props: ComposerWindowProps) { 13 + export function ComposerWindow() { 14 + const session = useAppSession(); 15 15 const [pending, setPending] = createSignal(false); 16 16 const [text, setText] = createSignal(""); 17 17 ··· 27 27 await emitTo("main", POST_CREATED_EVENT, null); 28 28 await closeWindow(); 29 29 } catch (error) { 30 - props.onError(`Failed to create post: ${String(error)}`); 30 + session.reportError(`Failed to create post: ${String(error)}`); 31 31 } finally { 32 32 setPending(false); 33 33 } ··· 36 36 return ( 37 37 <div class="min-h-screen bg-[radial-gradient(circle_at_top,rgba(125,175,255,0.12),transparent_32%),#000]"> 38 38 <ComposerSurface 39 - activeAvatar={props.activeAvatar} 40 - activeHandle={props.activeHandle} 39 + activeAvatar={session.activeAvatar} 40 + activeHandle={session.activeHandle} 41 41 layout="window" 42 42 pending={pending()} 43 43 quoteTarget={null}
+40 -126
src/components/feeds/FeedPane.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 + import { useAppSession } from "$/contexts/app-session"; 2 3 import { getFeedName } from "$/lib/feeds"; 3 - import type { FeedGeneratorView, FeedViewPost, PostView, SavedFeedItem } from "$/lib/types"; 4 + import type { FeedGeneratorView, SavedFeedItem } from "$/lib/types"; 4 5 import { ComposerLauncher } from "./FeedComposer"; 5 6 import { FeedContent } from "./FeedContent"; 6 7 import { FeedTabBar } from "./FeedTabs"; 7 - import type { FeedState } from "./types"; 8 + import type { FeedWorkspaceController } from "./useFeedWorkspaceController"; 8 9 9 10 function FeedHeaderActions(props: { onCompose: () => void; onRefresh: () => void }) { 10 11 return ( ··· 29 30 } 30 31 31 32 function FeedScroller( 32 - props: { 33 - activeFeedId: string; 34 - activeFeedState: FeedState | undefined; 35 - activeAvatar?: string | null; 36 - activeHandle: string; 37 - focusedIndex: number; 38 - generators: Record<string, FeedGeneratorView>; 39 - likePendingByUri: Record<string, boolean>; 40 - likePulseUri: string | null; 41 - onCompose: () => void; 42 - onFocusIndex: (index: number) => void; 43 - onLike: (post: PostView) => Promise<void>; 44 - onOpenThread: (uri: string) => Promise<void>; 45 - onQuote: (post: PostView) => void; 46 - onReply: (post: PostView, root: PostView) => void; 47 - onRepost: (post: PostView) => Promise<void>; 48 - postRefs: Map<string, HTMLElement>; 49 - repostPendingByUri: Record<string, boolean>; 50 - repostPulseUri: string | null; 51 - scrollerRef: (element: HTMLDivElement) => void; 52 - sentinelRef: (element: HTMLDivElement) => void; 53 - setScrollTop: (top: number) => void; 54 - visibleItems: FeedViewPost[]; 55 - }, 33 + props: { controller: FeedWorkspaceController; activeAvatar?: string | null; activeHandle: string }, 56 34 ) { 57 35 return ( 58 36 <div 59 - ref={(element) => props.scrollerRef(element)} 37 + ref={(element) => props.controller.registerScroller(element)} 60 38 class="feed-scroll-region min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain px-6 pb-8 pt-4 max-[760px]:px-4 max-[520px]:px-3" 61 - onScroll={(event) => props.setScrollTop(event.currentTarget.scrollTop)}> 39 + onScroll={(event) => props.controller.rememberScrollTop(event.currentTarget.scrollTop)}> 62 40 <ComposerLauncher 63 41 activeAvatar={props.activeAvatar} 64 42 activeHandle={props.activeHandle} 65 - onCompose={props.onCompose} /> 43 + onCompose={props.controller.openComposer} /> 66 44 <FeedContent 67 - activeFeedId={props.activeFeedId} 68 - activeFeedState={props.activeFeedState} 69 - focusedIndex={props.focusedIndex} 70 - likePendingByUri={props.likePendingByUri} 71 - likePulseUri={props.likePulseUri} 72 - onFocusIndex={props.onFocusIndex} 73 - onLike={props.onLike} 74 - onOpenThread={props.onOpenThread} 75 - onQuote={props.onQuote} 76 - onReply={props.onReply} 77 - onRepost={props.onRepost} 78 - postRefs={props.postRefs} 79 - repostPendingByUri={props.repostPendingByUri} 80 - repostPulseUri={props.repostPulseUri} 81 - sentinelRef={props.sentinelRef} 82 - visibleItems={props.visibleItems} /> 45 + activeFeedId={props.controller.activeFeed().id} 46 + activeFeedState={props.controller.activeFeedState()} 47 + focusedIndex={props.controller.workspace.focusedIndex} 48 + likePendingByUri={props.controller.workspace.likePendingByUri} 49 + likePulseUri={props.controller.workspace.likePulseUri} 50 + onFocusIndex={props.controller.setFocusedIndex} 51 + onLike={props.controller.toggleLike} 52 + onOpenThread={props.controller.openThread} 53 + onQuote={props.controller.openQuoteComposer} 54 + onReply={props.controller.openReplyComposer} 55 + onRepost={props.controller.toggleRepost} 56 + postRefs={props.controller.postRefs} 57 + repostPendingByUri={props.controller.workspace.repostPendingByUri} 58 + repostPulseUri={props.controller.workspace.repostPulseUri} 59 + sentinelRef={props.controller.registerSentinel} 60 + visibleItems={props.controller.visibleItems()} /> 83 61 </div> 84 62 ); 85 63 } ··· 105 83 ); 106 84 } 107 85 108 - function FeedPaneHeader( 109 - props: { 110 - activeFeed: SavedFeedItem; 111 - generators: Record<string, FeedGeneratorView>; 112 - onCompose: () => void; 113 - onFeedSelect: (feedId: string) => void; 114 - onRefresh: () => void; 115 - onToggleDrawer: () => void; 116 - pinnedFeeds: SavedFeedItem[]; 117 - }, 118 - ) { 86 + function FeedPaneHeader(props: { controller: FeedWorkspaceController }) { 119 87 return ( 120 88 <header class="sticky top-0 z-20 overflow-hidden rounded-t-4xl bg-[rgba(14,14,14,0.94)] px-6 pb-3 pt-5 backdrop-blur-[18px] shadow-[inset_0_-1px_0_rgba(255,255,255,0.04)] max-[960px]:px-5 max-[960px]:pb-4 max-[960px]:pt-4 max-[760px]:px-4 max-[520px]:px-3"> 121 89 <FeedPaneTitle 122 - activeFeed={props.activeFeed} 123 - generators={props.generators} 124 - onCompose={props.onCompose} 125 - onRefresh={props.onRefresh} /> 90 + activeFeed={props.controller.activeFeed()} 91 + generators={props.controller.workspace.generators} 92 + onCompose={props.controller.openComposer} 93 + onRefresh={() => void props.controller.refreshActiveFeed()} /> 126 94 <FeedTabBar 127 - activeFeedId={props.activeFeed.id} 128 - generators={props.generators} 129 - onFeedSelect={props.onFeedSelect} 130 - onToggleDrawer={props.onToggleDrawer} 131 - pinnedFeeds={props.pinnedFeeds} /> 95 + activeFeedId={props.controller.activeFeed().id} 96 + generators={props.controller.workspace.generators} 97 + onFeedSelect={props.controller.switchFeed} 98 + onToggleDrawer={props.controller.toggleFeedsDrawer} 99 + pinnedFeeds={props.controller.pinnedFeeds().slice(0, 9)} /> 132 100 </header> 133 101 ); 134 102 } 135 103 136 - export function FeedPane( 137 - props: { 138 - activeFeed: SavedFeedItem; 139 - activeFeedId: string; 140 - activeFeedState: FeedState | undefined; 141 - activeAvatar?: string | null; 142 - activeHandle: string; 143 - focusedIndex: number; 144 - generators: Record<string, FeedGeneratorView>; 145 - likePendingByUri: Record<string, boolean>; 146 - likePulseUri: string | null; 147 - onCompose: () => void; 148 - onFeedSelect: (feedId: string) => void; 149 - onFocusIndex: (index: number) => void; 150 - onLike: (post: PostView) => Promise<void>; 151 - onOpenThread: (uri: string) => Promise<void>; 152 - onQuote: (post: PostView) => void; 153 - onRefresh: () => void; 154 - onReply: (post: PostView, root: PostView) => void; 155 - onRepost: (post: PostView) => Promise<void>; 156 - onToggleDrawer: () => void; 157 - pinnedFeeds: SavedFeedItem[]; 158 - postRefs: Map<string, HTMLElement>; 159 - repostPendingByUri: Record<string, boolean>; 160 - repostPulseUri: string | null; 161 - scrollerRef: (element: HTMLDivElement) => void; 162 - sentinelRef: (element: HTMLDivElement) => void; 163 - setScrollTop: (top: number) => void; 164 - visibleItems: FeedViewPost[]; 165 - }, 166 - ) { 104 + export function FeedPane(props: { controller: FeedWorkspaceController }) { 105 + const session = useAppSession(); 106 + 167 107 return ( 168 108 <section class="grid min-h-0 min-w-0 overflow-hidden grid-rows-[auto_minmax(0,1fr)] rounded-4xl bg-[rgba(8,8,8,0.32)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 169 - <FeedPaneHeader 170 - activeFeed={props.activeFeed} 171 - generators={props.generators} 172 - onCompose={props.onCompose} 173 - onFeedSelect={props.onFeedSelect} 174 - onRefresh={props.onRefresh} 175 - onToggleDrawer={props.onToggleDrawer} 176 - pinnedFeeds={props.pinnedFeeds} /> 109 + <FeedPaneHeader controller={props.controller} /> 177 110 <FeedScroller 178 - activeFeedId={props.activeFeedId} 179 - activeFeedState={props.activeFeedState} 180 - activeAvatar={props.activeAvatar} 181 - activeHandle={props.activeHandle} 182 - focusedIndex={props.focusedIndex} 183 - generators={props.generators} 184 - likePendingByUri={props.likePendingByUri} 185 - likePulseUri={props.likePulseUri} 186 - onCompose={props.onCompose} 187 - onFocusIndex={props.onFocusIndex} 188 - onLike={props.onLike} 189 - onOpenThread={props.onOpenThread} 190 - onQuote={props.onQuote} 191 - onReply={props.onReply} 192 - onRepost={props.onRepost} 193 - postRefs={props.postRefs} 194 - repostPendingByUri={props.repostPendingByUri} 195 - repostPulseUri={props.repostPulseUri} 196 - scrollerRef={props.scrollerRef} 197 - sentinelRef={props.sentinelRef} 198 - setScrollTop={props.setScrollTop} 199 - visibleItems={props.visibleItems} /> 111 + controller={props.controller} 112 + activeAvatar={session.activeAvatar} 113 + activeHandle={session.activeHandle ?? ""} /> 200 114 </section> 201 115 ); 202 116 }
+5 -1
src/components/feeds/FeedWorkspace.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 1 2 import { fireEvent, render, screen } from "@solidjs/testing-library"; 2 3 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 4 import { FeedWorkspace } from "./FeedWorkspace"; ··· 107 108 }); 108 109 109 110 const { container } = render(() => ( 110 - <FeedWorkspace activeSession={ACTIVE_SESSION} onError={vi.fn()} onThreadRouteChange={vi.fn()} threadUri={null} /> 111 + <AppTestProviders 112 + session={{ activeDid: ACTIVE_SESSION.did, activeHandle: ACTIVE_SESSION.handle, activeSession: ACTIVE_SESSION }}> 113 + <FeedWorkspace onThreadRouteChange={vi.fn()} threadUri={null} /> 114 + </AppTestProviders> 111 115 )); 112 116 113 117 await screen.findByText("Page 1");
+25 -32
src/components/feeds/FeedWorkspace.tsx
··· 1 + import { useAppSession } from "$/contexts/app-session"; 1 2 import { FeedComposer } from "./FeedComposer"; 2 3 import { SavedFeedsDrawer } from "./FeedDrawer"; 3 4 import { FeedPane } from "./FeedPane"; ··· 5 6 import { ThreadPanel } from "./ThreadPanel"; 6 7 import { type FeedWorkspaceProps, useFeedWorkspaceController } from "./useFeedWorkspaceController"; 7 8 8 - export function FeedWorkspace(props: FeedWorkspaceProps) { 9 - const controller = useFeedWorkspaceController(props); 9 + type FeedWorkspaceRouteProps = Pick<FeedWorkspaceProps, "onThreadRouteChange" | "threadUri">; 10 + 11 + export function FeedWorkspace(props: FeedWorkspaceRouteProps) { 12 + const session = useAppSession(); 13 + const activeSession = () => { 14 + if (!session.activeSession) { 15 + throw new Error("FeedWorkspace requires an active session"); 16 + } 17 + 18 + return session.activeSession; 19 + }; 20 + const controller = useFeedWorkspaceController({ 21 + activeSession: activeSession(), 22 + onError: session.reportError, 23 + get onThreadRouteChange() { 24 + return props.onThreadRouteChange; 25 + }, 26 + get threadUri() { 27 + return props.threadUri; 28 + }, 29 + }); 10 30 11 31 return ( 12 32 <> 13 33 <div class="grid h-full min-h-0 min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem] max-[1180px]:gap-5 max-[900px]:gap-4"> 14 - <FeedPane 15 - activeFeed={controller.activeFeed()} 16 - activeFeedId={controller.activeFeed().id} 17 - activeFeedState={controller.activeFeedState()} 18 - activeAvatar={props.activeAvatar} 19 - activeHandle={props.activeSession.handle} 20 - focusedIndex={controller.workspace.focusedIndex} 21 - generators={controller.workspace.generators} 22 - likePendingByUri={controller.workspace.likePendingByUri} 23 - likePulseUri={controller.workspace.likePulseUri} 24 - onCompose={controller.openComposer} 25 - onFeedSelect={controller.switchFeed} 26 - onFocusIndex={controller.setFocusedIndex} 27 - onLike={controller.toggleLike} 28 - onOpenThread={controller.openThread} 29 - onQuote={controller.openQuoteComposer} 30 - onRefresh={() => void controller.refreshActiveFeed()} 31 - onReply={controller.openReplyComposer} 32 - onRepost={controller.toggleRepost} 33 - onToggleDrawer={controller.toggleFeedsDrawer} 34 - pinnedFeeds={controller.pinnedFeeds().slice(0, 9)} 35 - postRefs={controller.postRefs} 36 - repostPendingByUri={controller.workspace.repostPendingByUri} 37 - repostPulseUri={controller.workspace.repostPulseUri} 38 - scrollerRef={controller.registerScroller} 39 - sentinelRef={controller.registerSentinel} 40 - setScrollTop={controller.rememberScrollTop} 41 - visibleItems={controller.visibleItems()} /> 34 + <FeedPane controller={controller} /> 42 35 43 36 <FeedWorkspaceSidebar 44 37 activePref={controller.activePref()} ··· 72 65 thread={controller.workspace.thread.data} /> 73 66 74 67 <FeedComposer 75 - activeAvatar={props.activeAvatar} 76 - activeHandle={props.activeSession.handle} 68 + activeAvatar={session.activeAvatar} 69 + activeHandle={session.activeHandle} 77 70 open={controller.workspace.composer.open} 78 71 pending={controller.workspace.composer.pending} 79 72 quoteTarget={controller.workspace.composer.quoteTarget}
+2 -1
src/components/feeds/useFeedWorkspaceController.ts
··· 44 44 } from "./workspace-state"; 45 45 46 46 export type FeedWorkspaceProps = { 47 - activeAvatar?: string | null; 48 47 activeSession: ActiveSession; 49 48 onError: (message: string) => void; 50 49 onThreadRouteChange: (uri: string | null) => void; ··· 725 724 workspace, 726 725 }; 727 726 } 727 + 728 + export type FeedWorkspaceController = ReturnType<typeof useFeedWorkspaceController>;
+18 -5
src/components/notifications/NotificationsPanel.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 1 2 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 4 import { NotificationsPanel } from "./NotificationsPanel"; ··· 43 44 seenAt: null, 44 45 }); 45 46 46 - const onMarkSeen = vi.fn(); 47 - render(() => <NotificationsPanel onMarkSeen={onMarkSeen} />); 47 + const markNotificationsSeen = vi.fn(); 48 + render(() => ( 49 + <AppTestProviders session={{ markNotificationsSeen }}> 50 + <NotificationsPanel /> 51 + </AppTestProviders> 52 + )); 48 53 49 54 await screen.findByLabelText("mention author mentioned you"); 50 55 await waitFor(() => expect(updateSeenMock).toHaveBeenCalledOnce()); 51 - await waitFor(() => expect(onMarkSeen).toHaveBeenCalledOnce()); 56 + await waitFor(() => expect(markNotificationsSeen).toHaveBeenCalledOnce()); 52 57 53 58 expect(screen.queryByLabelText("like author liked your post")).not.toBeInTheDocument(); 54 59 ··· 75 80 return Promise.resolve(() => {}); 76 81 }); 77 82 78 - render(() => <NotificationsPanel onMarkSeen={vi.fn()} />); 83 + render(() => ( 84 + <AppTestProviders> 85 + <NotificationsPanel /> 86 + </AppTestProviders> 87 + )); 79 88 80 89 await screen.findByLabelText("mention author mentioned you"); 81 90 ··· 88 97 it("shows the error state when loading fails", async () => { 89 98 listNotificationsMock.mockRejectedValue(new Error("notification fetch failed")); 90 99 91 - render(() => <NotificationsPanel onMarkSeen={vi.fn()} />); 100 + render(() => ( 101 + <AppTestProviders> 102 + <NotificationsPanel /> 103 + </AppTestProviders> 104 + )); 92 105 93 106 expect(await screen.findByText("notification fetch failed")).toBeInTheDocument(); 94 107 });
+5 -4
src/components/notifications/NotificationsPanel.tsx
··· 1 + import { useAppSession } from "$/contexts/app-session"; 1 2 import { listNotifications, updateSeen } from "$/lib/api/notifications"; 2 3 import { NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; 3 4 import type { ListNotificationsResponse, NotificationView } from "$/lib/types"; ··· 13 14 14 15 const MENTION_REASONS = new Set(["mention", "reply", "quote"]); 15 16 16 - type NotificationsPanelProps = { onMarkSeen: () => void }; 17 - 18 - export function NotificationsPanel(props: NotificationsPanelProps) { 17 + export function NotificationsPanel() { 18 + const session = useAppSession(); 19 + // TODO: NotificationsStore via createStore 19 20 const [tab, setTab] = createSignal<Tab>("mentions"); 20 21 const [notifications, setNotifications] = createSignal<NotificationView[]>([]); 21 22 const [loading, setLoading] = createSignal(true); ··· 43 44 try { 44 45 await updateSeen(); 45 46 setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); 46 - props.onMarkSeen(); 47 + session.markNotificationsSeen(); 47 48 } catch (err) { 48 49 const error = normalizeError(err); 49 50 logger.warn("failed to mark notifications as seen", { keyValues: { error } });
+17 -8
src/components/search/SearchPanel.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 1 2 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 4 import { SearchPanel } from "./SearchPanel"; ··· 25 26 26 27 vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); 27 28 29 + function renderSearchPanel() { 30 + render(() => ( 31 + <AppTestProviders> 32 + <SearchPanel /> 33 + </AppTestProviders> 34 + )); 35 + } 36 + 28 37 describe("SearchPanel", () => { 29 38 beforeEach(() => { 30 39 vi.useFakeTimers(); ··· 61 70 }); 62 71 63 72 it("renders the search panel with initial state", async () => { 64 - render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 73 + renderSearchPanel(); 65 74 66 75 expect(await screen.findByPlaceholderText("Search your saved & liked posts...")).toBeInTheDocument(); 67 76 expect(screen.getByText("Network")).toBeInTheDocument(); ··· 71 80 }); 72 81 73 82 it("switches search modes when clicking mode buttons", async () => { 74 - render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 83 + renderSearchPanel(); 75 84 76 85 const keywordButton = screen.getByRole("button", { name: /keyword/i }); 77 86 fireEvent.click(keywordButton); ··· 92 101 }], 93 102 }); 94 103 95 - render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 104 + renderSearchPanel(); 96 105 97 106 const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 98 107 fireEvent.input(input, { target: { value: "test query" } }); ··· 121 130 semanticMatch: false, 122 131 }]); 123 132 124 - render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 133 + renderSearchPanel(); 125 134 126 135 const keywordButton = screen.getByRole("button", { name: /keyword/i }); 127 136 fireEvent.click(keywordButton); ··· 139 148 }); 140 149 141 150 it("cycles through modes with Tab key", async () => { 142 - render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 151 + renderSearchPanel(); 143 152 144 153 const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 145 154 input.focus(); ··· 159 168 }], 160 169 }); 161 170 162 - render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 171 + renderSearchPanel(); 163 172 164 173 const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 165 174 fireEvent.input(input, { target: { value: "test" } }); ··· 177 186 it("displays error state when search fails", async () => { 178 187 searchPostsNetworkMock.mockRejectedValue(new Error("Search failed")); 179 188 180 - render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 189 + renderSearchPanel(); 181 190 182 191 const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 183 192 fireEvent.input(input, { target: { value: "test" } }); ··· 192 201 getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]); 193 202 searchPostsMock.mockResolvedValue([]); 194 203 195 - render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 204 + renderSearchPanel(); 196 205 197 206 const keywordButton = screen.getByRole("button", { name: /keyword/i }); 198 207 fireEvent.click(keywordButton);
+4 -5
src/components/search/SearchPanel.tsx
··· 1 1 import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 2 + import { useAppSession } from "$/contexts/app-session"; 2 3 import { 3 4 type EmbeddingsConfig, 4 5 getEmbeddingsConfig, ··· 10 11 type SyncStatus, 11 12 } from "$/lib/api/search"; 12 13 import { formatRelativeTime } from "$/lib/feeds"; 13 - import type { ActiveSession } from "$/lib/types"; 14 14 import { normalizeError } from "$/lib/utils/text"; 15 15 import * as logger from "@tauri-apps/plugin-log"; 16 16 import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; ··· 37 37 ); 38 38 } 39 39 40 - type SearchPanelProps = { session: ActiveSession }; 41 - 42 - export function SearchPanel(props: SearchPanelProps) { 40 + export function SearchPanel() { 41 + const session = useAppSession(); 43 42 const [mode, setMode] = createSignal<SearchMode>("network"); 44 43 const [query, setQuery] = createSignal(""); 45 44 const [results, setResults] = createSignal<LocalPostResult[]>([]); ··· 231 230 </section> 232 231 233 232 <aside class="grid content-start gap-4 overflow-y-auto"> 234 - <SyncStatusPanel did={props.session.did} onStatusChange={setSyncStatus} /> 233 + <Show when={session.activeDid}>{(did) => <SyncStatusPanel did={did()} onStatusChange={setSyncStatus} />}</Show> 235 234 <EmbeddingsSettings 236 235 onConfigChange={(nextConfig) => { 237 236 setEmbeddingsConfig(nextConfig);
+101
src/contexts/app-session.test.tsx
··· 1 + import { ACCOUNT_SWITCH_EVENT, NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; 2 + import { render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { AppSessionProvider, useAppSession } from "./app-session"; 5 + 6 + const getAppBootstrapMock = vi.hoisted(() => vi.fn()); 7 + const loginMock = vi.hoisted(() => vi.fn()); 8 + const logoutMock = vi.hoisted(() => vi.fn()); 9 + const switchAccountMock = vi.hoisted(() => vi.fn()); 10 + const getUnreadCountMock = vi.hoisted(() => vi.fn()); 11 + const listenMock = vi.hoisted(() => vi.fn()); 12 + 13 + vi.mock( 14 + "$/lib/api/app", 15 + () => ({ 16 + getAppBootstrap: getAppBootstrapMock, 17 + login: loginMock, 18 + logout: logoutMock, 19 + switchAccount: switchAccountMock, 20 + }), 21 + ); 22 + vi.mock("$/lib/api/notifications", () => ({ getUnreadCount: getUnreadCountMock })); 23 + vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 24 + 25 + function SessionProbe() { 26 + const session = useAppSession(); 27 + 28 + return ( 29 + <div> 30 + <p data-testid="active-account">{session.activeAccount?.handle ?? "none"}</p> 31 + <p data-testid="primary-account">{session.primaryAccount?.handle ?? "none"}</p> 32 + <p data-testid="active-did">{session.activeDid ?? "none"}</p> 33 + </div> 34 + ); 35 + } 36 + 37 + describe("AppSessionProvider", () => { 38 + beforeEach(() => { 39 + getAppBootstrapMock.mockReset(); 40 + loginMock.mockReset(); 41 + logoutMock.mockReset(); 42 + switchAccountMock.mockReset(); 43 + getUnreadCountMock.mockReset(); 44 + listenMock.mockReset(); 45 + getUnreadCountMock.mockResolvedValue(0); 46 + }); 47 + 48 + it("keeps active account, primary account, and viewer identity in sync across bootstrap refreshes", async () => { 49 + let accountSwitchListener: (() => void) | undefined; 50 + 51 + getAppBootstrapMock.mockResolvedValueOnce({ 52 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 53 + accountList: [{ active: true, did: "did:plc:alice", handle: "alice.test", pdsUrl: "https://alice.pds" }, { 54 + active: false, 55 + did: "did:plc:bob", 56 + handle: "bob.test", 57 + pdsUrl: "https://bob.pds", 58 + }], 59 + }).mockResolvedValueOnce({ 60 + activeSession: { did: "did:plc:bob", handle: "bob.test" }, 61 + accountList: [{ active: false, did: "did:plc:alice", handle: "alice.test", pdsUrl: "https://alice.pds" }, { 62 + active: true, 63 + did: "did:plc:bob", 64 + handle: "bob.test", 65 + pdsUrl: "https://bob.pds", 66 + }], 67 + }); 68 + 69 + listenMock.mockImplementation((event: string, callback: () => void) => { 70 + if (event === ACCOUNT_SWITCH_EVENT) { 71 + accountSwitchListener = callback; 72 + } 73 + 74 + if (event === NOTIFICATIONS_UNREAD_COUNT_EVENT) { 75 + return Promise.resolve(() => {}); 76 + } 77 + 78 + return Promise.resolve(() => {}); 79 + }); 80 + 81 + render(() => ( 82 + <AppSessionProvider> 83 + <SessionProbe /> 84 + </AppSessionProvider> 85 + )); 86 + 87 + await waitFor(() => { 88 + expect(screen.getByTestId("active-account")).toHaveTextContent("alice.test"); 89 + expect(screen.getByTestId("primary-account")).toHaveTextContent("alice.test"); 90 + expect(screen.getByTestId("active-did")).toHaveTextContent("did:plc:alice"); 91 + }); 92 + 93 + accountSwitchListener?.(); 94 + 95 + await waitFor(() => { 96 + expect(screen.getByTestId("active-account")).toHaveTextContent("bob.test"); 97 + expect(screen.getByTestId("primary-account")).toHaveTextContent("bob.test"); 98 + expect(screen.getByTestId("active-did")).toHaveTextContent("did:plc:bob"); 99 + }); 100 + }); 101 + });
+333
src/contexts/app-session.tsx
··· 1 + import { 2 + getAppBootstrap, 3 + login as loginRequest, 4 + logout as logoutRequest, 5 + switchAccount as switchAccountRequest, 6 + } from "$/lib/api/app"; 7 + import { getUnreadCount } from "$/lib/api/notifications"; 8 + import { ACCOUNT_SWITCH_EVENT, NOTIFICATIONS_UNREAD_COUNT_EVENT } from "$/lib/constants/events"; 9 + import type { AccountSummary, ActiveSession } from "$/lib/types"; 10 + import { listen } from "@tauri-apps/api/event"; 11 + import { 12 + createContext, 13 + createMemo, 14 + onCleanup, 15 + onMount, 16 + type ParentProps, 17 + splitProps, 18 + startTransition, 19 + untrack, 20 + useContext, 21 + } from "solid-js"; 22 + import { createStore } from "solid-js/store"; 23 + 24 + type AppSessionState = { 25 + accounts: AccountSummary[]; 26 + activeSession: ActiveSession | null; 27 + bootstrapping: boolean; 28 + errorMessage: string | null; 29 + loggingIn: boolean; 30 + loginValue: string; 31 + logoutDid: string | null; 32 + reauthNeeded: boolean; 33 + shakeCount: number; 34 + switchingDid: string | null; 35 + unreadNotifications: number; 36 + }; 37 + 38 + export type AppSessionContextValue = { 39 + readonly accounts: AccountSummary[]; 40 + readonly activeAccount: AccountSummary | null; 41 + readonly activeAvatar: string | null; 42 + readonly activeDid: string | null; 43 + readonly activeHandle: string | null; 44 + readonly activeSession: ActiveSession | null; 45 + readonly bootstrapping: boolean; 46 + readonly errorMessage: string | null; 47 + readonly hasSession: boolean; 48 + readonly loggingIn: boolean; 49 + readonly loginValue: string; 50 + readonly logoutDid: string | null; 51 + readonly metaLabel: string; 52 + readonly primaryAccount: AccountSummary | null; 53 + readonly reauthNeeded: boolean; 54 + readonly shakeCount: number; 55 + readonly switchingDid: string | null; 56 + readonly unreadNotifications: number; 57 + clearError: () => void; 58 + logout: (did: string) => Promise<void>; 59 + markNotificationsSeen: () => void; 60 + reauthorizePrimaryAccount: () => Promise<void>; 61 + reportError: (message: string) => void; 62 + setLoginValue: (value: string) => void; 63 + submitLogin: (identifier?: string) => Promise<void>; 64 + switchAccount: (did: string) => Promise<void>; 65 + }; 66 + 67 + const AppSessionContext = createContext<AppSessionContextValue>(); 68 + 69 + function createInitialAppSessionState(): AppSessionState { 70 + return { 71 + accounts: [], 72 + activeSession: null, 73 + bootstrapping: true, 74 + errorMessage: null, 75 + loggingIn: false, 76 + loginValue: "", 77 + logoutDid: null, 78 + reauthNeeded: false, 79 + shakeCount: 0, 80 + switchingDid: null, 81 + unreadNotifications: 0, 82 + }; 83 + } 84 + 85 + function createAppSessionValue(): AppSessionContextValue { 86 + const [session, setSession] = createStore<AppSessionState>(createInitialAppSessionState()); 87 + 88 + const activeAccount = createMemo(() => 89 + session.accounts.find((account) => account.did === session.activeSession?.did) ?? null 90 + ); 91 + const primaryAccount = createMemo(() => activeAccount() ?? session.accounts[0] ?? null); 92 + const hasSession = createMemo(() => !!session.activeSession); 93 + const metaLabel = createMemo(() => { 94 + if (session.bootstrapping) { 95 + return "reconnecting"; 96 + } 97 + 98 + if (session.activeSession) { 99 + return "connected"; 100 + } 101 + 102 + return "ready"; 103 + }); 104 + 105 + async function loadBootstrap() { 106 + setSession("bootstrapping", true); 107 + 108 + try { 109 + const payload = await getAppBootstrap(); 110 + startTransition(() => { 111 + setSession("activeSession", payload.activeSession); 112 + setSession("accounts", payload.accountList); 113 + setSession("reauthNeeded", payload.accountList.length > 0 && !payload.activeSession); 114 + }); 115 + 116 + if (payload.activeSession) { 117 + try { 118 + setSession("unreadNotifications", await getUnreadCount()); 119 + } catch { 120 + setSession("unreadNotifications", 0); 121 + } 122 + } else { 123 + setSession("unreadNotifications", 0); 124 + } 125 + } catch (error) { 126 + setSession("errorMessage", `Failed to load app bootstrap: ${String(error)}`); 127 + } finally { 128 + setSession("bootstrapping", false); 129 + } 130 + } 131 + 132 + function triggerShake() { 133 + setSession("shakeCount", (count) => count + 1); 134 + } 135 + 136 + function markPotentialExpiry(error: unknown) { 137 + const message = String(error).toLowerCase(); 138 + if (message.includes("refresh failed permanently") || message.includes("session does not exist")) { 139 + setSession("reauthNeeded", true); 140 + } 141 + } 142 + 143 + function setLoginValue(value: string) { 144 + setSession("loginValue", value); 145 + } 146 + 147 + function clearError() { 148 + setSession("errorMessage", null); 149 + } 150 + 151 + function reportError(message: string) { 152 + setSession("errorMessage", message); 153 + } 154 + 155 + function markNotificationsSeen() { 156 + setSession("unreadNotifications", 0); 157 + } 158 + 159 + async function submitLogin(identifier = session.loginValue) { 160 + const trimmed = identifier.trim(); 161 + if (!validateIdentifier(trimmed)) { 162 + triggerShake(); 163 + setSession("errorMessage", "Please enter a valid handle or DID."); 164 + return; 165 + } 166 + 167 + setSession("loggingIn", true); 168 + try { 169 + await loginRequest(trimmed); 170 + setSession("loginValue", ""); 171 + await loadBootstrap(); 172 + } catch (error) { 173 + markPotentialExpiry(error); 174 + setSession("errorMessage", `Authentication failed: ${String(error)}`); 175 + } finally { 176 + setSession("loggingIn", false); 177 + } 178 + } 179 + 180 + async function switchAccount(did: string) { 181 + setSession("switchingDid", did); 182 + try { 183 + await switchAccountRequest(did); 184 + await loadBootstrap(); 185 + } catch (error) { 186 + markPotentialExpiry(error); 187 + setSession("errorMessage", `Failed to switch account: ${String(error)}`); 188 + } finally { 189 + setSession("switchingDid", null); 190 + } 191 + } 192 + 193 + async function logout(did: string) { 194 + setSession("logoutDid", did); 195 + try { 196 + await logoutRequest(did); 197 + await loadBootstrap(); 198 + } catch (error) { 199 + markPotentialExpiry(error); 200 + setSession("errorMessage", `Failed to logout account: ${String(error)}`); 201 + } finally { 202 + setSession("logoutDid", null); 203 + } 204 + } 205 + 206 + async function reauthorizePrimaryAccount() { 207 + const account = primaryAccount(); 208 + if (!account) { 209 + return; 210 + } 211 + 212 + await submitLogin(account.handle || account.did); 213 + } 214 + 215 + onMount(() => { 216 + let unlistenAccountSwitch: (() => void) | undefined; 217 + let unlistenUnreadCount: (() => void) | undefined; 218 + 219 + void loadBootstrap(); 220 + 221 + void listen<ActiveSession | null>(ACCOUNT_SWITCH_EVENT, () => { 222 + void loadBootstrap(); 223 + }).then((dispose) => { 224 + unlistenAccountSwitch = dispose; 225 + }); 226 + 227 + void listen<number>(NOTIFICATIONS_UNREAD_COUNT_EVENT, (event) => { 228 + setSession("unreadNotifications", event.payload); 229 + }).then((dispose) => { 230 + unlistenUnreadCount = dispose; 231 + }); 232 + 233 + onCleanup(() => { 234 + unlistenAccountSwitch?.(); 235 + unlistenUnreadCount?.(); 236 + }); 237 + }); 238 + 239 + return { 240 + get accounts() { 241 + return session.accounts; 242 + }, 243 + get activeAccount() { 244 + return activeAccount(); 245 + }, 246 + get activeAvatar() { 247 + return activeAccount()?.avatar ?? null; 248 + }, 249 + get activeDid() { 250 + return session.activeSession?.did ?? null; 251 + }, 252 + get activeHandle() { 253 + return session.activeSession?.handle ?? null; 254 + }, 255 + get activeSession() { 256 + return session.activeSession; 257 + }, 258 + get bootstrapping() { 259 + return session.bootstrapping; 260 + }, 261 + get errorMessage() { 262 + return session.errorMessage; 263 + }, 264 + get hasSession() { 265 + return hasSession(); 266 + }, 267 + get loggingIn() { 268 + return session.loggingIn; 269 + }, 270 + get loginValue() { 271 + return session.loginValue; 272 + }, 273 + get logoutDid() { 274 + return session.logoutDid; 275 + }, 276 + get metaLabel() { 277 + return metaLabel(); 278 + }, 279 + get primaryAccount() { 280 + return primaryAccount(); 281 + }, 282 + get reauthNeeded() { 283 + return session.reauthNeeded; 284 + }, 285 + get shakeCount() { 286 + return session.shakeCount; 287 + }, 288 + get switchingDid() { 289 + return session.switchingDid; 290 + }, 291 + get unreadNotifications() { 292 + return session.unreadNotifications; 293 + }, 294 + clearError, 295 + logout, 296 + markNotificationsSeen, 297 + reauthorizePrimaryAccount, 298 + reportError, 299 + setLoginValue, 300 + submitLogin, 301 + switchAccount, 302 + }; 303 + } 304 + 305 + export function AppSessionProvider(props: ParentProps) { 306 + const value = createAppSessionValue(); 307 + 308 + return <AppSessionContext.Provider value={value}>{props.children}</AppSessionContext.Provider>; 309 + } 310 + 311 + export function AppSessionContextProvider(props: ParentProps<{ value: AppSessionContextValue }>) { 312 + const [local] = splitProps(props, ["children", "value"]); 313 + const value = untrack(() => local.value); 314 + 315 + return <AppSessionContext.Provider value={value}>{local.children}</AppSessionContext.Provider>; 316 + } 317 + 318 + export function useAppSession() { 319 + const context = useContext(AppSessionContext); 320 + if (!context) { 321 + throw new Error("useAppSession must be used within an AppSessionProvider"); 322 + } 323 + 324 + return context; 325 + } 326 + 327 + function validateIdentifier(value: string) { 328 + const trimmed = value.trim(); 329 + const handlePattern = /^@?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i; 330 + const didPattern = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; 331 + const urlPattern = /^https?:\/\/\S+$/i; 332 + return handlePattern.test(trimmed) || didPattern.test(trimmed) || urlPattern.test(trimmed); 333 + }
+119
src/contexts/app-shell-ui.tsx
··· 1 + import { 2 + createContext, 3 + createEffect, 4 + createMemo, 5 + onCleanup, 6 + onMount, 7 + type ParentProps, 8 + splitProps, 9 + untrack, 10 + useContext, 11 + } from "solid-js"; 12 + import { createStore } from "solid-js/store"; 13 + 14 + const RAIL_COLLAPSED_STORAGE_KEY = "lazurite:rail-collapsed"; 15 + 16 + type AppShellUiState = { narrowViewport: boolean; railCollapsed: boolean; showSwitcher: boolean }; 17 + 18 + export type AppShellUiContextValue = { 19 + readonly narrowViewport: boolean; 20 + readonly railCollapsed: boolean; 21 + readonly railColumns: string; 22 + readonly railCondensed: boolean; 23 + readonly showSwitcher: boolean; 24 + closeSwitcher: () => void; 25 + toggleRailCollapsed: () => void; 26 + toggleSwitcher: () => void; 27 + }; 28 + 29 + const AppShellUiContext = createContext<AppShellUiContextValue>(); 30 + 31 + function createInitialAppShellUiState(): AppShellUiState { 32 + return { narrowViewport: false, railCollapsed: false, showSwitcher: false }; 33 + } 34 + 35 + function createAppShellUiValue(): AppShellUiContextValue { 36 + const [shell, setShell] = createStore<AppShellUiState>(createInitialAppShellUiState()); 37 + 38 + const railCompact = createMemo(() => shell.railCollapsed && !shell.narrowViewport); 39 + const railCondensed = createMemo(() => railCompact() || shell.narrowViewport); 40 + const railColumns = createMemo(() => (railCompact() ? "5.75rem minmax(0,1fr)" : "16rem minmax(0,1fr)")); 41 + 42 + function closeSwitcher() { 43 + if (shell.showSwitcher) { 44 + setShell("showSwitcher", false); 45 + } 46 + } 47 + 48 + function toggleRailCollapsed() { 49 + setShell("railCollapsed", (collapsed) => !collapsed); 50 + } 51 + 52 + function toggleSwitcher() { 53 + setShell("showSwitcher", (open) => !open); 54 + } 55 + 56 + onMount(() => { 57 + const media = globalThis.matchMedia("(max-width: 1180px)"); 58 + const syncViewport = () => setShell("narrowViewport", media.matches); 59 + 60 + const stored = globalThis.localStorage.getItem(RAIL_COLLAPSED_STORAGE_KEY); 61 + if (stored === "true") { 62 + setShell("railCollapsed", true); 63 + } 64 + 65 + syncViewport(); 66 + media.addEventListener("change", syncViewport); 67 + 68 + onCleanup(() => { 69 + media.removeEventListener("change", syncViewport); 70 + }); 71 + }); 72 + 73 + createEffect(() => { 74 + globalThis.localStorage.setItem(RAIL_COLLAPSED_STORAGE_KEY, shell.railCollapsed ? "true" : "false"); 75 + }); 76 + 77 + return { 78 + get narrowViewport() { 79 + return shell.narrowViewport; 80 + }, 81 + get railCollapsed() { 82 + return shell.railCollapsed; 83 + }, 84 + get railColumns() { 85 + return railColumns(); 86 + }, 87 + get railCondensed() { 88 + return railCondensed(); 89 + }, 90 + get showSwitcher() { 91 + return shell.showSwitcher; 92 + }, 93 + closeSwitcher, 94 + toggleRailCollapsed, 95 + toggleSwitcher, 96 + }; 97 + } 98 + 99 + export function AppShellUiProvider(props: ParentProps) { 100 + const value = createAppShellUiValue(); 101 + 102 + return <AppShellUiContext.Provider value={value}>{props.children}</AppShellUiContext.Provider>; 103 + } 104 + 105 + export function AppShellUiContextProvider(props: ParentProps<{ value: AppShellUiContextValue }>) { 106 + const [local] = splitProps(props, ["children", "value"]); 107 + const value = untrack(() => local.value); 108 + 109 + return <AppShellUiContext.Provider value={value}>{local.children}</AppShellUiContext.Provider>; 110 + } 111 + 112 + export function useAppShellUi() { 113 + const context = useContext(AppShellUiContext); 114 + if (!context) { 115 + throw new Error("useAppShellUi must be used within an AppShellUiProvider"); 116 + } 117 + 118 + return context; 119 + }
+19 -25
src/router.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 1 2 import { render, screen } from "@solidjs/testing-library"; 2 3 import type { Component, ParentProps } from "solid-js"; 3 4 import { describe, expect, it, vi } from "vitest"; 4 5 import { buildThreadRoute } from "./lib/feeds"; 5 - import type { ActiveSession } from "./lib/types"; 6 6 import { AppRouter } from "./router"; 7 7 8 - const session: ActiveSession = { did: "did:plc:alice", handle: "alice.test" }; 9 - 10 8 const Shell: Component<ParentProps> = (props) => <div>{props.children}</div>; 11 9 12 10 function renderRouter(hash: string) { 13 11 globalThis.location.hash = hash; 14 - const renderComposer = vi.fn((currentSession: ActiveSession) => ( 15 - <div data-testid="composer-view">{currentSession.handle}</div> 16 - )); 17 - const renderNotifications = vi.fn((currentSession: ActiveSession) => ( 18 - <div data-testid="notifications-view">{currentSession.handle}</div> 19 - )); 12 + const renderComposer = vi.fn(() => <div data-testid="composer-view">composer</div>); 13 + const renderNotifications = vi.fn(() => <div data-testid="notifications-view">notifications</div>); 20 14 const renderTimeline = vi.fn(( 21 - props: { 22 - session: ActiveSession; 23 - context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null }; 24 - }, 15 + props: { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } }, 25 16 ) => ( 26 17 <div data-testid="timeline-view"> 27 - <span>{props.session.handle}</span> 28 18 <span>{props.context.threadUri ?? "no-thread"}</span> 29 19 </div> 30 20 )); 31 21 32 22 render(() => ( 33 - <AppRouter 34 - bootstrapping={false} 35 - hasSession 36 - renderAuth={() => <div>Auth</div>} 37 - renderComposer={renderComposer} 38 - renderNotifications={renderNotifications} 39 - renderShell={Shell} 40 - renderTimeline={renderTimeline} 41 - session={session} /> 23 + <AppTestProviders 24 + session={{ 25 + activeDid: "did:plc:alice", 26 + activeHandle: "alice.test", 27 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 28 + }}> 29 + <AppRouter 30 + renderAuth={() => <div>Auth</div>} 31 + renderComposer={renderComposer} 32 + renderNotifications={renderNotifications} 33 + renderShell={Shell} 34 + renderTimeline={renderTimeline} /> 35 + </AppTestProviders> 42 36 )); 43 37 44 38 return { renderComposer, renderNotifications, renderTimeline }; ··· 71 65 await screen.findByTestId("composer-view"); 72 66 73 67 expect(renderComposer).toHaveBeenCalledOnce(); 74 - expect(screen.getByText(session.handle)).toBeInTheDocument(); 68 + expect(screen.getByText("composer")).toBeInTheDocument(); 75 69 }); 76 70 77 71 it("renders the notifications route inside the protected shell", async () => { ··· 80 74 await screen.findByTestId("notifications-view"); 81 75 82 76 expect(renderNotifications).toHaveBeenCalledOnce(); 83 - expect(screen.getByText(session.handle)).toBeInTheDocument(); 77 + expect(screen.getByText("notifications")).toBeInTheDocument(); 84 78 }); 85 79 });
+44 -74
src/router.tsx
··· 1 + import { useAppSession } from "$/contexts/app-session"; 2 + import { useAppShellUi } from "$/contexts/app-shell-ui"; 1 3 import { 2 4 HashRouter, 3 5 Navigate, ··· 8 10 useParams, 9 11 } from "@solidjs/router"; 10 12 import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js"; 13 + import { Dynamic } from "solid-js/web"; 11 14 import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 12 15 import { SearchPanel } from "./components/search/SearchPanel"; 13 16 import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 14 - import type { ActiveSession } from "./lib/types"; 17 + 18 + type TTimelineRouteProps = { context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } }; 15 19 16 20 type AppRouterProps = { 17 - bootstrapping: boolean; 18 - hasSession: boolean; 19 - onLocationChange?: () => void; 20 21 renderAuth: () => JSX.Element; 21 - renderComposer: (session: ActiveSession) => JSX.Element; 22 - renderNotifications: (session: ActiveSession) => JSX.Element; 22 + renderComposer: () => JSX.Element; 23 + renderNotifications: () => JSX.Element; 23 24 renderShell: Component<ParentProps>; 24 - renderTimeline: Component< 25 - { session: ActiveSession; context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } } 26 - >; 27 - session: ActiveSession | null; 25 + renderTimeline: Component<TTimelineRouteProps>; 28 26 }; 29 27 30 28 export function AppRouter(props: AppRouterProps) { 29 + const session = useAppSession(); 30 + const shell = useAppShellUi(); 31 + 31 32 const RouterFrame: Component<RouteSectionProps> = (routeProps) => { 32 33 const location = useLocation(); 33 34 let previousPath = location.pathname; ··· 36 37 createEffect(() => { 37 38 const nextPath = location.pathname; 38 39 if (nextPath !== previousPath) { 39 - props.onLocationChange?.(); 40 + shell.closeSwitcher(); 40 41 previousPath = nextPath; 41 42 } 42 43 }); ··· 49 50 }; 50 51 51 52 const IndexRoute = () => ( 52 - <Show when={!props.bootstrapping} fallback={<RouteLoadingState />}> 53 - <Navigate href={props.hasSession ? TIMELINE_ROUTE : "/auth"} /> 53 + <Show when={!session.bootstrapping} fallback={<RouteLoadingState />}> 54 + <Navigate href={session.hasSession ? TIMELINE_ROUTE : "/auth"} /> 54 55 </Show> 55 56 ); 56 57 57 - const AuthRoute = () => ( 58 - <PublicOnlyRoute bootstrapping={props.bootstrapping} when={!props.hasSession} redirectHref={TIMELINE_ROUTE}> 59 - {props.renderAuth()} 60 - </PublicOnlyRoute> 61 - ); 58 + const AuthRoute = () => <PublicOnlyRoute redirectHref={TIMELINE_ROUTE}>{props.renderAuth()}</PublicOnlyRoute>; 62 59 63 - const TimelineRoute = () => ( 64 - <TimelineRouteView 65 - bootstrapping={props.bootstrapping} 66 - renderTimeline={props.renderTimeline} 67 - session={props.session} 68 - threadUri={null} /> 69 - ); 60 + const TimelineRoute = () => <TimelineRouteView renderTimeline={props.renderTimeline} threadUri={null} />; 70 61 71 62 const ThreadRoute = () => { 72 63 const params = useParams<{ threadUri: string }>(); ··· 74 65 75 66 return ( 76 67 <Show when={threadUri()} keyed fallback={<Navigate href={TIMELINE_ROUTE} />}> 77 - {(uri) => ( 78 - <TimelineRouteView 79 - bootstrapping={props.bootstrapping} 80 - renderTimeline={props.renderTimeline} 81 - session={props.session} 82 - threadUri={uri} /> 83 - )} 68 + {(uri) => <TimelineRouteView renderTimeline={props.renderTimeline} threadUri={uri} />} 84 69 </Show> 85 70 ); 86 71 }; 87 72 88 73 const SearchRoute = () => ( 89 - <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 90 - {(session) => <SearchPanel session={session} />} 74 + <ProtectedRouteView> 75 + <SearchPanel /> 91 76 </ProtectedRouteView> 92 77 ); 93 78 94 - const NotificationsRoute = () => ( 95 - <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 96 - {(session) => props.renderNotifications(session)} 97 - </ProtectedRouteView> 98 - ); 79 + const NotificationsRoute = () => <ProtectedRouteView>{props.renderNotifications()}</ProtectedRouteView>; 99 80 100 - const ComposerRoute = () => ( 101 - <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 102 - {(session) => props.renderComposer(session)} 103 - </ProtectedRouteView> 104 - ); 81 + const ComposerRoute = () => <ProtectedRouteView>{props.renderComposer()}</ProtectedRouteView>; 105 82 106 83 const ExplorerRoute = () => ( 107 - <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 108 - {() => <ExplorerPanel />} 84 + <ProtectedRouteView> 85 + <ExplorerPanel /> 109 86 </ProtectedRouteView> 110 87 ); 111 88 112 89 const NotFoundRoute = () => ( 113 - <Show when={!props.bootstrapping} fallback={<RouteLoadingState />}> 114 - <Navigate href={props.hasSession ? TIMELINE_ROUTE : "/auth"} /> 90 + <Show when={session.bootstrapping} fallback={<Navigate href={session.hasSession ? TIMELINE_ROUTE : "/auth"} />}> 91 + <RouteLoadingState /> 115 92 </Show> 116 93 ); 117 94 ··· 130 107 ); 131 108 } 132 109 133 - function TimelineRouteView( 134 - props: { 135 - bootstrapping: boolean; 136 - renderTimeline: AppRouterProps["renderTimeline"]; 137 - session: ActiveSession | null; 138 - threadUri: string | null; 139 - }, 140 - ) { 110 + function TimelineRouteView(props: { renderTimeline: AppRouterProps["renderTimeline"]; threadUri: string | null }) { 141 111 const navigate = useNavigate(); 142 112 143 113 return ( 144 - <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 145 - {(session) => 146 - props.renderTimeline({ 147 - session, 148 - context: { 149 - onThreadRouteChange: (uri) => navigate(uri ? buildThreadRoute(uri) : TIMELINE_ROUTE), 150 - threadUri: props.threadUri, 151 - }, 152 - })} 114 + <ProtectedRouteView> 115 + <Dynamic 116 + component={props.renderTimeline} 117 + context={{ 118 + onThreadRouteChange: (uri: string | null) => navigate(uri ? buildThreadRoute(uri) : TIMELINE_ROUTE), 119 + threadUri: props.threadUri, 120 + }} /> 153 121 </ProtectedRouteView> 154 122 ); 155 123 } 156 124 157 - function PublicOnlyRoute(props: ParentProps & { bootstrapping: boolean; when: boolean; redirectHref: string }) { 125 + function PublicOnlyRoute(props: ParentProps & { redirectHref: string }) { 126 + const session = useAppSession(); 127 + 158 128 return ( 159 - <Show when={props.when || props.bootstrapping} fallback={<Navigate href={props.redirectHref} />}> 129 + <Show when={!session.hasSession || session.bootstrapping} fallback={<Navigate href={props.redirectHref} />}> 160 130 {props.children} 161 131 </Show> 162 132 ); 163 133 } 164 134 165 - function ProtectedRouteView( 166 - props: { bootstrapping: boolean; session: ActiveSession | null; children: (session: ActiveSession) => JSX.Element }, 167 - ) { 135 + function ProtectedRouteView(props: ParentProps) { 136 + const session = useAppSession(); 137 + 168 138 return ( 169 - <Show when={!props.bootstrapping} fallback={<RouteLoadingState />}> 170 - <Show when={props.session} keyed fallback={<Navigate href="/auth" />}> 171 - {(session) => props.children(session)} 172 - </Show> 139 + <Show 140 + when={session.bootstrapping} 141 + fallback={<Show when={session.activeSession} fallback={<Navigate href="/auth" />}>{props.children}</Show>}> 142 + <RouteLoadingState /> 173 143 </Show> 174 144 ); 175 145 }
+83
src/test/providers.tsx
··· 1 + import { AppSessionContextProvider, type AppSessionContextValue } from "$/contexts/app-session"; 2 + import { AppShellUiContextProvider, type AppShellUiContextValue } from "$/contexts/app-shell-ui"; 3 + import type { AccountSummary, ActiveSession } from "$/lib/types"; 4 + import { type ParentProps, splitProps, untrack } from "solid-js"; 5 + 6 + const DEFAULT_SESSION: ActiveSession = { did: "did:plc:test", handle: "test.bsky.social" }; 7 + 8 + const DEFAULT_ACCOUNT: AccountSummary = { 9 + active: true, 10 + avatar: "https://example.com/avatar.png", 11 + did: DEFAULT_SESSION.did, 12 + handle: DEFAULT_SESSION.handle, 13 + pdsUrl: "https://pds.example.com", 14 + }; 15 + 16 + function noop() {} 17 + 18 + export function createAppSessionTestValue(overrides: Partial<AppSessionContextValue> = {}): AppSessionContextValue { 19 + const accounts = overrides.accounts ?? [DEFAULT_ACCOUNT]; 20 + const activeSession = overrides.activeSession === undefined ? DEFAULT_SESSION : overrides.activeSession; 21 + const activeAccount = overrides.activeAccount === undefined 22 + ? accounts.find((account) => account.did === activeSession?.did) ?? accounts[0] ?? null 23 + : overrides.activeAccount; 24 + const primaryAccount = overrides.primaryAccount === undefined 25 + ? activeAccount ?? accounts[0] ?? null 26 + : overrides.primaryAccount; 27 + 28 + return { 29 + accounts, 30 + activeAccount, 31 + activeAvatar: overrides.activeAvatar ?? activeAccount?.avatar ?? null, 32 + activeDid: overrides.activeDid ?? activeSession?.did ?? null, 33 + activeHandle: overrides.activeHandle ?? activeSession?.handle ?? null, 34 + activeSession, 35 + bootstrapping: overrides.bootstrapping ?? false, 36 + errorMessage: overrides.errorMessage ?? null, 37 + hasSession: overrides.hasSession ?? !!activeSession, 38 + loggingIn: overrides.loggingIn ?? false, 39 + loginValue: overrides.loginValue ?? "", 40 + logoutDid: overrides.logoutDid ?? null, 41 + metaLabel: overrides.metaLabel ?? (activeSession ? "connected" : "ready"), 42 + primaryAccount, 43 + reauthNeeded: overrides.reauthNeeded ?? false, 44 + shakeCount: overrides.shakeCount ?? 0, 45 + switchingDid: overrides.switchingDid ?? null, 46 + unreadNotifications: overrides.unreadNotifications ?? 0, 47 + clearError: overrides.clearError ?? noop, 48 + logout: overrides.logout ?? (async () => {}), 49 + markNotificationsSeen: overrides.markNotificationsSeen ?? noop, 50 + reauthorizePrimaryAccount: overrides.reauthorizePrimaryAccount ?? (async () => {}), 51 + reportError: overrides.reportError ?? noop, 52 + setLoginValue: overrides.setLoginValue ?? noop, 53 + submitLogin: overrides.submitLogin ?? (async () => {}), 54 + switchAccount: overrides.switchAccount ?? (async () => {}), 55 + }; 56 + } 57 + 58 + export function createAppShellUiTestValue(overrides: Partial<AppShellUiContextValue> = {}): AppShellUiContextValue { 59 + return { 60 + narrowViewport: overrides.narrowViewport ?? false, 61 + railCollapsed: overrides.railCollapsed ?? false, 62 + railColumns: overrides.railColumns ?? "16rem minmax(0,1fr)", 63 + railCondensed: overrides.railCondensed ?? false, 64 + showSwitcher: overrides.showSwitcher ?? false, 65 + closeSwitcher: overrides.closeSwitcher ?? noop, 66 + toggleRailCollapsed: overrides.toggleRailCollapsed ?? noop, 67 + toggleSwitcher: overrides.toggleSwitcher ?? noop, 68 + }; 69 + } 70 + 71 + export function AppTestProviders( 72 + props: ParentProps<{ session?: Partial<AppSessionContextValue>; shell?: Partial<AppShellUiContextValue> }>, 73 + ) { 74 + const [local] = splitProps(props, ["children", "session", "shell"]); 75 + const sessionValue = createAppSessionTestValue(untrack(() => local.session)); 76 + const shellValue = createAppShellUiTestValue(untrack(() => local.shell)); 77 + 78 + return ( 79 + <AppSessionContextProvider value={sessionValue}> 80 + <AppShellUiContextProvider value={shellValue}>{local.children}</AppShellUiContextProvider> 81 + </AppSessionContextProvider> 82 + ); 83 + }