diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ba2db7..53d81ab 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,37 +1,46 @@ -import { lazy, Suspense, useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { createAccountService, subscribeLock } from "./account"; -import type { - AccountService, - AccountSession, - NotesStore, -} from "./domain/contracts"; +import type { AccountService, AccountSession } from "./domain/contracts"; +import type { NotesLibraryStore } from "./library/contracts"; import { AccountView, PendingDevice } from "./ui/AccountView"; import { usePreferences } from "./ui/primitives"; import { DesktopShell } from "./desktop/DesktopShell"; import { isDesktop } from "./desktop/window"; import { protectOperations } from "./desktop/closeGuard"; +import { useIncomingNoteLink } from "./links/useIncomingNoteLink"; +import { IncomingNoteLink } from "./ui/noteLinks/IncomingNoteLink"; const Workspace = lazy(() => import("./ui/Workspace").then((module) => ({ default: module.Workspace })), ); export function App() { - return ; + return ( + + + + ); } function AppContent() { usePreferences(); const [service, setService] = useState(null); const [session, setSession] = useState(null); - const [store, setStore] = useState(null); + const [store, setStore] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [attempt, setAttempt] = useState(0); + const incoming = useIncomingNoteLink(store, isDesktop()); + const activeSession = useRef(session); + activeSession.current = session; useEffect( () => subscribeLock(() => { + // Initial account establishment also announces a lock. Preserve an + // initial URL intent until the first session; revoke an active one. + if (activeSession.current) incoming.revoke(); setSession(null); setStore(null); setError(""); }), - [], + [incoming.revoke], ); useEffect(() => { let active = true; @@ -39,7 +48,9 @@ function AppContent() { setError(""); try { const original = createAccountService(); - const account = isDesktop() ? protectOperations(original, Object.keys(original)) : original; + const account = isDesktop() + ? protectOperations(original, Object.keys(original)) + : original; setService(account); void account .current() @@ -74,15 +85,24 @@ function AppContent() { useEffect(() => { if (!session || session.deviceState === "pending") return; let active = true; - let opened: NotesStore | undefined; + let opened: NotesLibraryStore | undefined; setLoading(true); void import("./data") .then((module) => module.openNotesStore(session)) .then((s) => { opened = s; - if (active) setStore(isDesktop() - ? protectOperations(s, ["create", "update", "trash", "restore", "erase"]) - : s); + if (active) + setStore( + isDesktop() + ? protectOperations(s, [ + "create", + "update", + "trash", + "restore", + "erase", + ]) + : s, + ); else s.close(); }) .catch(() => { @@ -103,49 +123,95 @@ function AppContent() { async function signOut(propagateFailure = false) { try { await service?.signOut(); + incoming.revoke(); store?.close(); setStore(null); setSession(null); setError(""); } catch (error) { - setError("Couldn’t sign out. Try again."); if (propagateFailure) throw error; + setError("Couldn’t sign out. Try again."); } } - if (loading) return ; + const incomingNotice = incoming.state ? ( + + ) : null; + // Generic syntax/transport notices are safe before authentication. Do not + // project a target, title or navigation outcome into account entry. + const publicNotice = + incoming.state === "invalid" || incoming.state === "service-error" + ? incomingNotice + : null; + if (loading) + return ( + <> + {publicNotice} + + + ); if (error) return ( -
-
-

Notes can’t open yet

-

{error}

- - {session && } -
-
+ <> + {publicNotice} +
+
+

Notes can’t open yet

+

{error}

+ + {session && ( + + )} +
+
+ + ); + if (!service) return publicNotice; + if (!session) + return ( + <> + {publicNotice} + + ); - if (!service) return null; - if (!session) return ; if (session.deviceState === "pending") return ( - void signOut()} - /> + <> + {publicNotice} + void signOut()} + /> + ); - if (!store) return null; + if (!store) return publicNotice; return ( - }> - signOut(true)} - /> - +
+ {incomingNotice} + }> + signOut(true)} + /> + +
); } function Loading() { diff --git a/web/src/links/useIncomingNoteLink.ts b/web/src/links/useIncomingNoteLink.ts new file mode 100644 index 0000000..5a3aee8 --- /dev/null +++ b/web/src/links/useIncomingNoteLink.ts @@ -0,0 +1,161 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + IncomingNoteLinkController, + formatWebNoteLink, + parseNoteLinkHash, +} from "./entry"; +import type { NoteLinkCopyFormatter, NoteLinkNavigator } from "./navigation"; +import { nativeNoteLinkIngress } from "../desktop/noteLinks"; + +// A React StrictMode setup/cleanup shares its controller. A genuinely different +// consumer must retire the old broker intent before it can receive anything. +let nativeConsumer: symbol | null = null; +let reportNativeFailure: () => void = () => {}; + +function clearNoteHash() { + if (parseNoteLinkHash(window.location.hash).kind === "unrelated") return; + // Keep the app's path/query intact; clearing an intent is not a navigation. + window.history.replaceState( + window.history.state, + "", + window.location.pathname + window.location.search, + ); +} + +export function useIncomingNoteLink(owner: object | null, desktop: boolean) { + const [transportError, setTransportError] = useState(false); + const consumer = useRef(Symbol("native-note-entry")); + const reconnect = useRef<(() => Promise) | null>(null); + const reconnecting = useRef(false); + const controllerRef = useRef(null); + if (!controllerRef.current) + controllerRef.current = new IncomingNoteLinkController(() => { + clearNoteHash(); + if (desktop) + void nativeNoteLinkIngress.retire().catch(() => reportNativeFailure()); + }); + const controller = controllerRef.current; + const state = useSyncExternalStore( + controller.subscribe, + controller.state, + () => null, + ); + const hasPending = useSyncExternalStore( + controller.subscribe, + controller.hasPending, + () => false, + ); + useLayoutEffect(() => { + controller.bind(owner); + }, [controller, owner]); + useEffect(() => { + // Web hash handling is not a claim of native protocol registration. + if (desktop) return; + const receive = () => controller.receive(window.location.hash); + receive(); + window.addEventListener("hashchange", receive); + return () => { + window.removeEventListener("hashchange", receive); + }; + }, [controller, desktop]); + useEffect(() => { + if (!desktop) return; + let active = true; + let connection = 0; + const identity = consumer.current; + const report = () => { + if (active && nativeConsumer === identity) setTransportError(true); + }; + reportNativeFailure = report; + if (nativeConsumer && nativeConsumer !== identity) + void nativeNoteLinkIngress.retire().catch(() => reportNativeFailure()); + nativeConsumer = identity; + const start = async () => { + const ticket = ++connection; + try { + await nativeNoteLinkIngress.start( + (target) => { + if (!active || nativeConsumer !== identity) return false; + setTransportError(false); + controller.receiveTarget(target); + return true; + }, + () => { + if (active && nativeConsumer === identity) { + setTransportError(false); + controller.receiveInvalid(); + } + }, + report, + ); + } catch (error) { + if (active && ticket === connection) throw error; + } + }; + const restart = async () => { + // Reconnect deliberately retires old pending identity. The notice tells + // the user to open the link again; this never replays a refused target. + await nativeNoteLinkIngress.retire(); + if (!active || nativeConsumer !== identity) return; + controller.dismiss(); + await start(); + if (active) setTransportError(false); + }; + reconnect.current = restart; + void start().catch(report); + return () => { + active = false; + connection++; + if (reconnect.current === restart) reconnect.current = null; + if (reportNativeFailure === report) reportNativeFailure = () => {}; + // The singleton listener/watermark lives for the app lifetime. Its old + // callback refuses handoff; a new consumer retires pending state above. + }; + }, [controller, desktop]); + const register = useCallback( + (navigator: NoteLinkNavigator | null) => { + if (owner) controller.register(owner, navigator); + }, + [controller, owner], + ); + const format = useCallback( + (target) => formatWebNoteLink(target, window.location.href), + [], + ); + const revoke = useCallback(() => controller.revoke(), [controller]); + const retry = useCallback(() => { + if (!transportError) { + controller.retry(); + return; + } + if (reconnecting.current || !reconnect.current) return; + reconnecting.current = true; + void reconnect + .current() + .catch(() => setTransportError(true)) + .finally(() => { + reconnecting.current = false; + }); + }, [controller, transportError]); + const dismiss = useCallback(() => { + setTransportError(false); + controller.dismiss(); + clearNoteHash(); + }, [controller]); + return { + hasPending, + state: transportError ? ("service-error" as const) : state, + register, + format: desktop ? undefined : format, + revoke, + retry, + dismiss, + }; +} diff --git a/web/src/styles/incoming-note-link.css b/web/src/styles/incoming-note-link.css new file mode 100644 index 0000000..ad55dda --- /dev/null +++ b/web/src/styles/incoming-note-link.css @@ -0,0 +1,65 @@ +.incoming-note-link { + position: fixed; + right: max(12px, env(safe-area-inset-right)); + bottom: max(12px, env(safe-area-inset-bottom)); + z-index: 50; + width: min(420px, calc(100vw - 24px)); + max-height: min(160px, calc(100dvh - 32px)); + overflow: auto; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 12px; + margin: 0; + padding: 10px 12px; + border: 1px solid var(--line2); + border-radius: 10px; + background: var(--main); + box-shadow: 0 8px 24px rgb(0 0 0 / 0.14); + color: var(--t1); +} +.incoming-note-link > p { + flex: 1 1 220px; + margin: 0; + font-size: 13px; + line-height: 1.5; +} +.incoming-note-link-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-left: auto; +} +.incoming-note-link-actions button { + min-height: 30px; + padding: 4px 10px; +} +.incoming-note-link-actions button:focus-visible { + outline: 2px solid var(--t2); + outline-offset: 2px; +} +@media (max-width: 480px) { + .incoming-note-link { + margin: 0; + } +} + +.notes-entry-layout { + height: 100dvh; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.notes-entry-layout > .incoming-note-link { + flex: none; +} +.notes-entry-layout > .app, +.notes-entry-layout > .loading { + flex: 1; + min-height: 0; + height: auto; +} +.desktop-content > .notes-entry-layout { + height: 100%; +} diff --git a/web/src/ui/CollectionsHost.tsx b/web/src/ui/CollectionsHost.tsx new file mode 100644 index 0000000..398ca0b --- /dev/null +++ b/web/src/ui/CollectionsHost.tsx @@ -0,0 +1,273 @@ +import { useEffect, useRef, useState } from "react"; +import type { + StudySourceOpenRequest, + StudyEntryOpenRequest, +} from "./studySourceNavigation"; +import type { AccountSession } from "../domain/contracts"; +import type { CollectionsStore } from "../collections/contracts"; +import { trackOperation } from "../desktop/closeGuard"; +import { StudyView } from "./StudyView"; +import { JournalView } from "./JournalView"; +import { ClipsView } from "./ClipsView"; +import type { ClipsStore } from "../clips/contracts"; +import { isDesktop } from "../desktop/window"; +import { openExternalUrl } from "../desktop/external"; +import type { JournalVault } from "../collections/journalVault"; +import type { + ClipSearchOpenRequest, + CardSearchOpenRequest, + SearchReaderResult, +} from "./searchNavigation"; + +/** Opens real account-scoped collections only when their route is selected. */ +export function CollectionsHost({ + session, + onOpenNote, + onEpochChange, + view = "study", + collections: borrowed, + onOpenSource, + openEntry, + openClip, + openCard, + onReturnToSearch, + holdClipSearchOrigin, + restoreClipSearchOrigin, + onSearchReaderResult, + covered = false, + onReopen, +}: { + onReopen?: () => void; + covered?: boolean; + openClip?: ClipSearchOpenRequest | null; + openCard?: CardSearchOpenRequest | null; + onReturnToSearch?: () => void; + holdClipSearchOrigin?: boolean; + restoreClipSearchOrigin?: number; + onSearchReaderResult?: SearchReaderResult; + onOpenSource?: (request: StudySourceOpenRequest) => void; + openEntry?: StudyEntryOpenRequest | null; + collections?: CollectionsStore | null; + session: AccountSession; + onOpenNote: (noteId: string) => void; + onEpochChange?: () => void; + view?: "study" | "journal" | "clips"; +}) { + const [clips, setClips] = useState(null); + const [clipsError, setClipsError] = useState(false); + const [clipsAttempt, setClipsAttempt] = useState(0); + const epochChanged = useRef(onEpochChange); + epochChanged.current = onEpochChange; + const [store, setStore] = useState(null); + const [error, setError] = useState(""); + const [showLoading, setShowLoading] = useState(false); + const [attempt, setAttempt] = useState(0); + const [vault, setVault] = useState(null); + const [vaultError, setVaultError] = useState(false); + const [vaultLoading, setVaultLoading] = useState(false); + const [vaultAttempt, setVaultAttempt] = useState(0); + useEffect(() => { + if (borrowed !== undefined) { + setStore(borrowed); + setError(""); + if (!borrowed) return; + let active = true, + stop: (() => void) | undefined; + void import("../collections/store").then((module) => { + if (active) + stop = module.onCollectionsEpochChange(borrowed, () => + epochChanged.current?.(), + ); + }); + return () => { + active = false; + stop?.(); + }; + } + let active = true; + let opened: CollectionsStore | undefined; + let stopEpoch: (() => void) | undefined; + setStore(null); + setError(""); + setShowLoading(false); + const timer = setTimeout(() => setShowLoading(true), 400); + void trackOperation(async () => { + const module = await import("../collections/store"); + const opened = await module.openCollectionsStore(session); + return { opened, module }; + }) + .then(({ opened: value, module }) => { + opened = value; + if (active) { + stopEpoch = module.onCollectionsEpochChange(value, () => + epochChanged.current?.(), + ); + setStore(value); + } else value.close(); + }) + .catch(() => { + opened?.close(); + if (active) setError("Your saved data was not changed. Try again."); + }); + return () => { + active = false; + clearTimeout(timer); + stopEpoch?.(); + opened?.close(); + }; + }, [session.accountId, session.deviceId, session.email, attempt, borrowed]); + useEffect(() => { + if (!store || view !== "journal") return; + let active = true; + let opened: JournalVault | undefined; + setVault(null); + setVaultError(false); + setVaultLoading(false); + const timer = setTimeout(() => setVaultLoading(true), 400); + void trackOperation(async () => { + const { openJournalVault } = await import("../collections/journalVault"); + return openJournalVault(session, store); + }) + .then((value) => { + opened = value; + if (active) setVault(value); + else value.close(); + }) + .catch(() => { + if (active) setVaultError(true); + }); + return () => { + active = false; + clearTimeout(timer); + opened?.close(); + setVault(null); + }; + }, [ + store, + view, + session.accountId, + session.deviceId, + session.email, + vaultAttempt, + ]); + useEffect(() => { + if (!store || view !== "clips") return; + let active = true, + opened: ClipsStore | undefined; + setClips(null); + setClipsError(false); + void import("../clips/store") + .then(({ createClipsStore }) => { + if (!active) return; + opened = createClipsStore(store); + setClips(opened); + }) + .catch(() => { + if (active) setClipsError(true); + }); + return () => { + active = false; + opened?.close(); + setClips(null); + }; + }, [store, view, clipsAttempt]); + const title = + view === "journal" ? "Journal" : view === "clips" ? "Clips" : "Study"; + if (error) + return ( +
+

