Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/reader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
},
"dependencies": {
"@citation-js/plugin-csl": "0.8.2",
"@mdbase-dev/sdk": "file:../../vendor/mdbase-dev-sdk-0.0.0-9ab0f31d.tgz",
"@mdbase-dev/ui": "0.1.0-beta.125",
"@mdbase-reader/connect": "workspace:*",
"@mdbase-reader/core": "workspace:*",
Expand Down
14 changes: 13 additions & 1 deletion apps/reader/src/ConnectReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ import {
import { ConnectionRetry, SelectedAuthorizationAction } from "./ConnectionLayout.js";
import { readerErrorMessage } from "./errors.js";
import { ChevronRightIcon, CollectionIcon, PlusIcon } from "./icons.js";
import { NativeSignIn } from "./NativeSignIn.js";
import { requestedAnnotation, requestedSourceId } from "./SourceDeepLink.js";
import { startReaderStartupTiming } from "./startup-timing.js";

import type { NativeSignInBinding } from "./native-signin-ui.js";
import type { ReaderApp as ReaderAppComponent } from "./ReaderApp.js";
const loadReaderApp = (): Promise<{ readonly ReaderApp: typeof ReaderAppComponent }> =>
import("./ReaderApp.js");
Expand All @@ -54,7 +56,17 @@ async function startSession(): Promise<Awaited<ReturnType<typeof readerSession.s
}
}

