Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 108 additions & 42 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,56 @@
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 <DesktopShell><AppContent /></DesktopShell>;
return (
<DesktopShell>
<AppContent />
</DesktopShell>
);
}
function AppContent() {
usePreferences();
const [service, setService] = useState<AccountService | null>(null);
const [session, setSession] = useState<AccountSession | null>(null);
const [store, setStore] = useState<NotesStore | null>(null);
const [store, setStore] = useState<NotesLibraryStore | null>(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;
setLoading(true);
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()
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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 <Loading />;
const incomingNotice = incoming.state ? (
<IncomingNoteLink
state={incoming.state}
onRetry={incoming.retry}
onDismiss={incoming.dismiss}
/>
) : 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}
<Loading />
</>
);
if (error)
return (
<main className="account-page">
<section className="account-card">
<h1>Notes can’t open yet</h1>
<p role="alert">{error}</p>
<button className="primary" onClick={() => setAttempt((v) => v + 1)}>
Try again
</button>
{session && <button onClick={() => void signOut()}>Sign out</button>}
</section>
</main>
<>
{publicNotice}
<main className="account-page">
<section className="account-card">
<h1>Notes can’t open yet</h1>
<p role="alert">{error}</p>
<button
className="primary"
onClick={() => setAttempt((v) => v + 1)}
>
Try again
</button>
{session && (
<button onClick={() => void signOut()}>Sign out</button>
)}
</section>
</main>
</>
);
if (!service) return publicNotice;
if (!session)
return (
<>
{publicNotice}
<AccountView service={service} onSession={setSession} />
</>
);
if (!service) return null;
if (!session) return <AccountView service={service} onSession={setSession} />;
if (session.deviceState === "pending")
return (
<PendingDevice
service={service}
onSession={setSession}
onSignOut={() => void signOut()}
/>
<>
{publicNotice}
<PendingDevice
service={service}
onSession={setSession}
onSignOut={() => void signOut()}
/>
</>
);
if (!store) return null;
if (!store) return publicNotice;
return (
<Suspense fallback={<Loading />}>
<Workspace
key={session.accountId}
store={store}
session={session}
onSignOut={() => signOut(true)}
/>
</Suspense>
<div className="notes-entry-layout">
{incomingNotice}
<Suspense fallback={<Loading />}>
<Workspace
key={session.accountId}
store={store}
library={store.library}
sourceOwner={store}
onNoteLinkNavigatorReady={incoming.register}
onNoteLinkScopeRevoked={incoming.revoke}
incomingNoteIntentActive={incoming.hasPending || incoming.state !== null}
formatNoteLink={incoming.format}
session={session}
onSignOut={() => signOut(true)}
/>
</Suspense>
</div>
);
}
function Loading() {
Expand Down
161 changes: 161 additions & 0 deletions web/src/links/useIncomingNoteLink.ts
Original file line number Diff line number Diff line change
@@ -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<void>) | null>(null);
const reconnecting = useRef(false);
const controllerRef = useRef<IncomingNoteLinkController | null>(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<NoteLinkCopyFormatter>(
(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,
};
}
Loading
Loading