diff --git a/apps/web/src/components/NotificationToast.tsx b/apps/web/src/components/NotificationToast.tsx new file mode 100644 index 000000000..037135689 --- /dev/null +++ b/apps/web/src/components/NotificationToast.tsx @@ -0,0 +1,38 @@ +import { useSyncExternalStore } from 'react'; +import { notificationStore } from '../stores/notification.store'; + +/** + * Standing warnings, dismissed by hand rather than on a timer: a trust warning + * that expired unread would read as "nothing was wrong". + */ +export function NotificationToast() { + const notices = useSyncExternalStore(notificationStore.subscribe, notificationStore.getState); + + if (notices.length === 0) return null; + + return ( +
+ {notices.map((notice) => ( +
+ + {notice.message} + +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/file-browser/FileBrowser.test.tsx b/apps/web/src/components/file-browser/FileBrowser.test.tsx new file mode 100644 index 000000000..40a7aec21 --- /dev/null +++ b/apps/web/src/components/file-browser/FileBrowser.test.tsx @@ -0,0 +1,90 @@ +import { EngineRequestError } from '@cipherbox/client'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; +import { EngineProvider } from '../../providers/EngineProvider'; +import { ROOT_ID, fakeEngine, view } from '../../engine/testFakes'; +import { FileBrowser } from './FileBrowser'; + +function draw(client: ReturnType['client']) { + return render( + + client}> + + } /> + + + + ); +} + +/** Renders the browser with two rows on screen, then fails its next pull. */ +async function listedThenFailed(failure: Error) { + const engine = fakeEngine(); + draw(engine.client); + + await act(async () => { + engine.emit({ kind: 'snapshotUpdated' }); + }); + await act(async () => { + engine.pulls[0].resolve(view(ROOT_ID, 'fresh', 2)); + }); + await waitFor(() => expect(screen.getAllByTestId('file-list-item')).toHaveLength(2)); + + await act(async () => { + engine.emit({ kind: 'snapshotUpdated' }); + }); + await act(async () => { + engine.pulls[1].reject(failure); + }); + return engine; +} + +describe('the vault browser', () => { + it('keeps the listing on screen when a refusal is recoverable', async () => { + await listedThenFailed( + new EngineRequestError('too many read streams are already open', 'tooManyStreams') + ); + + const notice = await screen.findByTestId('file-browser-notice'); + expect(notice.textContent).toContain('too many read streams are already open'); + // The gate is the rows, not the notice: a blanked listing must not pass. + expect(screen.getAllByTestId('file-list-item')).toHaveLength(2); + expect(screen.queryByTestId('file-browser-error')).toBeNull(); + }); + + it('blanks the listing on a failure that will not clear', async () => { + await listedThenFailed(new EngineRequestError('no such node', 'unknownNode')); + + const error = await screen.findByTestId('file-browser-error'); + expect(error.textContent).toBe('no such node'); + expect(screen.queryAllByTestId('file-list-item')).toHaveLength(0); + expect(screen.queryByTestId('file-browser-notice')).toBeNull(); + }); + + it('treats an engine code it does not recognise as fatal', async () => { + await listedThenFailed(new EngineRequestError('something new', 'someFutureCeiling')); + + expect(await screen.findByTestId('file-browser-error')).toBeTruthy(); + expect(screen.queryAllByTestId('file-list-item')).toHaveLength(0); + }); + + it('re-drives the pull from the recoverable notice', async () => { + const engine = await listedThenFailed( + new EngineRequestError('too many read streams are already open', 'tooManyStreams') + ); + await screen.findByTestId('file-browser-notice'); + expect(engine.refreshes()).toBe(0); + + await act(async () => { + fireEvent.click(screen.getByText('[retry]')); + }); + + expect(engine.refreshes()).toBe(1); + await act(async () => { + engine.pulls[2].resolve(view(ROOT_ID, 'fresh', 2)); + }); + await waitFor(() => expect(screen.queryByTestId('file-browser-notice')).toBeNull()); + expect(screen.getAllByTestId('file-list-item')).toHaveLength(2); + }); +}); diff --git a/apps/web/src/components/file-browser/FileBrowser.tsx b/apps/web/src/components/file-browser/FileBrowser.tsx index 5ebb4d028..17893e823 100644 --- a/apps/web/src/components/file-browser/FileBrowser.tsx +++ b/apps/web/src/components/file-browser/FileBrowser.tsx @@ -1,4 +1,6 @@ +import { isRecoverable } from '../../engine/snapshotStore'; import { useSnapshot } from '../../engine/useSnapshot'; +import { useSnapshotStore } from '../../providers/EngineProvider'; import { useFolderNavigation } from '../../vault/useFolderNavigation'; import { Breadcrumbs } from './Breadcrumbs'; import { DeadLetterNotice } from './DeadLetterNotice'; @@ -11,13 +13,29 @@ export function FileBrowser() { const { rows, folder, breadcrumbs, isLoading, isRoot, error, navigateTo, navigateUp } = useFolderNavigation(); const { view } = useSnapshot(); - const settled = !isLoading && error === null; + const store = useSnapshotStore(); + // A ceiling refusal clears on its own, so it renders over the listing it + // interrupted; anything else is a verdict and blanks it. + const ceiling = error !== null && isRecoverable(error) ? error : null; + const settled = !isLoading && (error === null || (ceiling !== null && folder !== null)); return (
- {error && ( + {ceiling !== null && ( +
+ {ceiling.message} + +
+ )} + {error !== null && ceiling === null && (

{error.message}

diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index bd3a24620..dfada956a 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -1,24 +1,35 @@ import type { ReactNode } from 'react'; +import { useRefreshHints } from '../../engine/useRefreshHints'; +import { NotificationToast } from '../NotificationToast'; import { StagingBanner } from '../StagingBanner'; import { AppFooter } from './AppFooter'; import { AppHeader } from './AppHeader'; import { AppSidebar } from './AppSidebar'; +import { OfflineBanner } from './OfflineBanner'; interface AppShellProps { children: ReactNode; } -/** The signed-in frame: header, sidebar, scrollable main, footer. */ +/** + * The signed-in frame: header, sidebar, scrollable main, footer, and the + * cross-cutting chrome that renders event-stream state only + * (blueprint/web-client.md "Composition"). + */ export function AppShell({ children }: AppShellProps) { + useRefreshHints(); + return (
+
{children}
+
); } diff --git a/apps/web/src/components/layout/OfflineBanner.test.tsx b/apps/web/src/components/layout/OfflineBanner.test.tsx new file mode 100644 index 000000000..9299a460a --- /dev/null +++ b/apps/web/src/components/layout/OfflineBanner.test.tsx @@ -0,0 +1,59 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { fakeEngine, view } from '../../engine/testFakes'; +import { EngineProvider } from '../../providers/EngineProvider'; +import { OfflineBanner } from './OfflineBanner'; + +function draw(client: ReturnType['client']) { + return render( + client}> + + + ); +} + +describe('the offline banner', () => { + it('stays down while the engine is reconciling', () => { + draw(fakeEngine().client); + expect(screen.queryByTestId('offline-banner')).toBeNull(); + }); + + it('follows the engine reaching the offline rung', async () => { + const engine = fakeEngine(); + draw(engine.client); + + await act(async () => { + engine.emit({ kind: 'stalenessChanged', staleness: 'offline' }); + }); + + await waitFor(() => expect(screen.getByTestId('offline-banner')).toBeTruthy()); + }); + + it('clears when the engine leaves that rung', async () => { + const engine = fakeEngine(); + draw(engine.client); + await act(async () => { + engine.emit({ kind: 'stalenessChanged', staleness: 'offline' }); + }); + await waitFor(() => expect(screen.getByTestId('offline-banner')).toBeTruthy()); + + await act(async () => { + engine.emit({ kind: 'snapshotUpdated' }); + engine.pulls[0].resolve(view()); + }); + + await waitFor(() => expect(screen.queryByTestId('offline-banner')).toBeNull()); + }); + + it('renders no banner for the rungs above it', async () => { + const engine = fakeEngine(); + draw(engine.client); + + for (const rung of ['fresh', 'reconciling', 'stale'] as const) { + await act(async () => { + engine.emit({ kind: 'stalenessChanged', staleness: rung }); + }); + expect(screen.queryByTestId('offline-banner')).toBeNull(); + } + }); +}); diff --git a/apps/web/src/components/layout/OfflineBanner.tsx b/apps/web/src/components/layout/OfflineBanner.tsx new file mode 100644 index 000000000..a7ffde77a --- /dev/null +++ b/apps/web/src/components/layout/OfflineBanner.tsx @@ -0,0 +1,20 @@ +import { useStaleness } from '../../engine/useStaleness'; + +/** + * The staleness ladder's bottom rung, at banner scale (blueprint/web-client.md + * "Staleness ladder rendering"). + */ +export function OfflineBanner() { + if (useStaleness() !== 'offline') return null; + + return ( +
+ + + {'// OFFLINE - changes queue on this device and publish when the network returns'} + +
+ ); +} diff --git a/apps/web/src/components/layout/StatusIndicator.test.tsx b/apps/web/src/components/layout/StatusIndicator.test.tsx new file mode 100644 index 000000000..61dab1400 --- /dev/null +++ b/apps/web/src/components/layout/StatusIndicator.test.tsx @@ -0,0 +1,43 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { fakeEngine } from '../../engine/testFakes'; +import { EngineProvider } from '../../providers/EngineProvider'; +import { StatusIndicator } from './StatusIndicator'; + +function draw(client: ReturnType['client']) { + return render( + client}> + + + ); +} + +describe('the status indicator', () => { + it('names the rung the engine reports', async () => { + const engine = fakeEngine(); + draw(engine.client); + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('reconciling'); + + for (const rung of ['fresh', 'stale', 'offline'] as const) { + await act(async () => { + engine.emit({ kind: 'stalenessChanged', staleness: rung }); + }); + await waitFor(() => + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe(rung) + ); + } + }); + + it('drives a manual refresh from the rung it renders', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(screen.getByTestId('status-indicator')).toBeTruthy()); + + await act(async () => { + fireEvent.click(screen.getByTestId('status-indicator')); + }); + + expect(engine.refreshes()).toBe(1); + expect(engine.pulls).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/layout/StatusIndicator.tsx b/apps/web/src/components/layout/StatusIndicator.tsx index e9bf99733..9134dc980 100644 --- a/apps/web/src/components/layout/StatusIndicator.tsx +++ b/apps/web/src/components/layout/StatusIndicator.tsx @@ -1,4 +1,5 @@ import { useStaleness } from '../../engine/useStaleness'; +import { useSnapshotStore } from '../../providers/EngineProvider'; /** The staleness ladder's rungs, as the footer renders them (#33 D4). */ const RUNGS = { @@ -8,19 +9,23 @@ const RUNGS = { offline: { label: 'offline', className: 'status-indicator--offline' }, } as const; -/** Where the vault sits on the staleness ladder. */ +/** Where the vault sits on the staleness ladder, and the manual refresh. */ export function StatusIndicator() { const staleness = useStaleness(); + const store = useSnapshotStore(); const rung = RUNGS[staleness]; return ( - store.refresh()} > + ); } diff --git a/apps/web/src/engine/engineNotices.test.tsx b/apps/web/src/engine/engineNotices.test.tsx new file mode 100644 index 000000000..1bbe0b258 --- /dev/null +++ b/apps/web/src/engine/engineNotices.test.tsx @@ -0,0 +1,106 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { NotificationToast } from '../components/NotificationToast'; +import { StatusIndicator } from '../components/layout/StatusIndicator'; +import { EngineProvider } from '../providers/EngineProvider'; +import { notificationStore } from '../stores/notification.store'; +import { fakeEngine } from './testFakes'; + +afterEach(() => notificationStore.clear()); + +/** The two surfaces side by side, so one event cannot land on both. */ +function draw(client: ReturnType['client']) { + return render( + client}> + + + + ); +} + +describe('engine warnings', () => { + it('renders a withheld-update escalation as a warning, never as staleness', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(screen.getByTestId('status-indicator')).toBeTruthy()); + const rung = screen.getByTestId('status-indicator').dataset.staleness; + + await act(async () => { + engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: new Uint8Array([0xab, 0xcd]) }); + }); + + const notice = await screen.findByTestId('notification-notice'); + expect(notice.getAttribute('role')).toBe('alert'); + // The pinned name identifies the scope for de-duplication only. + expect(notice.textContent).not.toContain('abcd'); + // The ladder is untouched: a trust warning is never a rung. + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe(rung); + }); + + it('renders an attributable-abuse report as the same warning class', async () => { + const engine = fakeEngine(); + draw(engine.client); + + await act(async () => { + engine.emit({ kind: 'attributableAbuse', description: 'k51abc: floor regression' }); + }); + + const notice = await screen.findByTestId('notification-notice'); + expect(notice.textContent).toContain('k51abc: floor regression'); + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('reconciling'); + }); + + it('collapses a scope that escalates on every tick', async () => { + const engine = fakeEngine(); + draw(engine.client); + const name = new Uint8Array([0x01, 0x02]); + + await act(async () => { + engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: name }); + engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: name }); + engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: name }); + }); + + expect(await screen.findAllByTestId('notification-notice')).toHaveLength(1); + }); + + it('dismisses a warning the reader has read', async () => { + const engine = fakeEngine(); + draw(engine.client); + await act(async () => { + engine.emit({ kind: 'attributableAbuse', description: 'refused' }); + }); + await screen.findByTestId('notification-notice'); + + fireEvent.click(screen.getByLabelText('Dismiss warning')); + + expect(screen.queryByTestId('notification-toast')).toBeNull(); + }); + + it('drops the warnings with the engine that raised them', async () => { + const engine = fakeEngine(); + const { unmount } = draw(engine.client); + await act(async () => { + engine.emit({ kind: 'attributableAbuse', description: 'refused' }); + }); + await screen.findByTestId('notification-notice'); + + unmount(); + + expect(notificationStore.getState()).toHaveLength(0); + }); + + it('leaves the staleness ladder to the events that own it', async () => { + const engine = fakeEngine(); + draw(engine.client); + + await act(async () => { + engine.emit({ kind: 'stalenessChanged', staleness: 'stale' }); + }); + + await waitFor(() => + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('stale') + ); + expect(screen.queryByTestId('notification-toast')).toBeNull(); + }); +}); diff --git a/apps/web/src/engine/snapshotStore.test.ts b/apps/web/src/engine/snapshotStore.test.ts index 7335bed1d..9f1737fef 100644 --- a/apps/web/src/engine/snapshotStore.test.ts +++ b/apps/web/src/engine/snapshotStore.test.ts @@ -1,6 +1,6 @@ import { EngineRequestError } from '@cipherbox/client'; import { describe, expect, it } from 'vitest'; -import { createSnapshotStore, idleSnapshotStore } from './snapshotStore'; +import { createSnapshotStore, idleSnapshotStore, isRecoverable } from './snapshotStore'; import { ROOT_ID, fakeEngine, flush, view } from './testFakes'; describe('snapshotStore', () => { @@ -253,3 +253,130 @@ describe('snapshotStore', () => { expect(engine.pulls).toHaveLength(0); }); }); + +describe('the focus window', () => { + const FOLDER = new Uint8Array(16).fill(3); + + it('re-asserts the cached focus after a consumer borrowed the window', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + store.setFocus(FOLDER); + engine.ackFocus(); + await flush(); + + // A folder picker drove `facade.setFocus` itself; the store's cache is + // unchanged, so asking it for the same folder short-circuits. + store.setFocus(FOLDER); + expect(engine.focus).toEqual([FOLDER]); + + store.refocus(); + expect(engine.focus).toEqual([FOLDER, FOLDER]); + expect(engine.reported).toEqual([FOLDER, FOLDER]); + }); + + it('pulls its own folder after taking the window back', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + store.setFocus(FOLDER); + engine.ackFocus(); + await flush(); + engine.pulls[0].resolve(view(FOLDER)); + await flush(); + + store.refocus(); + engine.ackFocus(); + await flush(); + + expect(engine.pulls[1].folder).toBe(FOLDER); + }); + + it('starts no pull for a focus that lands after disposal', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + store.setFocus(FOLDER); + + // The provider disposes the store, then the client; the focus it asked for + // settles afterwards and must not reach the closed facade. + store.dispose(); + engine.ackFocus(); + await flush(); + + expect(engine.pulls).toEqual([]); + + // Nor may a later consumer call reopen one. + store.refresh(); + store.refocus(); + await flush(); + expect(engine.pulls).toEqual([]); + expect(engine.focus).toEqual([FOLDER]); + }); +}); + +describe('a manual refresh', () => { + it('resolves nocache, then re-pulls the focused folder', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + + store.refresh(); + await flush(); + + expect(engine.refreshes()).toBe(1); + expect(engine.pulls).toHaveLength(1); + const refreshed = view(); + engine.pulls[0].resolve(refreshed); + await flush(); + expect(store.getSnapshot().view).toBe(refreshed); + }); + + it('still pulls, and keeps the listing, when the engine refuses the refresh', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + engine.emit({ kind: 'snapshotUpdated' }); + const listed = view(ROOT_ID, 'fresh', 2); + engine.pulls[0].resolve(listed); + await flush(); + + // A refused hint is a verdict on the hint, never on the listing. + engine.refuseRefresh(new EngineRequestError('not implemented yet', 'unimplemented')); + store.refresh(); + await flush(); + + expect(engine.pulls).toHaveLength(2); + expect(store.getSnapshot()).toEqual({ view: listed, error: null }); + }); + + it('clears a failure the retry cleared', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + engine.emit({ kind: 'snapshotUpdated' }); + engine.pulls[0].reject(new EngineRequestError('at the ceiling', 'tooManyStreams')); + await flush(); + expect(store.getSnapshot().error).toEqual({ + message: 'at the ceiling', + code: 'tooManyStreams', + }); + + store.refresh(); + await flush(); + engine.pulls[1].resolve(view()); + await flush(); + + expect(store.getSnapshot().error).toBeNull(); + }); +}); + +describe('failure classification', () => { + it('treats the stream ceiling as recoverable', () => { + expect(isRecoverable({ message: 'ceiling', code: 'tooManyStreams' })).toBe(true); + }); + + it('fails closed on anything it does not name', () => { + // A verdict, an unmapped future engine code, and a codeless transport fault + // all read as fatal rather than as something a retry would clear. + expect(isRecoverable({ message: 'refused', code: 'trustViolation' })).toBe(false); + expect(isRecoverable({ message: 'gone', code: 'unknownNode' })).toBe(false); + expect(isRecoverable({ message: 'new', code: 'someFutureCeiling' })).toBe(false); + expect(isRecoverable({ message: 'no room', code: 'overBudget' })).toBe(false); + expect(isRecoverable({ message: 'worker died' })).toBe(false); + }); +}); diff --git a/apps/web/src/engine/snapshotStore.ts b/apps/web/src/engine/snapshotStore.ts index dcef6e689..6bf42468f 100644 --- a/apps/web/src/engine/snapshotStore.ts +++ b/apps/web/src/engine/snapshotStore.ts @@ -3,11 +3,17 @@ * law"): a `useSyncExternalStore` adapter over the engine event stream with no * independent writers. It caches the descriptor the engine handed it and never * derives, merges, or patches one. + * + * It is also the stream's only listener, so the trust warnings that must never + * read as staleness are projected from here onto their own surface — a second + * subscription would open a render later than the engine starts, and drop the + * cold-start escalations that land in the gap. */ -import { EngineRequestError } from '@cipherbox/client'; +import { EngineRequestError, toHex } from '@cipherbox/client'; import type { EngineClient, SnapshotDescriptor, Staleness } from '@cipherbox/client'; import { sameNode } from '../lib/nodeId'; +import { notificationStore } from '../stores/notification.store'; /** A failed pull, carrying the engine's stable code so the UI can classify it. */ export interface SnapshotError { @@ -23,6 +29,15 @@ export interface SnapshotState { error: SnapshotError | null; } +/** + * Whether a later pull clears this on its own: `tooManyStreams` is a ceiling, + * not a verdict. Named codes only, so a codeless transport fault and every code + * this does not name — trust verdicts among them — stay fatal. + */ +export function isRecoverable(error: SnapshotError): boolean { + return error.code === 'tooManyStreams'; +} + export interface SnapshotStore { /** `useSyncExternalStore` subscribe: fires on every committed change. */ subscribe(onStoreChange: () => void): () => void; @@ -32,10 +47,18 @@ export interface SnapshotStore { getStaleness(): Staleness; /** Points the adapter (and the engine's focus window) at a folder. */ setFocus(node: Uint8Array | null): void; + /** Re-asserts the cached focus after a consumer drove `facade.setFocus` itself. */ + refocus(): void; + /** Re-pulls the focused folder, behind a best-effort nocache resolve. */ + refresh(): void; /** Releases the event subscription. */ dispose(): void; } +/** The pinned name identifies the scope for de-duplication, never for reading. */ +const WITHHELD = + 'a shared folder stopped serving updates you are entitled to see - what it shows may be behind'; + const IDLE: SnapshotState = { view: null, error: null }; /** A store-shaped no-op for consumers mounted before the engine client exists. */ @@ -44,6 +67,8 @@ export const idleSnapshotStore: SnapshotStore = { getSnapshot: () => IDLE, getStaleness: () => 'reconciling', setFocus: () => undefined, + refocus: () => undefined, + refresh: () => undefined, dispose: () => undefined, }; @@ -70,6 +95,10 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore { // final view. let inFlight = false; let coalesced = false; + // The provider disposes this store and its client together, and a logout + // rebuild does so with the tab still live — so a continuation still holding an + // older intent must not reach a closed facade. + let disposed = false; const commit = (next: Commit): void => { const view = next.view === undefined ? state.view : next.view; @@ -83,6 +112,7 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore { }; const pull = (): void => { + if (disposed) return; if (inFlight) { coalesced = true; return; @@ -113,12 +143,33 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore { }); }; + const assertFocus = (): void => { + if (disposed) return; + client.reportFocus(focus); + const id = ++generation; + client.facade.setFocus(focus).then( + () => { + if (id === generation) pull(); + }, + (error: unknown) => { + if (id === generation) commit({ error: describe(error) }); + } + ); + }; + const unsubscribe = client.facade.subscribe((event) => { if (event.kind === 'snapshotUpdated') { pull(); } else if (event.kind === 'stalenessChanged') { stalenessSeq += 1; commit({ staleness: event.staleness }); + } else if (event.kind === 'withheldUpdateEscalation') { + notificationStore.warn(`withheld:${toHex(event.ipnsName)}`, WITHHELD); + } else if (event.kind === 'attributableAbuse') { + notificationStore.warn( + `abuse:${event.description}`, + `verification refused an update: ${event.description}` + ); } }); @@ -132,20 +183,26 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore { setFocus(node) { if (sameNode(focus, node)) return; focus = node; - client.reportFocus(node); - const id = ++generation; - client.facade.setFocus(node).then( - () => { - if (id === generation) pull(); - }, - (error: unknown) => { - if (id === generation) commit({ error: describe(error) }); - } - ); + assertFocus(); + }, + refocus: assertFocus, + refresh() { + if (disposed) return; + const id = generation; + const again = (): void => { + if (id === generation) pull(); + }; + // The nocache hint is best-effort: only the pull it precedes sets `error`. + void client.facade.manualRefresh().then(again, again); }, dispose() { + disposed = true; + // Supersede every in-flight intent, so a late answer commits nothing. + generation += 1; unsubscribe(); listeners.clear(); + // A warning names the scope it came from; it must not outlive its engine. + notificationStore.clear(); }, }; } diff --git a/apps/web/src/engine/testFakes.ts b/apps/web/src/engine/testFakes.ts index ad19c05b6..9c4d05de8 100644 --- a/apps/web/src/engine/testFakes.ts +++ b/apps/web/src/engine/testFakes.ts @@ -54,6 +54,8 @@ export function fakeEngine() { const reported: (Uint8Array | null)[] = []; let settleFocus: (() => void) | null = null; let failFocus: ((error: Error) => void) | null = null; + let refreshes = 0; + let refuseRefresh: Error | null = null; const client = { facade: { @@ -73,6 +75,10 @@ export function fakeEngine() { failFocus = reject; }); }, + manualRefresh() { + refreshes += 1; + return refuseRefresh === null ? Promise.resolve() : Promise.reject(refuseRefresh); + }, }, reportFocus(node: Uint8Array | null) { reported.push(node); @@ -90,9 +96,29 @@ export function fakeEngine() { }, ackFocus: () => settleFocus?.(), rejectFocus: (error: Error) => failFocus?.(error), + refreshes: () => refreshes, + refuseRefresh: (error: Error) => (refuseRefresh = error), subscriberCount: () => listeners.size, }; } /** Lets every pending promise callback in the store run. */ export const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +/** + * jsdom leaves `navigator.onLine` and `document.visibilityState` read-only, so + * both stubs must redefine the property *and* fire the event the store listens + * on — a value change alone notifies nothing. + */ +export function setOnline(online: boolean): void { + Object.defineProperty(navigator, 'onLine', { configurable: true, value: online }); + window.dispatchEvent(new Event(online ? 'online' : 'offline')); +} + +export function setVisible(visible: boolean): void { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => (visible ? 'visible' : 'hidden'), + }); + document.dispatchEvent(new Event('visibilitychange')); +} diff --git a/apps/web/src/engine/useRefreshHints.test.tsx b/apps/web/src/engine/useRefreshHints.test.tsx new file mode 100644 index 000000000..e7d8b2d6e --- /dev/null +++ b/apps/web/src/engine/useRefreshHints.test.tsx @@ -0,0 +1,71 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { EngineProvider } from '../providers/EngineProvider'; +import { fakeEngine, setOnline, setVisible } from './testFakes'; +import { useRefreshHints } from './useRefreshHints'; + +afterEach(() => { + setOnline(true); + setVisible(true); +}); + +function Hints() { + useRefreshHints(); + return null; +} + +function draw(client: ReturnType['client']) { + return render( + client}> + + + ); +} + +describe('refresh hints', () => { + it('does not refresh on mount', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(engine.subscriberCount()).toBe(1)); + + expect(engine.refreshes()).toBe(0); + }); + + it('refreshes when the network comes back', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(engine.subscriberCount()).toBe(1)); + + await act(async () => setOnline(false)); + expect(engine.refreshes()).toBe(0); + + await act(async () => setOnline(true)); + expect(engine.refreshes()).toBe(1); + }); + + it('refreshes when a backgrounded tab comes back on screen', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(engine.subscriberCount()).toBe(1)); + + await act(async () => setVisible(false)); + await act(async () => setVisible(true)); + + expect(engine.refreshes()).toBe(1); + }); + + it('waits for both signals before refreshing', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(engine.subscriberCount()).toBe(1)); + + await act(async () => setVisible(false)); + await act(async () => setOnline(false)); + await act(async () => setOnline(true)); + // Still hidden: nothing on screen is waiting on a fresher answer. + expect(engine.refreshes()).toBe(0); + + await act(async () => setVisible(true)); + expect(engine.refreshes()).toBe(1); + }); +}); diff --git a/apps/web/src/engine/useRefreshHints.ts b/apps/web/src/engine/useRefreshHints.ts new file mode 100644 index 000000000..84a158159 --- /dev/null +++ b/apps/web/src/engine/useRefreshHints.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; +import { useOnlineStatus } from '../hooks/useOnlineStatus'; +import { useVisibility } from '../hooks/useVisibility'; +import { useSnapshotStore } from '../providers/EngineProvider'; + +/** + * Regaining the network or coming back to a backgrounded tab are the two moments + * a cached vault is most likely behind, so each edge back into + * on-screen-and-online drives one nocache refresh — the transition only, never + * the mount. + */ +export function useRefreshHints(): void { + const store = useSnapshotStore(); + const online = useOnlineStatus(); + const visible = useVisibility(); + const wasReady = useRef(true); + + useEffect(() => { + const ready = online && visible; + if (ready && !wasReady.current) store.refresh(); + wasReady.current = ready; + }, [online, visible, store]); +} diff --git a/apps/web/src/hooks/useFolderPicker.test.tsx b/apps/web/src/hooks/useFolderPicker.test.tsx new file mode 100644 index 000000000..bce6e31f9 --- /dev/null +++ b/apps/web/src/hooks/useFolderPicker.test.tsx @@ -0,0 +1,53 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { fakeEngine } from '../engine/testFakes'; +import type { SnapshotStore } from '../engine/snapshotStore'; +import { EngineProvider, useSnapshotStore } from '../providers/EngineProvider'; +import { useFolderPicker } from './useFolderPicker'; + +const HOME = new Uint8Array(16).fill(1); +const NEXT = new Uint8Array(16).fill(2); + +function Picker() { + useFolderPicker(HOME, ''); + return null; +} + +/** Publishes the provider's store so the test can drive the route's writer. */ +function Probe({ onStore }: { onStore: (store: SnapshotStore) => void }) { + onStore(useSnapshotStore()); + return null; +} + +describe('the folder picker', () => { + it('leaves the engine on the folder the store is focused on', async () => { + const engine = fakeEngine(); + let store!: SnapshotStore; + const { rerender } = render( + engine.client}> + (store = next)} /> + + + ); + await waitFor(() => expect(engine.focus.length).toBeGreaterThan(0)); + + // The route moves under the open dialog, then the dialog closes. + await act(async () => { + store.setFocus(NEXT); + }); + // The store's own write already names NEXT, so only a *further* write proves + // the close re-asserted the window rather than inheriting it. + const written = engine.focus.length; + const reported = engine.reported.length; + rerender( + engine.client}> + (store = next)} /> + + ); + + await waitFor(() => expect(engine.focus.length).toBeGreaterThan(written)); + expect(engine.reported.length).toBeGreaterThan(reported); + expect(engine.focus.at(-1)).toBe(NEXT); + expect(engine.reported.at(-1)).toBe(NEXT); + }); +}); diff --git a/apps/web/src/hooks/useFolderPicker.ts b/apps/web/src/hooks/useFolderPicker.ts index a527f9b4a..419758bab 100644 --- a/apps/web/src/hooks/useFolderPicker.ts +++ b/apps/web/src/hooks/useFolderPicker.ts @@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { SnapshotDescriptor } from '@cipherbox/client'; import { errorMessage } from '../lib/errorMessage'; import { sameNode } from '../lib/nodeId'; -import { useEngine } from '../providers/EngineProvider'; +import { useEngine, useSnapshotStore } from '../providers/EngineProvider'; import { listingRows, type ListingRow } from '../vault/listing'; export interface FolderPicker { @@ -28,14 +28,14 @@ export interface FolderPicker { } /** - * @param openOn the folder to start on and to hand the focus window back to, - * captured on mount + * @param openOn the folder to start the walk on, captured on mount * @param excludedKey a subtree the picker must not offer, by hex node id — * hiding the node hides everything under it, since the walk only descends * through what it lists */ export function useFolderPicker(openOn: Uint8Array | null, excludedKey: string): FolderPicker { const client = useEngine(); + const store = useSnapshotStore(); const home = useRef(openOn).current; const [cursor, setCursor] = useState(home); const [listing, setListing] = useState(null); @@ -65,9 +65,8 @@ export function useFolderPicker(openOn: Uint8Array | null, excludedKey: string): }; }, [client, cursor]); - // Handing the focus window back is best-effort: the picker is gone, and the - // route's own focus effect reasserts it either way. - useEffect(() => () => void client?.facade.setFocus(home).catch(() => undefined), [client, home]); + // The walk borrows the focus window; the store owns it and takes it back. + useEffect(() => () => store.refocus(), [store]); // The read effect retires the listing a render later than the cursor moves, // and a listing outliving its cursor names the folder just left as the diff --git a/apps/web/src/hooks/useOnlineStatus.ts b/apps/web/src/hooks/useOnlineStatus.ts new file mode 100644 index 000000000..7f1f538fc --- /dev/null +++ b/apps/web/src/hooks/useOnlineStatus.ts @@ -0,0 +1,23 @@ +import { useSyncExternalStore } from 'react'; + +function subscribe(onChange: () => void): () => void { + window.addEventListener('online', onChange); + window.addEventListener('offline', onChange); + return () => { + window.removeEventListener('online', onChange); + window.removeEventListener('offline', onChange); + }; +} + +/** + * Whether the browser has a network path. A refresh hint only + * (blueprint/web-client.md `RefreshHintSource`): `navigator.onLine` reports the + * link, not whether anything answers over it, so nothing renders from it. + */ +export function useOnlineStatus(): boolean { + return useSyncExternalStore( + subscribe, + () => navigator.onLine, + () => true + ); +} diff --git a/apps/web/src/hooks/useVisibility.ts b/apps/web/src/hooks/useVisibility.ts new file mode 100644 index 000000000..7e45df9d2 --- /dev/null +++ b/apps/web/src/hooks/useVisibility.ts @@ -0,0 +1,15 @@ +import { useSyncExternalStore } from 'react'; + +function subscribe(onChange: () => void): () => void { + document.addEventListener('visibilitychange', onChange); + return () => document.removeEventListener('visibilitychange', onChange); +} + +/** Whether this tab is on screen; a backgrounded tab's cache goes behind. */ +export function useVisibility(): boolean { + return useSyncExternalStore( + subscribe, + () => document.visibilityState === 'visible', + () => true + ); +} diff --git a/apps/web/src/stores/notification.store.test.ts b/apps/web/src/stores/notification.store.test.ts new file mode 100644 index 000000000..baa0513fb --- /dev/null +++ b/apps/web/src/stores/notification.store.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { notificationStore } from './notification.store'; + +afterEach(() => notificationStore.clear()); + +describe('the notification store', () => { + it('collapses a repeat of a warning that already stands', () => { + notificationStore.warn('withheld:aa', 'a shared folder stopped serving updates'); + notificationStore.warn('withheld:aa', 'a shared folder stopped serving updates'); + + expect(notificationStore.getState()).toHaveLength(1); + }); + + it('bounds what an event storm can accumulate', () => { + for (let i = 0; i < 12; i += 1) notificationStore.warn(`abuse:${i}`, `refused ${i}`); + + const keys = notificationStore.getState().map((notice) => notice.key); + expect(keys).toEqual(['abuse:7', 'abuse:8', 'abuse:9', 'abuse:10', 'abuse:11']); + }); + + it('raises the same key again once it was dismissed', () => { + notificationStore.warn('withheld:aa', 'first'); + notificationStore.dismiss('withheld:aa'); + expect(notificationStore.getState()).toHaveLength(0); + + notificationStore.warn('withheld:aa', 'again'); + expect(notificationStore.getState()).toHaveLength(1); + }); + + it('notifies only on a change and publishes a stable snapshot', () => { + let changes = 0; + const drop = notificationStore.subscribe(() => (changes += 1)); + + notificationStore.warn('abuse:x', 'refused'); + notificationStore.warn('abuse:x', 'refused'); + notificationStore.dismiss('abuse:missing'); + expect(changes).toBe(1); + // `useSyncExternalStore` bails out on identity: a repeat read must match. + expect(notificationStore.getState()).toBe(notificationStore.getState()); + + drop(); + notificationStore.warn('abuse:y', 'refused'); + expect(changes).toBe(1); + }); +}); diff --git a/apps/web/src/stores/notification.store.ts b/apps/web/src/stores/notification.store.ts new file mode 100644 index 000000000..eaae6605b --- /dev/null +++ b/apps/web/src/stores/notification.store.ts @@ -0,0 +1,46 @@ +/** + * The warning-notice surface: a trust violation or a withheld-update escalation + * renders here, as its own class, and never on the staleness ladder + * (blueprint/web-client.md "Staleness ladder rendering"). + * + * Memory only, and cleared when the engine that raised the notices goes away — + * a notice names the scope it came from, so it must not outlive that session. + */ + +/** One standing warning. `key` is its identity: a repeat collapses onto it. */ +export interface Notice { + readonly key: string; + readonly message: string; +} + +/** Distinct keys accumulate unbounded otherwise; the newest warning wins. */ +const MAX_NOTICES = 5; + +let notices: readonly Notice[] = Object.freeze([]); +const listeners = new Set<() => void>(); + +function publish(next: readonly Notice[]): void { + // Frozen: a consumer must not mutate what the UI is already rendering. + notices = Object.freeze(next); + for (const listener of listeners) listener(); +} + +export const notificationStore = { + subscribe(onStoreChange: () => void): () => void { + listeners.add(onStoreChange); + return () => listeners.delete(onStoreChange); + }, + getState: (): readonly Notice[] => notices, + /** Raises `message` under `key`, or does nothing if that key already stands. */ + warn(key: string, message: string): void { + if (notices.some((notice) => notice.key === key)) return; + publish([...notices, { key, message }].slice(-MAX_NOTICES)); + }, + dismiss(key: string): void { + const next = notices.filter((notice) => notice.key !== key); + if (next.length !== notices.length) publish(next); + }, + clear(): void { + if (notices.length > 0) publish([]); + }, +}; diff --git a/apps/web/src/styles/file-browser.css b/apps/web/src/styles/file-browser.css index b90fdf9e4..3de990ec9 100644 --- a/apps/web/src/styles/file-browser.css +++ b/apps/web/src/styles/file-browser.css @@ -23,6 +23,40 @@ border: var(--border-thickness) solid rgb(239 68 68 / 20%); } +/* A recoverable refusal: warning-toned and retryable, over the listing it + interrupted, never the settled red of a failure that will not clear. */ +.file-browser-notice { + display: flex; + align-items: center; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-warning); + background-color: var(--color-warning-bg); + border: var(--border-thickness) solid var(--color-warning-dim); +} + +.file-browser-notice-message { + flex: 1; +} + +.file-browser-notice-retry { + padding: 0; + font-family: inherit; + font-size: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; +} + +.file-browser-notice-retry:hover, +.file-browser-notice-retry:focus-visible { + text-decoration: underline; +} + .file-browser-loading { padding: var(--spacing-sm) 0; font-family: var(--font-family-mono); diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css index 8f75d14de..3d0d378a9 100644 --- a/apps/web/src/styles/layout.css +++ b/apps/web/src/styles/layout.css @@ -292,9 +292,18 @@ display: flex; align-items: center; gap: 6px; + padding: 0; font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-secondary); + background: none; + border: none; + cursor: pointer; +} + +.status-indicator:hover, +.status-indicator:focus-visible { + color: var(--color-text-primary); } .status-indicator-dot { @@ -338,3 +347,67 @@ animation: none; } } + +/* ========================================================================== + Offline Banner + ========================================================================== */ + +.offline-banner { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-md); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-warning); + background-color: var(--color-warning-bg); + border-bottom: var(--border-thickness) solid var(--color-warning-dim); +} + +/* ========================================================================== + Notification Toast - the warning class, never the staleness ladder + ========================================================================== */ + +.notification-toast { + position: fixed; + right: var(--spacing-md); + /* Clears the footer rather than sitting over the staleness rung. */ + bottom: var(--spacing-xl); + z-index: 40; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + max-width: 380px; +} + +.notification-toast-item { + display: flex; + align-items: flex-start; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-warning); + background-color: var(--color-background); + border: var(--border-thickness) solid var(--color-warning); +} + +.notification-toast-message { + flex: 1; +} + +.notification-toast-dismiss { + padding: 0; + font-family: inherit; + font-size: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; +} + +.notification-toast-dismiss:hover, +.notification-toast-dismiss:focus-visible { + text-decoration: underline; +}