export function ConnectReader(): JSX.Element {
export function ConnectReader({
nativeSignIn,
}: {
nativeSignIn?: { binding: NativeSignInBinding; onSignedOut(): void };
} = {}): JSX.Element {
// Qualified native owner composition supplies this SDK binding; the existing
// runtime remains the default until its actual build inputs are delivered.
return nativeSignIn ? <NativeSignIn {...nativeSignIn} /> : <LegacyConnectReader />;
}

function LegacyConnectReader(): JSX.Element {
const { reportError } = useFeedback();
const session = useSyncExternalStore(subscribe, snapshot, snapshot);
const [error, setError] = useState<string | null>(null);
Expand Down
165 changes: 165 additions & 0 deletions apps/reader/src/NativeSignIn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { ConnectLayout } from "@mdbase-dev/ui/screens";
import { useEffect, useRef, useState, useSyncExternalStore, type JSX } from "react";

import {
SignInTabBlocked,
authorizeNativeSignIn,
signInCopy,
signInPresentation,
type createNativeSignIn,
} from "./native-signin-ui.js";

interface NativeSignInProps {
/** Actual SDK instance plus its UI tab reservation; caller owns the lifetime. */
binding: ReturnType<typeof createNativeSignIn>;
/** Close succeeded. Owner may show signed-out UI; original stores stay. */
onSignedOut: () => void;
}

/** Presentation/actions only: no polling, custody, grants or session engine. */
export function NativeSignIn(props: NativeSignInProps): JSX.Element {
return <NativeSignInView key={props.binding.viewKey} {...props} />;
}

function observeForeground(
session: NativeSignInProps["binding"]["session"],
onError: () => void,
): () => void {
const foreground = (): void => {
if (session.getSnapshot().status === "closed") {
return;
}
try {
session.setForeground(document.visibilityState === "visible");
} catch {
onError();
}
};
document.addEventListener("visibilitychange", foreground);
if (session.getSnapshot().status !== "closed") {
try {
session.setForeground(document.visibilityState === "visible");
} catch {
/* Retired owner. The next explicit action reports its failure. */
}
}
return () => {
document.removeEventListener("visibilitychange", foreground);
};
}

function NativeSignInView({ binding, onSignedOut }: NativeSignInProps): JSX.Element {
const { session, portal } = binding;
const snapshot = useSyncExternalStore(
(listener) => session.subscribe(listener),
() => session.getSnapshot(),
() => session.getSnapshot(),
);
const [problem, setProblem] = useState<"tab_blocked" | "interrupted" | null>(null);
const [working, setWorking] = useState(false);
const busy = useRef(false);
const mounted = useRef(false);

useEffect(() => {
mounted.current = true;
const stop = observeForeground(session, () => {
if (mounted.current) {
setProblem("interrupted");
}
});
return () => {
mounted.current = false;
stop();
// Borrowed SDK session: its actual owner handles unmount/HMR shutdown.
};
}, [session]);

const act = async (action: () => Promise<unknown>): Promise<void> => {
if (busy.current) {
return;
}
busy.current = true;
setWorking(true);
setProblem(null);
try {
await action();
} catch (reason) {
if (mounted.current) {
setProblem(reason instanceof SignInTabBlocked ? "tab_blocked" : "interrupted");
}
} finally {
if (mounted.current) {
busy.current = false;
setWorking(false);
}
}
};
const view = signInPresentation(snapshot, "mdbase Reader", problem);
const signOut = async (): Promise<void> => {
await session.close();
portal.cancel();
if (mounted.current) {
onSignedOut();
}
};
const authorize = (): ReturnType<typeof authorizeNativeSignIn> =>
authorizeNativeSignIn(session, portal);

return (
<ConnectLayout app="reader" title="Read from your collection">
<section aria-label="Sign-in status">
{view.identity ? <p>{view.identity}</p> : null}
<p role={view.error ? "alert" : "status"} aria-live="polite">
{view.message}
</p>
{view.unavailable ? <p>{signInCopy.unavailable}</p> : null}
<div className="connection-actions">
{view.waiting ? (
<button
className="text-action"
type="button"
disabled={working}
onClick={() => void act(authorize)}
>
{signInCopy.reopen}
</button>
) : !view.paired || view.error ? (
<button
className="connection-connect"
type="button"
disabled={working || view.disabled}
onClick={() => void act(authorize)}
>
{view.action}
</button>
) : null}
{view.paired ? (
<button
className="text-action"
type="button"
disabled={working}
onClick={() => void act(signOut)}
>
{signInCopy.signOut}
</button>
) : null}
{snapshot.selectedCollectionId ? (
<button
className="text-action"
type="button"
disabled={working}
onClick={() => {
const collectionId = snapshot.selectedCollectionId;
if (collectionId) {
void act(() => session.forget(collectionId));
}
}}
>
Forget collection
</button>
) : null}
</div>
</section>
</ConnectLayout>
);
}
158 changes: 158 additions & 0 deletions apps/reader/src/native-signin-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from "vitest";

import {
openSignInPortal,
SignInTabBlocked,
signInCopy,
signInPresentation,
unavailableCollection,
} from "./native-signin-ui";

import type { AppWebSignInSnapshot } from "@mdbase-dev/sdk/app-host";

function snapshot(patch: Partial<AppWebSignInSnapshot> = {}): AppWebSignInSnapshot {
return {
status: "signed_out",
signIn: null,
collections: [],
selectedCollectionId: null,
problem: null,
timing: null,
...patch,
};
}
const paired: NonNullable<AppWebSignInSnapshot["signIn"]> = {
requestId: "request",
installationId: "installation",
deviceId: "device",
appId: "app",
kind: "app-runtime",
verificationUri: "https://mdbase.test/approve",
state: "paired",
accountId: "account",
accountEmail: "writer@example.test",
collectionIds: [],
createCollections: false,
requestedCreateCollections: false,
};

describe("native sign-in presentation, not an authentication engine", () => {
it("uses canonical signed-out wording", () => {
expect(signInPresentation(snapshot(), "TaskNotes Planner").message).toBe(
"Sign in with your mdbase account to sync TaskNotes Planner across your devices.",
);
});
it("renders SDK authorizing without introducing an app poller", () => {
const view = signInPresentation(snapshot({ status: "authorizing" }), "Planner");
expect(view.message).toBe("Waiting for approval in the mdbase tab…");
expect(view.waiting).toBe(true);
});
it("shows email only from a paired SDK view", () => {
expect(
signInPresentation(snapshot({ status: "unselected", signIn: paired }), "Planner").message,
).toBe("Signed in as writer@example.test");
expect(
signInPresentation(
snapshot({
status: "authorizing",
signIn: { ...paired, state: "account_selected" },
}),
"Planner",
).identity,
).toBeNull();
});
it("does not invent an email or use account IDs as labels", () => {
const view = signInPresentation(
snapshot({
status: "unselected",
signIn: { ...paired, accountEmail: null },
}),
"Planner",
);
expect(view.identity).toBeNull();
expect(view.message).toBe(signInCopy.unavailable);
expect(view.message).not.toContain(paired.accountId);
});
it("uses actual selected collection metadata for opening", () => {
const view = signInPresentation(
snapshot({
status: "opening",
signIn: paired,
selectedCollectionId: "selected",
collections: [{ collectionId: "selected", displayName: "Research", role: "owner" }],
}),
"Planner",
);
expect(view.message).toBe("Signed in. Opening Research…");
});
it("refuses to invent a collection name from an identifier", () => {
const view = signInPresentation(
snapshot({ status: "opening", selectedCollectionId: "private-id" }),
"Planner",
);
expect(view.message).not.toContain("private-id");
});
it("does not mistake a generic refusal for the user declining", () => {
expect(
signInPresentation(snapshot({ status: "blocked", problem: "refused" }), "Planner").message,
).toBe(signInCopy.interrupted);
});
it("does not imply saved/sync or reminders capability from authorization", () => {
const view = signInPresentation(snapshot({ status: "unselected", signIn: paired }), "Planner");
expect(JSON.stringify(view)).not.toMatch(
/saved on this device|reminders|Set up|Continue setup/,
);
expect(view.unavailable).toBe(true);
});
it("clears visible identity after SDK closure", () => {
const view = signInPresentation(snapshot({ status: "closed", signIn: paired }), "Planner");
expect(view.paired).toBe(false);
expect(view.identity).toBeNull();
});
it("has a distinct blocked-tab action without rendering raw errors", () => {
const view = signInPresentation(snapshot(), "Planner", "tab_blocked");
expect(view.message).toBe("Your browser blocked the sign-in tab.");
expect(view.action).toBe("Open sign-in");
});
it("opens a tab in the UI gesture, detaches the opener, and delegates only navigation", () => {
const tab = { opener: {}, location: { replace: vi.fn() }, close: vi.fn() };
vi.stubGlobal("window", { open: vi.fn(() => tab) });
try {
const portal = openSignInPortal();
expect(window.open).toHaveBeenCalledWith("about:blank", "_blank");
expect(tab.opener).toBeNull();
portal.navigate("https://mdbase.test/approve");
expect(tab.location.replace).toHaveBeenCalledWith("https://mdbase.test/approve");
portal.close();
expect(tab.close).toHaveBeenCalledOnce();
} finally {
vi.unstubAllGlobals();
}
});
it("distinguishes an actually blocked tab", () => {
vi.stubGlobal("window", { open: () => null });
try {
expect(openSignInPortal).toThrow(SignInTabBlocked);
} finally {
vi.unstubAllGlobals();
}
});
it("the unavailable collection callback never manufactures a connection", async () => {
// Only signal is observed; fake host/scope/collection data is not consumed.
await expect(
unavailableCollection({
signal: new AbortController().signal,
} as Parameters<typeof unavailableCollection>[0]),
).rejects.toThrow(signInCopy.unavailable);
});
it("preserves original cancellation before the unavailable boundary", async () => {
const controller = new AbortController();
const reason = new Error("original cancelled");
controller.abort(reason);
await expect(
unavailableCollection({ signal: controller.signal } as Parameters<
typeof unavailableCollection
>[0]),
).rejects.toBe(reason);
});
});
Loading