{title} couldn’t open

+

{error}

+ +
+ ); + if (!store) + return ( +
+ {showLoading &&
} +
+ ); + if (view === "clips") { + if (clipsError) + return ( +
+

Clips couldn’t open

+

Your saved clips were not changed.

+ +
+ ); + if (!clips) + return ( +
+ ); + return ( + + ); + } + if (view === "journal") { + if (vaultError) + return ( +
+

Journal couldn’t open

+

Your saved Journal was not changed. Try again.

+ +
+ ); + if (!vault) + return ( +
+ {vaultLoading &&
} +
+ ); + return ( + setVaultAttempt((value) => value + 1)} + /> + ); + } + return ( + setAttempt((value) => value + 1) + : onReopen + } + accountId={session.accountId} + openCard={openCard} + onSearchReaderResult={onSearchReaderResult} + onReturnToSearch={onReturnToSearch} + onOpenNote={onOpenNote} + onOpenSource={onOpenSource} + openEntry={openEntry} + /> + ); +} diff --git a/web/src/ui/Workspace.tsx b/web/src/ui/Workspace.tsx index 5936e10..96e148c 100644 --- a/web/src/ui/Workspace.tsx +++ b/web/src/ui/Workspace.tsx @@ -1,4 +1,11 @@ -import { useEffect, useRef, useState } from "react"; +import { + lazy, + Suspense, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { FileText, Search, @@ -8,28 +15,380 @@ import { Menu, Grid2X2, List, + Pin, + Paperclip, + Tags, } from "lucide-react"; import type { AccountSession, Note, NotesStore } from "../domain/contracts"; -import { parseMarkdown } from "../transfer"; +import { + parseMarkdown, + MAX_MARKDOWN_FILE_BYTES, + MAX_MARKDOWN_BATCH_BYTES, + MAX_MARKDOWN_FILES, +} from "../transfer"; import { projectNotes, plainText, message } from "./model"; import { Dialog, usePreferences } from "./primitives"; import { SettingsView } from "./SettingsView"; +import type { SettingsLeaveGuard } from "./studySettingsTypes"; import { NoteEditor } from "./NoteEditor"; import { buildDownload } from "./download"; import { useDesktop } from "../desktop/DesktopShell"; -import { trackOperation } from "../desktop/closeGuard"; +import { + checkClose, + trackOperation, + registerCloseGuard, +} from "../desktop/closeGuard"; +import { FirstRunSetup, type SetupImportState } from "./FirstRunSetup"; +import { + profileSetupPreference, + type SetupPreference, +} from "../setup/preference"; +import type { + LibraryReadResult, + LibraryMutationResult, + NotesLibrary, + NotesLibraryStore, + TrashUndo, + NoteSort, +} from "../library/contracts"; +import type { EditorSourceTarget } from "./RichEditor"; +import type { + StudySourceOpenRequest, + StudyEntryOpenRequest, +} from "./studySourceNavigation"; +import { useStudySourceOwners } from "./studySourceOwners"; +import { useNoteLinkOwner } from "./noteLinkOwner"; +import type { + LinkQuery, + OwnerAdmission, + NoteExportResult, + NoteLinkService, +} from "../links/contracts"; +import { noteLinkDownload } from "./noteLinkDownload"; +import { NoteLinksPanel } from "./NoteLinksPanel"; +import type { + NoteLinkNavigator, + NoteLinkNavigatorRegistration, + NoteLinkCopyFormatter, +} from "../links/navigation"; +import { projectLibraryNotes } from "../library/model"; +import { TagsView } from "./TagsView"; +import { VersionHistory } from "./VersionHistory"; +import { LibraryActionFeedback, useLibraryAction } from "./libraryAction"; +import "../styles/library.css"; +import { SearchView, type SearchTab } from "./SearchView"; +import { SearchSurface } from "./SearchSurface"; +import { useLocalSearchOwner } from "./localSearchOwner"; +import type { SearchLocator, SearchKind } from "../search/results"; +import type { + ClipSearchOpenRequest, + CardSearchOpenRequest, +} from "./searchNavigation"; +type WorkspaceModule = "notes" | "study" | "journal" | "clips" | "tags"; +type SearchOrigin = { + module: WorkspaceModule; + selected: string | null; + trash: boolean; + tag: string; + pinned: boolean; + linked: boolean; +}; +const CollectionsHost = lazy(() => + import("./CollectionsHost").then((module) => ({ + default: module.CollectionsHost, + })), +); type Imported = ReturnType; export function Workspace({ store, session, onSignOut, + library, + sourceOwner, + onNoteLinkNavigatorReady, + formatNoteLink, + onNoteLinkScopeRevoked, + incomingNoteIntentActive = false, + setupPreference = profileSetupPreference, }: { + incomingNoteIntentActive?: boolean; + setupPreference?: SetupPreference; + onNoteLinkScopeRevoked?: () => void; + formatNoteLink?: NoteLinkCopyFormatter; + onNoteLinkNavigatorReady?: NoteLinkNavigatorRegistration; store: NotesStore; + library?: NotesLibrary; + sourceOwner?: NotesLibraryStore; session: AccountSession; onSignOut: () => Promise; }) { + const [sourceTarget, setSourceTarget] = useState( + null, + ), + [sourceReturn, setSourceReturn] = useState( + null, + ), + [sourceUnavailable, setSourceUnavailable] = useState<{ + request: StudySourceOpenRequest; + reason: string; + } | null>(null), + [studyEntry, setStudyEntry] = useState(null); + const [sourceProjectionError, setSourceProjectionError] = useState< + string | null + >(null); + const activeSourceTarget = useRef(null); + const linkNavigationTicket = useRef(0); + const [linkNotice, setLinkNotice] = useState(""); + const exportWorking = useRef(false), + exportGeneration = useRef(0), + exportReceipt = useRef<{ + service: NoteLinkService; + result: NoteExportResult; + } | null>(null); + const [exportBusy, setExportBusy] = useState(false); + useEffect( + () => () => { + exportGeneration.current++; + exportReceipt.current = null; + }, + [sourceOwner], + ); + const [linkedOnly, setLinkedOnly] = useState(false); + const [linkRecoveryOpen, setLinkRecoveryOpen] = useState(false); + const linkRecoveryCanClose = useRef<() => boolean>(() => true); + const [linkAdmission, setLinkAdmission] = useState( + null, + ); + const [linkAdmissionSettled, setLinkAdmissionSettled] = useState(false); + const linkVerificationPending = useRef(false); + const [linkedQuery, setLinkedQuery] = useState | null>( + null, + ); + const links = useNoteLinkOwner(sourceOwner, (reason) => { + try { + onNoteLinkScopeRevoked?.(); + } catch { + /* A failed observer must not retain private UI projections. */ + } + revokeSearchScope(); + + importGeneration.current++; + importHeld.current = false; + importPending.current = false; + fileReading.current = false; + setImportError(""); + setupVisible.current = false; + setSetupOpen(false); + setImports(null); + setImporting(false); + setSetupImportState("idle"); + clearSourceRoute(); + setStudyEntry(null); + readSequence.current++; + setLibraryRead(null); + setNotes([]); + setHistoryNote(null); + setEmptyConfirm(null); + setLinkRecoveryOpen(false); + exportGeneration.current++; + exportReceipt.current = null; + exportWorking.current = false; + setExportBusy(false); + setDownload(null); + setExportNotes(null); + setLinkAdmission(null); + linkNavigationTicket.current++; + setLinkNotice(""); + setLinkedQuery(null); + selectedSnapshot.current = undefined; + setSelected(null); + if (reason === "epoch") void refresh(); + }); + useEffect(() => { + setLinkAdmission(null); + setLinkAdmissionSettled(false); + if (!links.service) return; + let active = true, + sequence = 0; + const read = () => { + const ticket = ++sequence; + void Promise.all([ + links.service!.linkedNoteIds(), + links.service!.admission(), + ]) + .then(([result, admission]) => { + if (active && ticket === sequence) { + setLinkedQuery(result); + setLinkAdmission(admission); + setLinkAdmissionSettled(true); + } + }) + .catch(() => { + if (active && ticket === sequence) { + setLinkAdmissionSettled(true); + setLinkNotice("Couldn’t load note links. Try again."); + } + }); + }; + read(); + const stop = links.service.subscribe(read); + return () => { + active = false; + sequence++; + stop(); + }; + }, [links.service]); + const sourceRequest = useRef(0), + sourceWorking = useRef(false); + function assignSourceTarget(value: EditorSourceTarget | null) { + activeSourceTarget.current = value?.key ?? null; + setSourceTarget(value); + } + function clearSourceRoute() { + linkNavigationTicket.current++; + setLinkNotice(""); + sourceRequest.current++; + setSourceProjectionError(null); + assignSourceTarget(null); + setSourceReturn(null); + setSourceUnavailable(null); + } + const sourceOwners = useStudySourceOwners(sourceOwner, session, () => { + revokeSearchScope(); + sourceRequest.current++; + sourceWorking.current = false; + setSourceProjectionError(null); + selectedSnapshot.current = undefined; + setSelected(null); + assignSourceTarget(null); + setSourceReturn(null); + setSourceUnavailable(null); + setStudyEntry(null); + }); + const [libraryRead, setLibraryRead] = useState( + null, + ); + const [pinnedOnly, setPinnedOnly] = useState(false); + const [historyNote, setHistoryNote] = useState(null); + const [emptyConfirm, setEmptyConfirm] = useState( + null, + ); + const libraryAction = useLibraryAction( + libraryRead?.mode === "ready" + ? libraryRead.snapshot.token.epoch + : libraryRead?.mode, + ); + const adoptedEpoch = useRef(null); + const readSequence = useRef(0); + const snapshot = libraryRead?.mode === "ready" ? libraryRead.snapshot : null; + const readOnly = + (!!library && libraryRead?.mode !== "ready") || + linkAdmission?.mode === "index-recovery"; + const readOnlyRef = useRef(readOnly); + readOnlyRef.current = readOnly; + const libraryFeedbackAction = { + ...libraryAction, + reloaded: !readOnly && libraryAction.reloaded, + retry: () => { + if (!readOnlyRef.current) libraryAction.retry(); + }, + }; + const legacyReadOnly = libraryRead?.mode === "legacy-read-only"; + function adoptLibrary(result: LibraryReadResult) { + if (result.mode === "ready") { + const nextEpoch = result.snapshot.token.epoch; + if (adoptedEpoch.current && adoptedEpoch.current !== nextEpoch) { + selectedSnapshot.current = undefined; + setSelected(null); + setHistoryNote(null); + setEmptyConfirm(null); + setUndo(null); + setTag(""); + setPinnedOnly(false); + } + adoptedEpoch.current = nextEpoch; + } + setLibraryRead(result); + setNotes(result.mode === "ready" ? result.snapshot.notes : result.notes); + } + function acceptLibrary(result: LibraryMutationResult) { + readSequence.current++; + adoptLibrary({ mode: "ready", snapshot: result.snapshot }); + } const [notes, setNotes] = useState([]); const [selected, setSelected] = useState(null); + const [module, setModuleState] = useState("notes"); + const [searchActive, setSearchActive] = useState(false), + [searchQuery, setSearchQuery] = useState(""), + [searchTab, setSearchTab] = useState("all"); + const [searchOrigin, setSearchOrigin] = useState(null), + [searchNavigation, setSearchNavigation] = useState< + "opening" | "blocked" | "unavailable" | null + >(null); + const [searchActionError, setSearchActionError] = useState(""); + const [searchPrivacyVersion, setSearchPrivacyVersion] = useState(0); + const [restoreClipSearchOrigin, setRestoreClipSearchOrigin] = useState(0); + const [clipSearchRequest, setClipSearchRequest] = + useState(null), + [cardSearchRequest, setCardSearchRequest] = + useState(null); + const searchTicket = useRef(0), + searchScroll = useRef(0), + restoreSearchScroll = useRef<{ ticket: number; value: number } | null>( + null, + ), + searchAbort = useRef(null), + searchRequest = useRef(null); + const searchOwner = useLocalSearchOwner( + sourceOwner, + sourceOwners.collections, + (reason) => { + searchTicket.current++; + searchAbort.current?.abort(); + searchRequest.current = null; + setSearchNavigation(null); + setSearchActionError(""); + setClipSearchRequest(null); + setCardSearchRequest(null); + if (reason !== "changed") { + setSearchPrivacyVersion((value) => value + 1); + setSearchQuery(""); + setSearchTab("all"); + setSearchOrigin(null); + setSearchActive(false); + selectedSnapshot.current = undefined; + setSelected(null); + } + }, + ); + function revokeSearchScope() { + setSearchPrivacyVersion((value) => value + 1); + searchTicket.current++; + searchAbort.current?.abort(); + searchRequest.current = null; + setSearchQuery(""); + setSearchTab("all"); + setSearchOrigin(null); + setSearchActive(false); + setSearchNavigation(null); + setSearchActionError(""); + setClipSearchRequest(null); + setCardSearchRequest(null); + selectedSnapshot.current = undefined; + setSelected(null); + searchOwner.service?.cancel(); + } + function setModule(next: WorkspaceModule) { + searchTicket.current++; + searchAbort.current?.abort(); + searchRequest.current = null; + setSearchActive(false); + setSearchOrigin(null); + setClipSearchRequest(null); + setCardSearchRequest(null); + setSearchNavigation(null); + setModuleState(next); + } const [query, setQuery] = useState(""); const [tag, setTag] = useState(""); const [trash, setTrash] = useState(false); @@ -37,8 +396,11 @@ export function Workspace({ const [drawer, setDrawer] = useState(false); const desktop = useDesktop(); const [sidebarHidden, setSidebarHidden] = useState(false); - const [wide, setWide] = useState(() => matchMedia("(min-width: 900px)").matches); + const [wide, setWide] = useState( + () => matchMedia("(min-width: 900px)").matches, + ); const [settings, setSettings] = useState(false); + const settingsStudyGuard = useRef(null); const [signOutOpen, setSignOutOpen] = useState(false); const [signOutBusy, setSignOutBusy] = useState(false); const [signOutError, setSignOutError] = useState(""); @@ -46,8 +408,22 @@ export function Workspace({ const [loaded, setLoaded] = useState(false); const [showLoadingSkeleton, setShowLoadingSkeleton] = useState(false); const [notice, setNotice] = useState(""); - const [undo, setUndo] = useState(null); + const navigationGuardNotice = useRef(null); + const [undo, setUndo] = useState(null); const [imports, setImports] = useState(null); + const [setupOpen, setSetupOpen] = useState(false), + [setupImportState, setSetupImportState] = + useState("idle"), + [setupImported, setSetupImported] = useState(0), + [importError, setImportError] = useState(""); + const setupVisible = useRef(false), + setupOrigin = useRef<"auto" | "manual">("auto"), + autoSetupOwner = useRef(undefined); + const importGeneration = useRef(0), + importHeld = useRef(false), + importPending = useRef(false), + fileReading = useRef(false), + importOrigin = useRef<"setup" | "library">("library"); const [importing, setImporting] = useState(false); const [exportNotes, setExportNotes] = useState(null); const [archiveExport, setArchiveExport] = useState(false); @@ -69,17 +445,53 @@ export function Workspace({ const settingsReturnFocus = useRef(null); const drawerRef = useRef(null); const drawerWasOpen = useRef(false); + useEffect(() => { + const input = fileRef.current; + input?.addEventListener("cancel", cancelMarkdown); + return () => input?.removeEventListener("cancel", cancelMarkdown); + }, [loaded, sourceOwner]); + useLayoutEffect( + () => + registerCloseGuard( + () => + !importHeld.current || importPending.current || fileReading.current, + ), + [], + ); + useEffect( + () => () => { + importGeneration.current++; + importHeld.current = false; + setupVisible.current = false; + }, + [], + ); + useEffect(() => { + const guard = (event: BeforeUnloadEvent) => { + if (importHeld.current) event.preventDefault(); + }; + window.addEventListener("beforeunload", guard); + return () => window.removeEventListener("beforeunload", guard); + }, []); useEffect(() => { const query = matchMedia("(min-width: 900px)"); - const update = () => { setWide(query.matches); if (query.matches) setDrawer(false); }; + const update = () => { + setWide(query.matches); + if (query.matches) setDrawer(false); + }; update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); useEffect(() => { if (!desktop) return; - desktop.setNavigation({ expanded: wide ? !sidebarHidden : drawer, - toggle: () => { if (wide) setSidebarHidden(value => !value); else setDrawer(value => !value); } }); + desktop.setNavigation({ + expanded: wide ? !sidebarHidden : drawer, + toggle: () => { + if (wide) setSidebarHidden((value) => !value); + else setDrawer((value) => !value); + }, + }); return () => desktop.setNavigation(null); }, [desktop?.setNavigation, wide, sidebarHidden, drawer]); useEffect(() => { @@ -87,9 +499,28 @@ export function Workspace({ const timer = setTimeout(() => setShowLoadingSkeleton(true), 400); return () => clearTimeout(timer); }, [loaded]); + async function reloadLibrary() { + const sequence = ++readSequence.current; + if (library) { + const value = await library.read(); + if (sequence === readSequence.current) adoptLibrary(value); + } else { + const value = await store.list(); + if (sequence === readSequence.current) setNotes(value); + } + } async function refresh() { + const sequence = ++readSequence.current; try { - setNotes(await store.list()); + if (library) { + const result = await library.read(); + if (sequence !== readSequence.current) return; + adoptLibrary(result); + } else { + const result = await store.list(); + if (sequence !== readSequence.current) return; + setNotes(result); + } setLoaded(true); } catch (e) { setError(`Couldn’t load notes. ${message(e)}`); @@ -98,10 +529,17 @@ export function Workspace({ useEffect(() => { let active = true; const read = async () => { + const sequence = ++readSequence.current; try { - const list = await store.list(); - if (active) { - setNotes(list); + const result = library ? await library.read() : null; + const list = result + ? result.mode === "ready" + ? result.snapshot.notes + : result.notes + : await store.list(); + if (active && sequence === readSequence.current) { + if (result) adoptLibrary(result); + else setNotes(list); setLoaded(true); } } catch (e) { @@ -114,7 +552,7 @@ export function Workspace({ active = false; unsub(); }; - }, [store]); + }, [store, library]); useEffect(() => { const update = () => setOffline(!navigator.onLine); window.addEventListener("online", update); @@ -126,7 +564,12 @@ export function Workspace({ }, []); useEffect(() => { if (!undo) return; - const timer = setTimeout(() => setUndo(null), 5000); + const timer = setTimeout( + () => setUndo(null), + "expiresAt" in undo + ? Math.max(0, Date.parse(undo.expiresAt) - Date.now()) + : 5000, + ); return () => clearTimeout(timer); }, [undo]); useEffect(() => { @@ -175,23 +618,655 @@ export function Workspace({ ? selectedSnapshot.current : undefined); if (current) selectedSnapshot.current = current; - const visible = projectNotes(notes, query, tag, trash); - const tags = [ - ...new Set(notes.filter((n) => !n.trashedAt).flatMap((n) => n.tags)), - ].sort(); + const metadata = new Map( + snapshot?.metadata.map((value) => [value.noteId, value]), + ); + const sort = snapshot?.preferences.sort ?? "edited"; + const selectedTag = snapshot?.tags.find( + (value) => value.state === "active" && value.name === tag, + ); + const allVisible = snapshot + ? tag && !selectedTag + ? [] + : projectLibraryNotes( + { ...snapshot, notes }, + { query, tagId: selectedTag?.id, trash, pinnedOnly }, + ) + : projectNotes(notes, query, tag, trash); + const visible = + linkedOnly && !trash + ? linkedQuery?.status === "ready" + ? allVisible.filter((note) => linkedQuery.items.includes(note.id)) + : [] + : allVisible; + const tags = snapshot + ? snapshot.tags + .filter((value) => value.state === "active") + .map((value) => value.name) + .sort((a, b) => a.localeCompare(b)) + : [ + ...new Set(notes.filter((n) => !n.trashedAt).flatMap((n) => n.tags)), + ].sort(); const editing = selected !== null && current; - function navigate(isTrash = false, nextTag = "") { - if (editing) { + const noteActors = Array.from( + new Set( + [searchOrigin?.selected, selected].filter((id): id is string => !!id), + ), + ) + .map( + (id) => + notes.find((note) => note.id === id) ?? + (current?.id === id ? current : undefined), + ) + .filter((note): note is Note => !!note); + + function canNavigate() { + const state = checkClose(); + if (state === "working" || state === "draft") { + const value = + state === "working" + ? "Notes is still working. Wait for it to finish before leaving." + : "Finish or discard your unsaved changes before leaving."; + navigationGuardNotice.current = value; + setNotice(value); + } else clearNavigationGuardNotice(); + return state === "allowed"; + } + function clearNavigationGuardNotice() { + const owned = navigationGuardNotice.current; + if (owned) { + navigationGuardNotice.current = null; + setNotice((value) => (value === owned ? "" : value)); + } + } + function enterSearch(raw = searchQuery, submit = false) { + setSearchQuery(raw); + if (!canNavigate()) { + setSearchNavigation("blocked"); + return; + } + if (!searchOrigin) { + searchScroll.current = 0; + setSearchOrigin({ + module, + selected, + trash, + tag, + pinned: pinnedOnly, + linked: linkedOnly, + }); + } + setSearchNavigation(null); + // A retained certificate is not current proof. Clear it synchronously before exposure. + if (submit && searchOwner.service) + runSearchAction(() => searchOwner.service!.query(raw)); + else if (!searchActive) freshenReadySearch(raw); + setSearchActive(true); + setDrawer(false); + setSettings(false); + const main = document.getElementById("main"); + if (main) main.scrollTop = 0; + requestAnimationFrame(() => + document + .querySelector(".unified-search input") + ?.focus(), + ); + } + function runSearchAction(action: () => Promise) { + const ticket = ++searchTicket.current; + searchAbort.current?.abort(); + searchRequest.current = null; + setSearchActionError(""); + setSearchNavigation(null); + void action().catch(() => { + if (ticket === searchTicket.current) + setSearchActionError("Search couldn’t finish this action. Try again."); + }); + } + function freshenReadySearch(raw: string) { + const service = searchOwner.service, + retained = service?.getSnapshot(); + if (!service || retained?.status !== "ready") return; + const pages: Partial> = {}; + for (const group of retained.groups) pages[group.kind] = group.offset; + runSearchAction(() => service.query(raw, undefined, Object.freeze(pages))); + } + function cancelSearchWork() { + searchTicket.current++; + searchAbort.current?.abort(); + searchRequest.current = null; + setSearchNavigation(null); + setSearchActionError(""); + searchOwner.service?.cancel(); + } + function editSearchQuery(raw: string) { + cancelSearchWork(); + setSearchQuery(raw); + } + function selectSearchTab(tab: SearchTab) { + restoreSearchScroll.current = null; + setSearchTab(tab); + const view = document.querySelector(".unified-search"); + if (view) view.scrollTop = 0; + } + function clearSearch() { + if (!canNavigate()) { + setSearchNavigation("blocked"); + return; + } + searchTicket.current++; + searchAbort.current?.abort(); + searchRequest.current = null; + searchOwner.service?.cancel(); + setSearchQuery(""); + setSearchTab("all"); + setSearchActive(false); + setSearchNavigation(null); + setClipSearchRequest(null); + setCardSearchRequest(null); + const origin = searchOrigin; + if (origin?.module === "clips") + setRestoreClipSearchOrigin((value) => value + 1); + setSearchOrigin(null); + if (origin) { + setModuleState(origin.module); + setSelected(origin.selected); + setTrash(origin.trash); + setTag(origin.tag); + setPinnedOnly(origin.pinned); + setLinkedOnly(origin.linked); + } else { + setModuleState("notes"); + setSelected(null); + } + } + async function openSearchTarget(target: SearchLocator) { + if (!canNavigate()) { + setSearchNavigation("blocked"); + return; + } + const ticket = ++searchTicket.current; + searchAbort.current?.abort(); + const abort = new AbortController(); + searchAbort.current = abort; + setSearchNavigation("opening"); + searchScroll.current = + document.querySelector(".unified-search")?.scrollTop ?? 0; + if (target.accountId !== session.accountId) { + setSearchNavigation("unavailable"); + return; + } + if (target.kind === "note") { + const result = await openNoteTarget(target, abort.signal, true); + if (ticket !== searchTicket.current || abort.signal.aborted) return; + setSearchNavigation(result.status === "opened" ? null : result.status); + return; + } + const collections = sourceOwners.collections; + if (!collections) { + setSearchNavigation("unavailable"); + return; + } + try { + const value = await trackOperation(() => collections.read()); + if (ticket !== searchTicket.current || abort.signal.aborted) return; + if (!canNavigate()) { + setSearchNavigation("blocked"); + return; + } + const exists = + value.epoch === target.collectionsEpoch && + (target.kind === "clip" + ? value.clips.items.some((item) => item.id === target.clipId) + : value.study.cards.some( + (card) => + card.id === target.cardId && + card.entryId === target.entryId && + card.key === target.key, + ) && + value.study.entries.some((entry) => entry.id === target.entryId)); + if (!exists) { + setSearchNavigation("unavailable"); + return; + } + const requestId = crypto.randomUUID(); + searchRequest.current = requestId; + if (target.kind === "clip") { + setClipSearchRequest({ requestId, target }); + setModuleState("clips"); + } else { + setCardSearchRequest({ requestId, target }); + setModuleState("study"); + } + } catch { + if (ticket === searchTicket.current && !abort.signal.aborted) + setSearchNavigation("unavailable"); + } + } + function searchReaderResult( + requestId: string, + status: "opened" | "blocked" | "unavailable", + ) { + if (searchRequest.current !== requestId) return; + searchRequest.current = null; + if (status === "opened" && !canNavigate()) { + setSearchNavigation("blocked"); + return; + } + setSearchNavigation(status === "opened" ? null : status); + if (status === "opened") { + setSelected(null); + setSearchActive(false); + } else if (searchOrigin) setModuleState(searchOrigin.module); + } + function returnToSearch() { + if (!canNavigate()) return; + searchTicket.current++; + searchAbort.current?.abort(); + searchRequest.current = null; + setCardSearchRequest(null); + setClipSearchRequest(null); + freshenReadySearch(searchQuery); + setSearchActive(true); + setSearchNavigation(null); + const ticket = searchTicket.current; + restoreSearchScroll.current = { ticket, value: searchScroll.current }; + requestAnimationFrame(() => { + if (ticket !== searchTicket.current) return; + const view = document.querySelector(".unified-search"); + if (view) { + view + .querySelector("input") + ?.focus({ preventScroll: true }); + } + }); + } + useLayoutEffect(() => { + const pending = restoreSearchScroll.current; + if (!pending) return; + if (pending.ticket !== searchTicket.current || !searchActive) { + restoreSearchScroll.current = null; + return; + } + if (searchOwner.state.status === "ready") { + const view = document.querySelector(".unified-search"); + if (view) view.scrollTop = pending.value; + restoreSearchScroll.current = null; + } else if (searchOwner.state.status !== "pending") + restoreSearchScroll.current = null; + }, [searchActive, searchOwner.state]); + const enterSearchRef = useRef(enterSearch); + enterSearchRef.current = enterSearch; + useEffect(() => { + if (!sourceOwner) return; + const key = (event: KeyboardEvent) => { + if ( + event.key.toLowerCase() !== "k" || + !(event.ctrlKey || event.metaKey) || + event.altKey || + event.isComposing || + document.querySelector('dialog[open], [role="dialog"]') + ) + return; + event.preventDefault(); + enterSearchRef.current(); + }; + window.addEventListener("keydown", key); + return () => window.removeEventListener("keydown", key); + }, [sourceOwner]); + function openSetup(origin: "auto" | "manual") { + if (incomingNoteIntentActive || !canNavigate()) return; + setupOrigin.current = origin; + setupVisible.current = true; + setSetupImported(0); + setSetupImportState("idle"); + setImportError(""); + setSettings(false); + setSetupOpen(true); + } + function finishSetup() { + if (importHeld.current || importPending.current || fileReading.current) + return; + setupVisible.current = false; + setSetupOpen(false); + setImportError(""); + const result = setupPreference.dismiss(); + if (!result.persisted) setNotice( - "Use Back to notes before changing collections. Your draft stays open.", + `Setup closed. This ${desktop ? "device" : "browser"} couldn’t save the preference.`, ); + } + useEffect(() => { + if ( + !sourceOwner || + autoSetupOwner.current === sourceOwner || + !loaded || + !links.bootSettled || + !sourceOwners.bootSettled || + (links.service && !linkAdmissionSettled) + ) + return; + // Consume before ALL eligibility/guard refusals; later saves or link completion cannot take over. + autoSetupOwner.current = sourceOwner; + if ( + setupPreference.isDismissed() || + searchActive || + !!searchOrigin || + incomingNoteIntentActive || + readOnly || + !linkAdmission || + !snapshot || + snapshot.notes.length || + module !== "notes" || + selected !== null || + sourceReturn || + query.trim() || + trash || + tag || + pinnedOnly || + linkedOnly || + settings || + drawer || + historyNote || + emptyConfirm || + linkRecoveryOpen || + exportNotes || + erase || + signOutOpen || + importHeld.current || + libraryAction.error || + checkClose() !== "allowed" + ) + return; + openSetup("auto"); + }, [ + sourceOwner, + loaded, + links.bootSettled, + links.service, + linkAdmissionSettled, + linkAdmission, + sourceOwners.bootSettled, + setupPreference, + searchActive, + searchOrigin, + incomingNoteIntentActive, + readOnly, + snapshot, + module, + selected, + sourceReturn, + query, + trash, + tag, + pinnedOnly, + linkedOnly, + settings, + drawer, + historyNote, + emptyConfirm, + linkRecoveryOpen, + exportNotes, + erase, + signOutOpen, + libraryAction.error, + ]); + useEffect(() => { + const maybeClose = () => { + if ( + setupVisible.current && + setupOrigin.current === "auto" && + setupPreference.isDismissed() && + !importHeld.current && + !importPending.current && + !fileReading.current + ) { + setupVisible.current = false; + setSetupOpen(false); + } + }; + maybeClose(); + return setupPreference.subscribe(maybeClose); + }, [setupPreference, setupImportState]); + useEffect(() => { + if ( + incomingNoteIntentActive && + setupVisible.current && + !importHeld.current && + !importPending.current && + !fileReading.current + ) { + setupVisible.current = false; + setSetupOpen(false); + } + }, [incomingNoteIntentActive, setupImportState]); + function chooseMarkdown(origin: "setup" | "library" = "library") { + if ( + readOnlyRef.current || + importHeld.current || + importPending.current || + fileReading.current || + !canNavigate() + ) + return; + importOrigin.current = origin; + importHeld.current = true; + setImportError(""); + if (origin === "setup") setSetupImportState("picking"); + fileRef.current?.click(); + } + function cancelMarkdown() { + if (importPending.current || fileReading.current) return; + importHeld.current = false; + setImports(null); + if (importOrigin.current === "setup") { + setSetupImportState("idle"); + setImportError(""); + } + } + const openNoteTarget = async ( + target: Parameters[0], + signal?: AbortSignal, + fromSearch = false, + ): ReturnType => { + if (signal?.aborted) return { status: "unavailable" }; + const service = links.service; + if (!service) return { status: "unavailable" }; + const self = + target.noteId === selected && + target.accountId === session.accountId && + target.libraryEpoch === snapshot?.token.epoch; + if (!self && !canNavigate()) return { status: "blocked" }; + if (setupVisible.current && !importHeld.current) { + setupVisible.current = false; + setSetupOpen(false); + } + const ticket = ++linkNavigationTicket.current; + setLinkNotice(""); + try { + const resolved = await trackOperation(() => service.resolve(target)); + if (signal?.aborted || ticket !== linkNavigationTicket.current) + return { status: "unavailable" }; + if (resolved.status !== "available") { + setLinkNotice( + resolved.reason === "trashed" + ? "This note is in Trash." + : "This note isn’t available in this workspace.", + ); + return { status: "unavailable" }; + } + if (self) { + if (signal?.aborted || ticket !== linkNavigationTicket.current) + return { status: "unavailable" }; + clearNavigationGuardNotice(); + setSearchActive(false); + if (!fromSearch) setSearchOrigin(null); + const focusCurrent = () => { + if (signal?.aborted || ticket !== linkNavigationTicket.current) + return; + document + .querySelector( + '[data-note-actor-active="true"] [aria-label="Note content"]', + ) + ?.focus(); + }; + if (searchActive) requestAnimationFrame(focusCurrent); + else focusCurrent(); + return { status: "opened" }; + } + const saved = await trackOperation(() => + store.get(resolved.target.noteId), + ); + if (signal?.aborted || ticket !== linkNavigationTicket.current) + return { status: "unavailable" }; + if (!saved || saved.trashedAt) { + setLinkNotice("This note isn’t available in this workspace."); + return { status: "unavailable" }; + } + if (!canNavigate()) return { status: "blocked" }; + if (signal?.aborted || ticket !== linkNavigationTicket.current) + return { status: "unavailable" }; + selectedSnapshot.current = saved; + setNotes((values) => [ + saved, + ...values.filter((note) => note.id !== saved.id), + ]); + assignSourceTarget(null); + setSourceProjectionError(null); + setSourceUnavailable(null); + if (signal?.aborted || ticket !== linkNavigationTicket.current) + return { status: "unavailable" }; + setSelected(saved.id); + setDrawer(false); + if (!sourceReturn) { + if (fromSearch) setModuleState("notes"); + else setModule("notes"); + } + setSearchActive(false); + return { status: "opened" }; + } catch { + if (!signal?.aborted && ticket === linkNavigationTicket.current) + setLinkNotice("This note isn’t available in this workspace."); + return { status: "unavailable" }; + } + }; + const noteNavigator = useRef(openNoteTarget); + noteNavigator.current = openNoteTarget; + const admittedNavigatorService = useRef(null); + const navigatorBootReady = + loaded && links.bootSettled && (!sourceOwner || sourceOwners.bootSettled); + const canRegisterNavigator = Boolean( + links.service && + (admittedNavigatorService.current === links.service || navigatorBootReady), + ); + useEffect(() => { + if (!links.service || !canRegisterNavigator) { + if (!links.service) admittedNavigatorService.current = null; + onNoteLinkNavigatorReady?.(null); return; } + admittedNavigatorService.current = links.service; + const invoke: NoteLinkNavigator = (target, signal?: AbortSignal) => + noteNavigator.current(target, signal); + onNoteLinkNavigatorReady?.(invoke); + return () => { + linkNavigationTicket.current++; + onNoteLinkNavigatorReady?.(null); + }; + }, [links.service, canRegisterNavigator, onNoteLinkNavigatorReady]); + function openStudyEntry(entryId: string) { + if (!canNavigate()) return; + clearSourceRoute(); + setStudyEntry({ key: crypto.randomUUID(), entryId }); + setModule("study"); + setSelected(null); + setDrawer(false); + } + async function openSource(request: StudySourceOpenRequest, ordinary = false) { + if ( + sourceWorking.current || + !sourceOwners.service || + !sourceOwners.collections || + !canNavigate() + ) + return; + const ticket = ++sourceRequest.current, + service = sourceOwners.service; + sourceWorking.current = true; + setSourceProjectionError(null); + setSourceUnavailable(null); + assignSourceTarget(null); + try { + const target = await trackOperation(() => + service.resolve(request.entryId), + ); + if (ticket !== sourceRequest.current) return; + if (target.status !== "attached" && !ordinary) { + setSourceUnavailable({ request, reason: target.reason }); + return; + } + let noteId = target.status === "attached" ? target.noteId : null; + if (!noteId && ordinary) { + const data = await trackOperation(() => + sourceOwners.collections!.read(), + ); + noteId = + data.study.entries.find((entry) => entry.id === request.entryId) + ?.source?.noteId ?? null; + } + if (!noteId) throw new Error("This source note is no longer available."); + const saved = await trackOperation(() => store.get(noteId!)); + if (ticket !== sourceRequest.current) return; + if (!saved || saved.trashedAt) + throw new Error("This source note is no longer available."); + if ( + target.status === "attached" && + saved.revision !== target.noteRevision + ) { + setSourceUnavailable({ request, reason: "stale" }); + return; + } + if (!canNavigate()) return; + selectedSnapshot.current = saved; + setNotes((values) => [ + saved, + ...values.filter((note) => note.id !== saved.id), + ]); + setSourceReturn(request); + setSourceProjectionError(null); + assignSourceTarget( + target.status === "attached" + ? { key: crypto.randomUUID(), ...target } + : null, + ); + setSourceUnavailable( + target.status === "attached" + ? null + : { request, reason: target.reason }, + ); + setSelected(saved.id); + setDrawer(false); + } catch (failure) { + if (ticket === sourceRequest.current) + setSourceProjectionError(message(failure)); + } finally { + if (ticket === sourceRequest.current) sourceWorking.current = false; + } + } + function navigate(isTrash = false, nextTag = "") { + if (!canNavigate()) return; + clearSourceRoute(); + setModule("notes"); + setSelected(null); setTrash(isTrash); + setPinnedOnly(false); + setLinkedOnly(false); setTag(nextTag); setDrawer(false); } async function create() { + if (readOnly || !canNavigate()) return; + clearSourceRoute(); try { const note = await store.create({ title: "", @@ -206,6 +1281,20 @@ export function Workspace({ } } async function move(note: Note) { + if (readOnly) return; + if (library) { + libraryAction.start( + (id) => library.changeTrash(note.id, "trash", note.revision, id), + (result) => { + acceptLibrary(result); + setUndo(result.undo); + setNotice("Note moved to Trash"); + setSelected(null); + selectedSnapshot.current = undefined; + }, + ); + return; + } try { const moved = await store.trash(note.id, note.revision); setUndo(moved); @@ -217,6 +1306,18 @@ export function Workspace({ } } async function restore(note: Note) { + if (readOnly) return; + if (library) { + libraryAction.start( + (id) => library.changeTrash(note.id, "restore", note.revision, id), + (result) => { + acceptLibrary(result); + setUndo(result.undo); + setNotice("Note restored"); + }, + ); + return; + } try { await store.restore(note.id, note.revision); setUndo(null); @@ -227,63 +1328,182 @@ export function Workspace({ } } async function readFiles(files: FileList | null) { - if (!files) return; + if (!files || files.length === 0) { + cancelMarkdown(); + return; + } + if (fileReading.current || importPending.current) return; + const ticket = ++importGeneration.current; + fileReading.current = true; + importHeld.current = true; + setImportError(""); + if (importOrigin.current === "setup") setSetupImportState("reading"); try { - const selectedFiles = Array.from(files); - if ( - selectedFiles.some( - (f) => - !/(\.md|\.markdown)$/i.test(f.name) || f.size > 5 * 1024 * 1024, + const parsed = await trackOperation(async () => { + const selectedFiles = Array.from(files); + if (selectedFiles.length > MAX_MARKDOWN_FILES) + throw Error("Import at most 100 files at once."); + if ( + selectedFiles.some( + (file) => + !/[.](md|markdown|mdown)$/i.test(file.name) || + file.size > MAX_MARKDOWN_FILE_BYTES, + ) ) - ) - throw new Error("Choose Markdown files smaller than 5 MB each."); - const content = await Promise.all( - selectedFiles.map(async (f) => ({ - name: f.name, - content: await f.text(), - })), - ); - setImports(parseMarkdown(content)); - } catch (e) { - setError(message(e)); + throw Error("Choose Markdown files up to 2 MB each."); + if ( + selectedFiles.reduce((total, file) => total + file.size, 0) > + MAX_MARKDOWN_BATCH_BYTES + ) + throw Error("Import batches up to 10 MB."); + const content = await Promise.all( + selectedFiles.map(async (file) => ({ + name: file.name, + content: await file.text(), + })), + ); + if (ticket !== importGeneration.current) return null; + return parseMarkdown(content); + }); + if (ticket !== importGeneration.current || !parsed) return; + setImports(parsed.length ? parsed : null); + importHeld.current = parsed.length > 0; + if (importOrigin.current === "setup") + setSetupImportState(parsed.length ? "review" : "idle"); + } catch (failure) { + if (ticket === importGeneration.current) { + importHeld.current = false; + setImportError(message(failure)); + if (importOrigin.current === "setup") setSetupImportState("failed"); + } } finally { - if (fileRef.current) fileRef.current.value = ""; + if (ticket === importGeneration.current) { + fileReading.current = false; + if (fileRef.current) fileRef.current.value = ""; + } } } async function importAll() { - return trackOperation(importAllNotes); - } - async function importAllNotes() { - if (!imports) return; + if ( + !imports || + importPending.current || + fileReading.current || + readOnlyRef.current + ) + return; + const batch = [...imports], + ticket = importGeneration.current, + prior = setupImported; + importPending.current = true; setImporting(true); + setImportError(""); + if (importOrigin.current === "setup") setSetupImportState("running"); let done = 0; try { - for (const input of imports) { - setNotice(`Importing ${done} of ${imports.length} notes…`); - await store.create(input); - done++; - } - setNotice(`Imported ${done} notes`); + await trackOperation(async () => { + for (const input of batch) { + if (ticket !== importGeneration.current) return; + await store.create(input); + if (ticket !== importGeneration.current) return; + done++; + setNotice(`Importing ${done} of ${batch.length} notes…`); + if (importOrigin.current === "setup") setSetupImported(prior + done); + } + }); + if (ticket !== importGeneration.current) return; + importHeld.current = false; setImports(null); - } catch (e) { - setImports(imports.slice(done)); - setError( - `Imported ${done} of ${imports.length} notes. ${imports.length - done} weren’t imported. ${message(e)}`, - ); + setNotice(`Imported ${done} notes`); + if (importOrigin.current === "setup") setSetupImportState("completed"); + } catch (failure) { + if (ticket === importGeneration.current) { + const remaining = batch.slice(done); + setImports(remaining.length ? remaining : null); + importHeld.current = remaining.length > 0; + setImportError( + `Imported ${done} of ${batch.length} notes. ${remaining.length} weren’t imported. ${message(failure)}`, + ); + if (importOrigin.current === "setup") setSetupImportState("failed"); + } } finally { - setImporting(false); - await refresh(); + if (ticket === importGeneration.current) { + importPending.current = false; + setImporting(false); + await refresh(); + } } } - function prepareExport() { - if (!exportNotes) return; + async function prepareExport() { + if (!exportNotes || exportWorking.current) return; + if (sourceOwner && !links.service) { + setError( + "Note links aren’t ready. Wait for them to open, then try exporting again.", + ); + return; + } + exportWorking.current = true; + setExportBusy(true); + const ticket = exportGeneration.current, + selected = [...exportNotes], + archive = archiveExport, + service = links.service; try { - const { blob, name } = buildDownload(exportNotes, archiveExport); + let output: { blob: Blob; name: string }; + if (service) { + const result = await trackOperation(async () => { + const intent = await service.prepareExport( + selected.map((note) => note.id), + ); + const result = await service.exportFiles(intent); + await service.assertFreshExport(result); + return result; + }); + if (ticket !== exportGeneration.current) return; + output = noteLinkDownload(result.files, archive); + exportReceipt.current = { service, result }; + } else { + output = buildDownload(selected, archive); + exportReceipt.current = null; + } + const { blob, name } = output; setDownload({ url: URL.createObjectURL(blob), name }); setExportNotes(null); setNotice("Export prepared"); } catch { - setError("Couldn’t export notes. Your notes were not changed."); + if (ticket === exportGeneration.current) + setError("Couldn’t export notes. Your notes were not changed."); + } finally { + if (ticket === exportGeneration.current) { + exportWorking.current = false; + setExportBusy(false); + } + } + } + async function downloadNotes(event: React.MouseEvent) { + const receipt = exportReceipt.current; + if (!receipt) return; + event.preventDefault(); + if (!download || exportWorking.current) return; + const value = download, + ticket = exportGeneration.current; + exportWorking.current = true; + try { + await trackOperation(() => + receipt.service.assertFreshExport(receipt.result), + ); + if (ticket !== exportGeneration.current) return; + const a = document.createElement("a"); + a.href = value.url; + a.download = value.name; + a.click(); + } catch { + if (ticket === exportGeneration.current) { + setDownload(null); + exportReceipt.current = null; + setError("Notes changed. Prepare a new export before downloading."); + } + } finally { + exportWorking.current = false; } } if (!loaded && !error) @@ -293,7 +1513,11 @@ export function Workspace({ ); return ( -
+
Skip to notes @@ -332,38 +1556,210 @@ export function Workspace({ {session.email}
- + {sourceOwner ? ( + + ) : ( + + )}
- )} - {notice && ( -
- {notice} - {undo && } - {download && ( - - Download {notice === "Export prepared" ? "" : "again"} - - )} - + + {searchActionError && ( +

+ {searchActionError} +

+ )} + {searchOwner.service ? ( + enterSearch(raw, true)} + onTabChange={selectSearchTab} + onClear={clearSearch} + onOpen={(target) => void openSearchTarget(target)} + onPage={(cursor) => + runSearchAction(() => searchOwner.service!.page(cursor)) + } + onContinue={(handle) => + runSearchAction(() => searchOwner.service!.continue(handle)) + } + onCancelPage={() => searchOwner.service?.cancelPage()} + onCancel={cancelSearchWork} + navigationState={searchNavigation} + /> + ) : ( +
+

Search

+

+ {searchOwner.error || sourceOwners.error + ? "Search couldn’t open. Your saved data was not changed." + : "Opening local search…"} +

+ {(searchOwner.error || sourceOwners.error) && ( + + )} + +
+ )} +
+ {!searchActive && searchOrigin && ( +
+
)} - {editing ? ( - - setNotes((v) => [n, ...v.filter((x) => x.id !== n.id)]) - } - onBack={() => setSelected(null)} - onTrash={(n) => void move(n)} - onExport={(n) => requestExport([n])} + + )} + + )} +
void readFiles(e.target.files)} /> + {linkRecoveryOpen && links.service && ( + { + if (linkRecoveryCanClose.current()) setLinkRecoveryOpen(false); + }} + > + { + void openNoteTarget(target); + }} + onClose={() => setLinkRecoveryOpen(false)} + /> + + )} + {setupOpen && ( + chooseMarkdown("setup")} + onComplete={finishSetup} + onClose={finishSetup} + /> + )} + {historyNote && library && ( + { + void openNoteTarget(target); + }, + } + : undefined + } + note={notes.find((note) => note.id === historyNote.id) ?? historyNote} + library={library} + reload={reloadLibrary} + onClose={() => setHistoryNote(null)} + onResult={(result) => { + acceptLibrary(result); + if (result.note) selectedSnapshot.current = result.note; + }} + /> + )} + {emptyConfirm?.mode === "ready" && library && ( + { + if (!libraryAction.busy && !libraryAction.error) + setEmptyConfirm(null); + }} + > +

+ Permanently remove{" "} + { + emptyConfirm.snapshot.notes.filter((note) => note.trashedAt) + .length + }{" "} + notes and their version history? This can’t be undone. Study source + links to these notes will become unavailable. +

+ + + +
+ )} {settings && ( setSettings(false)} + onClose={() => { + if (settingsStudyGuard.current) + settingsStudyGuard.current.requestLeave(() => setSettings(false)); + else setSettings(false); + }} > { + settingsStudyGuard.current = guard; + }} email={session.email} preferences={prefs} onSignOut={() => { @@ -608,6 +2638,8 @@ export function Workspace({ onExport={() => requestExport(notes, true)} onErase={() => setErase(true)} onDone={() => setSettings(false)} + onSetup={() => openSetup("manual")} + setupUnavailable={incomingNoteIntentActive} /> )} @@ -655,32 +2687,53 @@ export function Workspace({ { - if (!importing) setImports(null); + if (!importPending.current) cancelMarkdown(); }} >

{imports.length} files ready. Original files remain untouched.

+ {importError &&

{importError}

} + {readOnly && ( +

+ Import is unavailable while Notes is read-only. You can cancel + without changing the originals. +

+ )}
-
)} {exportNotes && ( - setExportNotes(null)}> + { + if (!exportWorking.current) setExportNotes(null); + }} + >

Exported files are readable copies. Anyone with them can read your - notes. + notes. Notes Markdown export does not include Study, Journal, Clips, + tag colors, pins or version history. Note references preserve local + identity tokens, not access grants. Markdown import does not restore + the note-link graph.

- - +
@@ -689,17 +2742,73 @@ export function Workspace({ { - if (!busy) setErase(false); + if (!busy && !libraryAction.busy && !libraryAction.error) + setErase(false); }} >

- This beta has no synced copy. This permanently removes this - account’s notes and Trash from this browser. Export a copy first. + This permanently removes this account’s notes and Trash, tags, pins + and version history, Study decks, cards and reviews, saved Clips, + and protected Journal entries, habits and recovery drafts from this + device. There is no synced copy. This can’t be undone. Your account + stays signed in.

+

+ Notes Markdown export does not include Study, Journal, Clips, tag + colors, pins or version history. Export Journal, Study and Clips + separately before erasing. Their exports do not provide automatic + restoration. +

+
+ + + -
diff --git a/web/src/ui/localSearchOwner.ts b/web/src/ui/localSearchOwner.ts new file mode 100644 index 0000000..f52b800 --- /dev/null +++ b/web/src/ui/localSearchOwner.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { flushSync } from "react-dom"; +import type { CollectionsStore } from "../collections/contracts"; +import type { NotesLibraryStore } from "../library/contracts"; +import type { SearchInvalidation } from "../search/contracts"; +import type { LocalSearchService, SearchState } from "../search/results"; +const idle: SearchState = Object.freeze({ status: "idle", raw: "" }); +const subscribeEmpty = () => () => {}; +const readEmpty = () => idle; + +/** One genuine account-lifetime facade; no corpus admission or fixture mode. */ +export function useLocalSearchOwner( + owner: NotesLibraryStore | undefined, + collections: CollectionsStore | null, + onInvalidate: (reason: SearchInvalidation) => void, +) { + const [service, setService] = useState(null); + const [error, setError] = useState(false), + [attempt, setAttempt] = useState(0); + const invalidation = useRef(onInvalidate); + invalidation.current = onInvalidate; + useEffect(() => { + setService(null); + setError(false); + if (!owner || !collections) return; + let active = true, + opened: LocalSearchService | undefined, + stop: (() => void) | undefined; + void import("../search/service") + .then(({ createLocalSearchService }) => { + if (!active) return; + opened = createLocalSearchService(owner, collections); + stop = opened.onInvalidate((reason) => { + if (active) flushSync(() => invalidation.current(reason)); + }); + setService(opened); + }) + .catch(() => { + if (active) setError(true); + }); + return () => { + active = false; + stop?.(); + opened?.close(); + }; + }, [owner, collections, attempt]); + const state = useSyncExternalStore( + service?.subscribe ?? subscribeEmpty, + service?.getSnapshot ?? readEmpty, + readEmpty, + ); + return { + service, + state, + error, + retry: () => setAttempt((value) => value + 1), + }; +} diff --git a/web/src/ui/noteLinkDownload.ts b/web/src/ui/noteLinkDownload.ts new file mode 100644 index 0000000..582b16a --- /dev/null +++ b/web/src/ui/noteLinkDownload.ts @@ -0,0 +1,20 @@ +import { zipSync, strToU8 } from "fflate"; +import type { NoteExportFile } from "../links/contracts"; +export function noteLinkDownload( + files: readonly NoteExportFile[], + archive: boolean, +) { + if (!archive && files.length === 1) { + const file = files[0]!; + return { + blob: new Blob([file.content], { type: "text/markdown;charset=utf-8" }), + name: file.filename, + }; + } + const entries: Record = {}; + for (const file of files) entries[file.filename] = strToU8(file.content); + return { + blob: new Blob([zipSync(entries) as BlobPart], { type: "application/zip" }), + name: "orbyn-notes.zip", + }; +} diff --git a/web/src/ui/noteLinkOwner.ts b/web/src/ui/noteLinkOwner.ts new file mode 100644 index 0000000..d2744e3 --- /dev/null +++ b/web/src/ui/noteLinkOwner.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import type { NotesLibraryStore } from "../library/contracts"; +import type { NoteLinkService } from "../links/contracts"; +export function useNoteLinkOwner( + owner: NotesLibraryStore | undefined, + onInvalidate: (reason: "epoch" | "closed") => void, +) { + const [service, setService] = useState(null), + [bootSettled, setBootSettled] = useState(false), + [error, setError] = useState(""), + [attempt, setAttempt] = useState(0); + const invalid = useRef(onInvalidate); + invalid.current = onInvalidate; + useEffect(() => { + if (!owner) return; + let active = true, + opened: NoteLinkService | undefined, + stop: (() => void) | undefined; + setService(null); + setBootSettled(false); + setError(""); + void import("../links/store") + .then(({ createNoteLinkService }) => { + if (!active) return; + opened = createNoteLinkService(owner); + stop = opened.onInvalidate((reason) => { + if (active) + flushSync(() => { + setService(null); + invalid.current(reason); + if (reason === "epoch") setAttempt((value) => value + 1); + }); + }); + setService(opened); + // One bounded startup allowance; paused work resumes only by deliberate action. + void opened + .verifyGraph() + .catch(() => {}) + .finally(() => { + if (active) setBootSettled(true); + }); + }) + .catch(() => { + if (active) + setError("Note links couldn’t open. Your notes were not changed."); + if (active) setBootSettled(true); + }); + return () => { + active = false; + stop?.(); + opened?.close(); + }; + }, [owner, attempt]); + return { + service, + error, + bootSettled, + retry: () => setAttempt((value) => value + 1), + }; +} diff --git a/web/src/ui/noteLinks/IncomingNoteLink.tsx b/web/src/ui/noteLinks/IncomingNoteLink.tsx new file mode 100644 index 0000000..9610a82 --- /dev/null +++ b/web/src/ui/noteLinks/IncomingNoteLink.tsx @@ -0,0 +1,55 @@ +import "../../styles/incoming-note-link.css"; + +export type IncomingNoteLinkState = + "opening" | "blocked" | "unavailable" | "invalid" | "service-error"; +export type IncomingNoteLinkProps = { + state: IncomingNoteLinkState; + onRetry: () => void; + onDismiss: () => void; +}; + +/** Presentation only. The parent owns the bounded identity intent and guarded + * navigator. Retrying must synchronously admit an opening state in that owner; + * this component never resolves a target or retries after a state change. + * Opening has no Dismiss action: hiding a notice cannot cancel an already + * admitted Workspace navigation through the current navigator contract. */ +export function IncomingNoteLink({ + state, + onRetry, + onDismiss, +}: IncomingNoteLinkProps) { + const message = { + opening: "Opening note…", + blocked: "Finish your current work before opening this note.", + unavailable: "This note isn’t available in this workspace.", + invalid: "This note link isn’t valid.", + "service-error": + "Note links couldn’t connect. Reconnect, then open the link again.", + }[state]; + return ( +
+

{message}

+ {state !== "opening" && ( +
+ {(state === "blocked" || + state === "unavailable" || + state === "service-error") && ( + + )} + +
+ )} +
+ ); +} diff --git a/web/src/ui/studySourceOwners.ts b/web/src/ui/studySourceOwners.ts new file mode 100644 index 0000000..9911e18 --- /dev/null +++ b/web/src/ui/studySourceOwners.ts @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import type { AccountSession } from "../domain/contracts"; +import type { NotesLibraryStore } from "../library/contracts"; +import type { CollectionsStore } from "../collections/contracts"; +import type { StudySourceService } from "../study/sourceContracts"; +import { trackOperation } from "../desktop/closeGuard"; +/** Genuine account owners, shared by Notes and the collections host. Fixtures may omit the owner. */ +export function useStudySourceOwners( + owner: NotesLibraryStore | undefined, + session: AccountSession, + onInvalidate: () => void, +) { + const [collections, setCollections] = useState(null), + [service, setService] = useState(null), + [bootSettled, setBootSettled] = useState(false), + [error, setError] = useState(""), + [attempt, setAttempt] = useState(0); + const invalidate = useRef(onInvalidate); + const bootOperations = useRef(0), + mounted = useRef(false); + invalidate.current = onInvalidate; + useEffect(() => { + if (!owner) return; + mounted.current = true; + bootOperations.current++; + let active = true, + openedCollections: CollectionsStore | undefined, + openedService: StudySourceService | undefined, + stop: (() => void) | undefined; + setCollections(null); + setBootSettled(false); + setService(null); + setError(""); + void trackOperation(async () => { + const [{ openCollectionsStore }, { createStudySourceService }] = + await Promise.all([ + import("../collections/store"), + import("../study/sourceStore"), + ]); + const collections = await openCollectionsStore(session); + openedCollections = collections; + if (!active) { + collections.close(); + return; + } + const service = createStudySourceService(owner, collections); + openedService = service; + stop = service.onInvalidate(() => { + if (active) flushSync(() => invalidate.current()); + }); + if (active) { + setCollections(collections); + setService(service); + } else { + stop(); + service.close(); + collections.close(); + } + }) + .catch(() => { + openedService?.close(); + openedCollections?.close(); + if (active) + setError( + "Study links couldn’t open. Your saved data was not changed.", + ); + }) + .finally(() => { + bootOperations.current--; + if (mounted.current && bootOperations.current === 0) + setBootSettled(true); + }); + return () => { + mounted.current = false; + active = false; + stop?.(); + openedService?.close(); + openedCollections?.close(); + }; + }, [owner, session.accountId, session.deviceId, session.email, attempt]); + return { + collections, + service, + bootSettled, + error, + retry: () => setAttempt((value) => value + 1), + }; +} diff --git a/web/tests/ui/incoming-note-link.test.tsx b/web/tests/ui/incoming-note-link.test.tsx new file mode 100644 index 0000000..698e47e --- /dev/null +++ b/web/tests/ui/incoming-note-link.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { IncomingNoteLink } from "../../src/ui/noteLinks/IncomingNoteLink"; +it("announces opening without offering a false cancellation or automatic retry", () => { + const retry = vi.fn(), + dismiss = vi.fn(); + const view = render( + , + ); + expect( + screen.getByRole("status", { name: "Incoming note link" }), + ).toHaveTextContent("Opening note…"); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + view.rerender( + , + ); + expect(retry).not.toHaveBeenCalled(); + expect(dismiss).not.toHaveBeenCalled(); + expect(screen.getByRole("status")).toHaveTextContent( + "Finish your current work before opening this note.", + ); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(retry).toHaveBeenCalledTimes(1); + expect(dismiss).not.toHaveBeenCalled(); +}); +it.each(["blocked", "unavailable"] as const)( + "offers explicit retry and dismissal only for settled %s state", + (state) => { + const retry = vi.fn(), + dismiss = vi.fn(); + render( + , + ); + const notice = screen.getByRole("status", { name: "Incoming note link" }); + expect(within(notice).getAllByRole("button")).toHaveLength(2); + fireEvent.click(within(notice).getByRole("button", { name: "Dismiss" })); + expect(dismiss).toHaveBeenCalledTimes(1); + expect(retry).not.toHaveBeenCalled(); + if (state === "unavailable") + expect(notice).toHaveTextContent( + "This note isn’t available in this workspace.", + ); + }, +); +it("keeps malformed ingress content out of the notice and provides no invalid-link retry", () => { + const retry = vi.fn(), + dismiss = vi.fn(); + render( + , + ); + const notice = screen.getByRole("alert", { name: "Incoming note link" }); + expect(notice).toHaveTextContent("This note link isn’t valid."); + expect( + screen.queryByRole("button", { name: "Try again" }), + ).not.toBeInTheDocument(); + expect(notice).not.toHaveTextContent( + /orbyn-notes:\/\/|accountId|libraryEpoch|noteId/, + ); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(dismiss).toHaveBeenCalledTimes(1); + expect(retry).not.toHaveBeenCalled(); +}); + +it("offers deliberate transport reconnect without promising to reopen a previous note", () => { + const retry = vi.fn(), + dismiss = vi.fn(); + render( + , + ); + const notice = screen.getByRole("alert", { name: "Incoming note link" }); + expect(notice).toHaveTextContent( + "Note links couldn’t connect. Reconnect, then open the link again.", + ); + expect( + screen.queryByRole("button", { name: "Try again" }), + ).not.toBeInTheDocument(); + expect(retry).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Reconnect" })); + expect(retry).toHaveBeenCalledTimes(1); + expect(dismiss).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(dismiss).toHaveBeenCalledTimes(1); + expect(notice).not.toHaveTextContent( + /orbyn-notes:\/\/|accountId|libraryEpoch|noteId/, + ); +}); diff --git a/web/tests/ui/loading.test.tsx b/web/tests/ui/loading.test.tsx index a84143a..173d059 100644 --- a/web/tests/ui/loading.test.tsx +++ b/web/tests/ui/loading.test.tsx @@ -49,5 +49,5 @@ it("does not paint provisional zero counts or empty tag navigation while initial ]), ); expect(screen.getByRole("button", { name: "All notes 1" })).toBeVisible(); - expect(screen.getByRole("button", { name: /^# Work\s*1$/ })).toBeVisible(); + expect(screen.getByRole("button", { name: "Work, 1 note" })).toBeVisible(); }); diff --git a/web/tests/ui/native-note-entry.test.tsx b/web/tests/ui/native-note-entry.test.tsx new file mode 100644 index 0000000..c203647 --- /dev/null +++ b/web/tests/ui/native-note-entry.test.tsx @@ -0,0 +1,188 @@ +import { StrictMode, useEffect } from "react"; +import { + act, + fireEvent, + render, + renderHook, + screen, + waitFor, +} from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import type { NoteTarget } from "../../src/links/contracts"; +import { useIncomingNoteLink } from "../../src/links/useIncomingNoteLink"; +import { encodeNoteReference } from "../../src/links/reference"; + +const native = vi.hoisted(() => ({ + start: vi.fn(), + retire: vi.fn(), + handoff: null as null | ((target: NoteTarget, sequence: number) => boolean), + invalid: null as null | (() => void), + error: null as null | (() => void), +})); +vi.mock("../../src/desktop/noteLinks", () => ({ + nativeNoteLinkIngress: native, +})); +const target: NoteTarget = { + kind: "note", + accountId: "11111111-1111-4111-8111-111111111111", + libraryEpoch: "22222222-2222-4222-8222-222222222222", + noteId: "native", +}; +const owner = {}; +const navigate = vi.fn(); +function Harness({ desktop = true }: { desktop?: boolean }) { + const incoming = useIncomingNoteLink(owner, desktop); + useEffect(() => { + incoming.register(navigate); + return () => incoming.register(null); + }, [incoming.register]); + return ( + <> +

{incoming.state ?? "none"}

+ + + +

{incoming.format ? "Web format" : "Local reference fallback"}

+ + ); +} +beforeEach(() => { + native.start + .mockReset() + .mockImplementation(async (handoff, invalid, error) => { + native.handoff = handoff; + native.invalid = invalid; + native.error = error; + }); + native.retire.mockReset().mockResolvedValue(undefined); + native.handoff = null; + native.invalid = null; + native.error = null; + navigate.mockReset().mockResolvedValue({ status: "opened" }); + window.history.replaceState(null, "", "/"); +}); +it("notifies React about a valid pre-workspace intent whose visible status stays null", () => { + const hash = "#note/v1/" + encodeNoteReference(target).split("/v1/")[1]; + window.history.replaceState(null, "", "/" + hash); + const { result } = renderHook(() => useIncomingNoteLink(null, false)); + expect(result.current.state).toBe(null); + expect(result.current.hasPending).toBe(true); + act(() => result.current.revoke()); + expect(result.current.hasPending).toBe(false); +}); +it("hands native targets to the shared navigator and keeps local-reference copy until association is qualified", async () => { + render( + + + , + ); + await waitFor(() => expect(native.handoff).not.toBeNull()); + // The StrictMode second setup uses the same consumer and must not retire the + // startup pending URI. At most a previous test's distinct consumer retires. + expect(native.retire.mock.calls.length).toBeLessThanOrEqual(1); + act(() => { + expect(native.handoff?.(target, 1)).toBe(true); + }); + await waitFor(() => + expect(navigate).toHaveBeenCalledExactlyOnceWith( + target, + expect.any(AbortSignal), + ), + ); + expect(screen.getByText("Local reference fallback")).toBeVisible(); +}); +it("does not start native IPC in the web app", async () => { + render(); + await act(async () => {}); + expect(native.start).not.toHaveBeenCalled(); + expect(native.retire).not.toHaveBeenCalled(); + expect(screen.getByText("Web format")).toBeVisible(); +}); +it.each(["opened", "blocked"] as const)( + "clears an older transport error when a newer activation is %s", + async (status) => { + navigate.mockResolvedValue({ status }); + render(); + await waitFor(() => expect(native.handoff).not.toBeNull()); + act(() => native.error?.()); + expect(screen.getByRole("status")).toHaveTextContent("service-error"); + act(() => { + native.handoff?.(target, 10); + }); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + status === "opened" ? "none" : "blocked", + ), + ); + act(() => native.error?.()); + act(() => native.invalid?.()); + expect(screen.getByRole("status")).toHaveTextContent("invalid"); + }, +); +it("ignores an older connection failure after an explicit reconnect succeeds", async () => { + let rejectOld!: (error: Error) => void; + native.start.mockImplementationOnce((handoff, invalid, error) => { + native.handoff = handoff; + native.invalid = invalid; + native.error = error; + return new Promise((_resolve, reject) => { + rejectOld = reject; + }); + }); + render(); + await waitFor(() => expect(native.error).not.toBeNull()); + act(() => native.error?.()); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("none"), + ); + await act(async () => { + rejectOld(new Error("Older listener failed")); + }); + expect(screen.getByRole("status")).toHaveTextContent("none"); +}); +it("retires native pending delivery synchronously on active scope revocation and does not retry blocked notes", async () => { + navigate.mockResolvedValue({ status: "blocked" }); + render(); + await waitFor(() => expect(native.handoff).not.toBeNull()); + act(() => { + native.handoff?.(target, 2); + }); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("blocked"), + ); + const before = native.retire.mock.calls.length; + fireEvent.click(screen.getByRole("button", { name: "Revoke" })); + expect(native.retire.mock.calls.length).toBe(before + 1); + expect(screen.getByRole("status")).toHaveTextContent("none"); + await act(async () => {}); + expect(navigate).toHaveBeenCalledTimes(1); +}); +it("shows invalid versus transport failure separately and reconnects without reopening a stale target", async () => { + render(); + await waitFor(() => expect(native.handoff).not.toBeNull()); + act(() => native.invalid?.()); + expect(screen.getByRole("status")).toHaveTextContent("invalid"); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + act(() => native.error?.()); + expect(screen.getByRole("status")).toHaveTextContent("service-error"); + const before = native.retire.mock.calls.length; + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("none"), + ); + expect(native.retire.mock.calls.length).toBe(before + 1); + expect(navigate).not.toHaveBeenCalled(); +}); +it("rejects delivery to an unmounted consumer and retires before handing a pending URI to a different consumer", async () => { + const first = render(); + await waitFor(() => expect(native.handoff).not.toBeNull()); + const oldHandoff = native.handoff!; + first.unmount(); + expect(oldHandoff(target, 3)).toBe(false); + const before = native.retire.mock.calls.length; + render(); + await waitFor(() => expect(native.retire.mock.calls.length).toBe(before + 1)); + expect(navigate).not.toHaveBeenCalled(); +}); diff --git a/web/tests/ui/session.test.tsx b/web/tests/ui/session.test.tsx index ea91f03..b705733 100644 --- a/web/tests/ui/session.test.tsx +++ b/web/tests/ui/session.test.tsx @@ -1,7 +1,8 @@ -import { render, screen, act } from "@testing-library/react"; +import { render, screen, act, fireEvent, waitFor } from "@testing-library/react"; import { beforeEach, describe, it, expect, vi } from "vitest"; import type { AccountSession } from "../../src/domain/contracts"; const control = vi.hoisted(() => ({ + signOut: vi.fn(), lock: null as null | (() => void), session: { accountId: "a", @@ -11,7 +12,7 @@ const control = vi.hoisted(() => ({ } as AccountSession | null, })); vi.mock("../../src/account", () => ({ - createAccountService: () => ({ current: async () => control.session }), + createAccountService: () => ({ current: async () => control.session, signOut: control.signOut }), subscribeLock: (listener: () => void) => { control.lock = listener; return () => { @@ -23,7 +24,7 @@ vi.mock("../../src/data", () => ({ openNotesStore: async () => ({ close: vi.fn() }), })); vi.mock("../../src/ui/Workspace", () => ({ - Workspace: () =>

Private library contents

, + Workspace: ({ onSignOut }: { onSignOut: () => Promise }) => <>

Private library contents

, })); vi.mock("../../src/ui/AccountView", () => ({ AccountView: () =>

Signed out entry

, @@ -31,6 +32,7 @@ vi.mock("../../src/ui/AccountView", () => ({ })); import { App } from "../../src/App"; beforeEach(() => { + control.signOut.mockReset().mockResolvedValue(undefined); vi.stubEnv("VITE_ENGINE_URL", "http://127.0.0.1:8089"); vi.stubGlobal("matchMedia", () => ({ matches: false, @@ -45,6 +47,16 @@ beforeEach(() => { }; }); describe("session boundaries", () => { + it("keeps the mounted private workspace when sign-out fails before revocation", async () => { + control.signOut.mockRejectedValue(new Error("synthetic network failure")); + render(); + await screen.findByText("Private library contents"); + fireEvent.click(screen.getByRole("button", { name: "Fixture sign out" })); + await waitFor(() => expect(control.signOut).toHaveBeenCalledOnce()); + await act(async () => {}); + expect(screen.getByText("Private library contents")).toBeVisible(); + expect(screen.queryByText("Notes can’t open yet")).not.toBeInTheDocument(); + }); it("removes decrypted UI immediately on a cross-tab lock", async () => { render(); await screen.findByText("Private library contents");