From c0912fcd41e9f701c3b2e4d2bf1c01f5d4b3337d Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 16:20:27 -0500
Subject: [PATCH 01/11] Serve a share link's revisions to the canvas
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
packages/review/src/sharing/host.ts | 107 +++++++++++------
packages/review/src/sharing/publish.test.ts | 121 ++++++++++++++++++++
2 files changed, 191 insertions(+), 37 deletions(-)
diff --git a/packages/review/src/sharing/host.ts b/packages/review/src/sharing/host.ts
index 48eec35377..27364fafc2 100644
--- a/packages/review/src/sharing/host.ts
+++ b/packages/review/src/sharing/host.ts
@@ -401,6 +401,40 @@ export function remoteShare(
: undefined;
}
+const appUrl = (href: string) => {
+ const url = new URL(href);
+
+ if (
+ process.env.DEV_FAST_REVIEW_APP_URL_PROTOCOL === "dev-fast-review-preview"
+ )
+ url.searchParams.set("app", "preview");
+
+ return url.href;
+};
+
+const shareRequest = async (
+ request: () => Promise,
+ message: string,
+): Promise => {
+ try {
+ return await request();
+ } catch (error) {
+ if (error instanceof ReviewInputError) throw error;
+
+ if (error instanceof ShareAuthError) {
+ // A CI token lives in the environment; only the saved login can go stale.
+ if (process.env.DEV_REVIEW_SHARE_TOKEN === undefined)
+ await clearStoreAuth();
+ throw new ReviewInputError(
+ "Your sign-in has expired. Sign in again to share.",
+ 401,
+ );
+ }
+
+ throw new ReviewInputError(message, 409);
+ }
+};
+
/** Publishing needs only an authored store, never Desktop or recipient workspaces. */
export function mountSharingPublisher(
app: Hono,
@@ -498,49 +532,48 @@ export function mountSharingPublisher(
const { bundle, version } = await prepare(input);
- try {
- const result = await new ShareClient(
- account.origin,
- account.token,
- options.fetch,
- ).create(bundle, input.requestId ?? randomUUID());
-
- const url = new URL(result.url);
-
- if (
- process.env.DEV_FAST_REVIEW_APP_URL_PROTOCOL ===
- "dev-fast-review-preview"
- )
- url.searchParams.set("app", "preview");
-
- options.onPublished?.({
- reviewId: input.reviewId,
- version,
- });
+ const result = await shareRequest(
+ () =>
+ new ShareClient(account.origin, account.token, options.fetch).create(
+ bundle,
+ input.requestId ?? randomUUID(),
+ ),
+ "Sharing failed. Check your connection and login, then retry.",
+ );
- return context.json({
- ...result,
- url: url.href,
- version,
- });
- } catch (error) {
- if (error instanceof ReviewInputError) throw error;
+ options.onPublished?.({
+ reviewId: input.reviewId,
+ version,
+ });
- if (error instanceof ShareAuthError) {
- // A CI token lives in the environment; only the saved login can go stale.
- if (process.env.DEV_REVIEW_SHARE_TOKEN === undefined)
- await clearStoreAuth();
- throw new ReviewInputError(
- "Your sign-in has expired. Sign in again to share.",
- 401,
- );
- }
+ return context.json({
+ ...result,
+ url: appUrl(result.url),
+ version,
+ });
+ });
+ app.get("/sharing/history/:shareId", async (context) => {
+ const shareId = shareIdSchema.parse(context.req.param("shareId"));
+ const account = await readSharingAuth();
+ if (!account)
throw new ReviewInputError(
- "Sharing failed. Check your connection and login, then retry.",
+ "Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login first.",
409,
);
- }
+
+ const history = await shareRequest(
+ () =>
+ new ShareClient(account.origin, account.token, options.fetch).revisions(
+ shareId,
+ ),
+ "Could not load this link's history. Check your connection, then retry.",
+ );
+
+ return context.json({
+ ...history,
+ items: history.items.map((item) => ({ ...item, url: appUrl(item.url) })),
+ });
});
app.post("/sharing/revoke", async (context) => {
const { shareId } = z
diff --git a/packages/review/src/sharing/publish.test.ts b/packages/review/src/sharing/publish.test.ts
index 4b755b3bcb..3de80a947b 100644
--- a/packages/review/src/sharing/publish.test.ts
+++ b/packages/review/src/sharing/publish.test.ts
@@ -125,3 +125,124 @@ it("signs the user out when the share service rejects the stored token", async (
await rm(root, { recursive: true, force: true });
}
});
+
+const historyHost = async (
+ root: string,
+ fetch: typeof globalThis.fetch,
+ token = "fixture",
+) => {
+ const fixture = await createShareFixture(root);
+ await writeStoreAuth({
+ origin: "https://app.dev.fast",
+ token,
+ login: "fixture",
+ savedAt: new Date().toISOString(),
+ });
+ const api = new Hono();
+ api.onError((error, context) =>
+ context.json(
+ { error: error.message },
+ error instanceof ReviewInputError ? error.status : 500,
+ ),
+ );
+ mountSharingHost(api, fixture.store, fixture.data, { fetch });
+
+ return { api, fixture };
+};
+
+it.each(["dev-fast-review", "dev-fast-review-preview"])(
+ "lists a link's revisions newest first (%s)",
+ async (protocol) => {
+ const root = await mkdtemp(path.join(tmpdir(), "sharing-history-"));
+ vi.stubEnv("DEV_REVIEW_HOME", root);
+ vi.stubEnv("DEV_FAST_REVIEW_APP_URL_PROTOCOL", protocol);
+ const preview = protocol === "dev-fast-review-preview";
+ const [shareId, R1, R2] = [randomUUID(), randomUUID(), randomUUID()];
+ const link = `https://app.dev.fast/s/${"x".repeat(43)}`;
+ const requests: string[] = [];
+
+ const { api, fixture } = await historyHost(root, async (input, init) => {
+ const url = new URL(String(input));
+
+ if (url.pathname === "/api/auth/token")
+ return new Response(null, { status: 404 });
+ requests.push(`${init?.method ?? "GET"} ${url.pathname}`);
+
+ return Response.json({
+ latest: R2,
+ items: [
+ {
+ id: R2,
+ version: 5,
+ size: 2048,
+ createdAt: 2,
+ url: `${link}?rev=${R2}`,
+ },
+ {
+ id: R1,
+ version: 4,
+ size: 1024,
+ createdAt: 1,
+ url: `${link}?rev=${R1}`,
+ },
+ ],
+ });
+ });
+
+ try {
+ expect(
+ await (await api.request(`/sharing/history/${shareId}`)).json(),
+ ).toEqual({
+ latest: R2,
+ items: [
+ {
+ id: R2,
+ version: 5,
+ size: 2048,
+ createdAt: 2,
+ url: `${link}?rev=${R2}${preview ? "&app=preview" : ""}`,
+ },
+ {
+ id: R1,
+ version: 4,
+ size: 1024,
+ createdAt: 1,
+ url: `${link}?rev=${R1}${preview ? "&app=preview" : ""}`,
+ },
+ ],
+ });
+ expect(requests).toEqual([`GET /api/v2/shares/${shareId}/revisions`]);
+ } finally {
+ vi.unstubAllEnvs();
+ await fixture.data.close();
+ await fixture.store.close();
+ await rm(root, { recursive: true, force: true });
+ }
+ },
+);
+
+it("signs the user out when loading a link's history is unauthorized", async () => {
+ const root = await mkdtemp(path.join(tmpdir(), "sharing-history-"));
+ vi.stubEnv("DEV_REVIEW_HOME", root);
+
+ const { api, fixture } = await historyHost(
+ root,
+ async () => new Response("unauthorized", { status: 401 }),
+ "expired",
+ );
+
+ try {
+ const response = await api.request(`/sharing/history/${randomUUID()}`);
+
+ expect(await response.json()).toEqual({
+ error: "Your sign-in has expired. Sign in again to share.",
+ });
+ expect(response.status).toBe(401);
+ expect(await readStoreAuth()).toBeNull();
+ } finally {
+ vi.unstubAllEnvs();
+ await fixture.data.close();
+ await fixture.store.close();
+ await rm(root, { recursive: true, force: true });
+ }
+});
From f49c9832a087a0426b07c8d0a034b77c52463f9a Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 16:21:08 -0500
Subject: [PATCH 02/11] Open a share revision in the browser through the host
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
packages/review/src/sharing/host.test.ts | 41 ++++++++++++++++++++++++
packages/review/src/sharing/host.ts | 24 +++++++++++++-
2 files changed, 64 insertions(+), 1 deletion(-)
diff --git a/packages/review/src/sharing/host.test.ts b/packages/review/src/sharing/host.test.ts
index a4a20ad3c5..d962a1936d 100644
--- a/packages/review/src/sharing/host.test.ts
+++ b/packages/review/src/sharing/host.test.ts
@@ -9,6 +9,7 @@ import {
type runStoreLogin,
writeStoreAuth,
} from "@dev.fast/trace-core";
+import { ReviewInputError } from "@dev.fast/whiteboard-document";
import type { SharedProvenance } from "@review/review-api/store.js";
import { Hono } from "hono";
import { afterEach, expect, it, vi } from "vitest";
@@ -481,3 +482,43 @@ it("reports the saved sign-in and forgets it on sign-out", async () => {
await rm(root, { recursive: true, force: true });
}
});
+
+it("opens a share revision in the browser and refuses any other URL", async () => {
+ const root = await mkdtemp(path.join(tmpdir(), "share-open-"));
+ vi.stubEnv("DEV_REVIEW_HOME", root);
+ const fixture = await createShareFixture(root);
+ const api = new Hono();
+ const openUrl = vi.fn(async () => {});
+ api.onError((error, context) =>
+ context.json(
+ { error: error.message },
+ error instanceof ReviewInputError ? error.status : 500,
+ ),
+ );
+ mountSharingHost(api, fixture.store, fixture.data, { openUrl });
+
+ const post = (path: string, body: { url: string }) =>
+ api.request(path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ try {
+ expect(
+ (await post("/sharing/open", { url: `${url}?rev=${R}` })).status,
+ ).toBe(200);
+ expect(openUrl).toHaveBeenCalledWith(`${url}?rev=${R}`);
+
+ for (const url of [
+ "https://evil.test/s/" + capability,
+ "file:///etc/passwd",
+ ])
+ expect((await post("/sharing/open", { url })).status).toBe(400);
+ expect(openUrl).toHaveBeenCalledTimes(1);
+ } finally {
+ await fixture.data.close();
+ fixture.store.close();
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/packages/review/src/sharing/host.ts b/packages/review/src/sharing/host.ts
index 27364fafc2..29dc26ad5d 100644
--- a/packages/review/src/sharing/host.ts
+++ b/packages/review/src/sharing/host.ts
@@ -12,7 +12,7 @@ import {
import {
DEFAULT_STORE_ORIGIN,
clearStoreAuth,
- type openUrlInBrowser,
+ openUrlInBrowser,
readStoreAuth,
runStoreLogin,
} from "@dev.fast/trace-core";
@@ -181,6 +181,28 @@ export function mountSharingHost(
return context.json(login);
});
+ app.post("/sharing/open", async (context) => {
+ const { url } = z
+ .strictObject({ url: z.string().max(2048) })
+ .parse(await readBoundedRequestJson(context.req.raw));
+
+ const origin = (() => {
+ try {
+ return parseShareLink(url).origin;
+ } catch {
+ return undefined;
+ }
+ })();
+
+ if (
+ origin !== DEFAULT_STORE_ORIGIN &&
+ (!origin || origin !== (await readStoreAuth())?.origin)
+ )
+ throw new ReviewInputError("Only Whiteboard share links open here.", 400);
+ await (options.openUrl ?? openUrlInBrowser)(url);
+
+ return context.json({ opened: true });
+ });
mountSharingPublisher(app, store, data, options);
const imports = new Map();
From ae15f2f6b81ae39a1532e0df50d0b9936011a9e3 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 16:21:26 -0500
Subject: [PATCH 03/11] Let the canvas ask the host to confirm
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
.../src/vs/review/browser/parts/canvas/reviewCanvasPart.ts | 2 ++
packages/review-protocol/src/contracts.ts | 5 +++++
2 files changed, 7 insertions(+)
diff --git a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts
index 3f014a568b..e9b793e459 100644
--- a/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/browser/parts/canvas/reviewCanvasPart.ts
@@ -1450,6 +1450,7 @@ export class ReviewCanvasEditorPane extends EditorPane {
| "onDidChangeDiffLayout"
| "setupTooltip"
| "notify"
+ | "confirm"
| "ready"
| "reportDiagnostic"
| "reportSidebar"
@@ -1472,6 +1473,7 @@ export class ReviewCanvasEditorPane extends EditorPane {
setDiffLayout: (scope, layout) => this.diffViews.layout(scope).set(layout),
onDidChangeDiffLayout: (scope, listener) => this.diffViews.layout(scope).onDidChange(listener),
notify: (notification) => showReviewNotification(this.notificationService, notification),
+ confirm: async (request) => (await this.dialogService.confirm({ type: 'warning', ...request })).confirmed,
setupTooltip: this.setupTooltip,
ready: () => {
if (generation !== this.loadGeneration || !this.targetDocument) return;
diff --git a/packages/review-protocol/src/contracts.ts b/packages/review-protocol/src/contracts.ts
index 2c75e63a5b..00488b5b97 100644
--- a/packages/review-protocol/src/contracts.ts
+++ b/packages/review-protocol/src/contracts.ts
@@ -404,6 +404,11 @@ export interface ReviewCanvasBridge {
listener: (show: boolean) => void,
): ReviewDisposable;
notify?(notification: ReviewNotification): ReviewDisposable;
+ confirm?(request: {
+ message: string;
+ detail?: string;
+ primaryButton: string;
+ }): Promise;
setupTooltip?(
target: HTMLElement,
text: string,
From 3ee209782a162b467cbaa2f4d7f5f4e48de09784 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 16:22:45 -0500
Subject: [PATCH 04/11] Format byte sizes
Agent-Session: 64182dea-534e-45f1-8e28-c1e27a5d6bab
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
packages/review/app/src/format.test.ts | 8 +++++++-
packages/review/app/src/format.ts | 10 ++++++++++
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/packages/review/app/src/format.test.ts b/packages/review/app/src/format.test.ts
index ed42d88280..0e2fd52237 100644
--- a/packages/review/app/src/format.test.ts
+++ b/packages/review/app/src/format.test.ts
@@ -1,6 +1,6 @@
import { expect, it } from "vitest";
-import { formatRelativeTime } from "./format";
+import { formatBytes, formatRelativeTime } from "./format";
const now = Date.parse("2026-10-15T12:00:00Z");
@@ -27,3 +27,9 @@ it("writes hours and days in short for a narrow row", () => {
"2d ago",
);
});
+
+it("writes a size in B, KB or MB", () => {
+ expect(formatBytes(512)).toBe("512 B");
+ expect(formatBytes(2048)).toBe("2 KB");
+ expect(formatBytes(1_572_864)).toBe("1.5 MB");
+});
diff --git a/packages/review/app/src/format.ts b/packages/review/app/src/format.ts
index 4046d1432c..a63e4636cd 100644
--- a/packages/review/app/src/format.ts
+++ b/packages/review/app/src/format.ts
@@ -65,3 +65,13 @@ export function formatDuration(durationMs: number): string {
return rest ? `${hours}h ${rest}m` : `${hours}h`;
}
+
+/** A byte count to one decimal: "512 B", "2 KB", "1.5 MB". */
+export function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ const kilobytes = bytes / 1024;
+
+ if (kilobytes < 1024) return `${Number(kilobytes.toFixed(1))} KB`;
+
+ return `${Number((kilobytes / 1024).toFixed(1))} MB`;
+}
From 0bcb36ff3bc02156a06f5a5c88384cfda87fcca4 Mon Sep 17 00:00:00 2001
From: thesiti92
Date: Sun, 1 Nov 2026 16:28:41 -0500
Subject: [PATCH 05/11] List a share link's revisions in the share popover
Agent-Session: 2b04f44e-91c6-4568-8c57-c4a01b2828ad
Agent-Session: 61c94475-694a-4e46-8971-e0d703d3b344
Agent-Session: 196d7f0b-63a2-4186-84b0-5068df27bf9a
Agent-Session: b47f56d8-24aa-4d4f-95b3-4217b7ec9e00
Agent-Session: f2f8f3d8-5a50-4c15-8ad8-9c9308d4a110
Agent-Session: 226d831c-5d71-4f4d-8cdb-7b3c4f30284d
---
packages/review/app/src/canvas-query.tsx | 1 +
.../review/app/src/share-control.test.tsx | 54 ++++++++++++++-
packages/review/app/src/share-control.tsx | 69 +++++++++++++++++--
3 files changed, 119 insertions(+), 5 deletions(-)
diff --git a/packages/review/app/src/canvas-query.tsx b/packages/review/app/src/canvas-query.tsx
index 88b6aeda1d..172964a353 100644
--- a/packages/review/app/src/canvas-query.tsx
+++ b/packages/review/app/src/canvas-query.tsx
@@ -35,6 +35,7 @@ export function createCanvasQueryClient(): QueryClient {
export const canvasQueryKeys = {
sharingAccount: () => ["sharing", "account"] as const,
shareLink: (board: string) => ["sharing", "link", board] as const,
+ shareHistory: (shareId: string) => ["sharing", "history", shareId] as const,
traceList: (
board: string,
version: number | undefined,
diff --git a/packages/review/app/src/share-control.test.tsx b/packages/review/app/src/share-control.test.tsx
index b285cc6c3a..120ade31c6 100644
--- a/packages/review/app/src/share-control.test.tsx
+++ b/packages/review/app/src/share-control.test.tsx
@@ -11,6 +11,16 @@ import { ShareControl, SharingContext } from "./share-control";
let dispose: (() => void) | undefined;
+const shareId = "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a";
+
+const revision5 = "0b8d6c3e-3a9f-4c1e-8f2a-5d7e9b1c4a60";
+
+const revision4 = "1c9e7d4f-4b0a-4d2f-9a3b-6e8f0c2d5b71";
+
+const u5 = `https://app.dev.fast/s/capability?rev=${revision5}`;
+
+const u4 = `https://app.dev.fast/s/capability?rev=${revision4}`;
+
afterEach(async () => {
await act(async () => dispose?.());
document.body.replaceChildren();
@@ -75,6 +85,29 @@ function mount(options: {
});
}
+ if (path.includes("/history/"))
+ return Response.json({
+ latest: revision5,
+ items: [
+ {
+ id: revision5,
+ version: 5,
+ size: 2048,
+ createdAt: Date.now(),
+ url: u5,
+ },
+ {
+ id: revision4,
+ version: 4,
+ size: 1024,
+ createdAt: Date.now() - 2 * 3_600_000,
+ url: u4,
+ },
+ ],
+ });
+
+ if (path.endsWith("/open")) return Response.json({ opened: true });
+
if (state.holdPublish) await state.holdPublish;
if (
@@ -101,7 +134,7 @@ function mount(options: {
);
return Response.json({
- shareId: "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a",
+ shareId,
url: "https://app.dev.fast/s/capability",
version: JSON.parse(String(init!.body)).version,
});
@@ -410,6 +443,25 @@ it("shows a newer version's link when the popover opens on an older version", as
expect(container.querySelector("input")?.value).toContain("/s/capability");
});
+it("lists the link's revisions newest first, marks the latest and opens one in the browser", async () => {
+ const harness = mount({ signedIn: true });
+ const { container } = harness;
+
+ await harness.render(5);
+ await harness.click("Share review");
+ await harness.settle();
+ expect(
+ [...container.querySelectorAll("[role=dialog] [data-share-revision]")].map(
+ (row) => row.textContent,
+ ),
+ ).toEqual(["just now · v5 · 2 KB · Latest", "2 hours ago · v4 · 1 KB"]);
+ await harness.click("2 hours ago · v4 · 1 KB");
+ expect(harness.posts.at(-1)).toEqual({
+ path: "/reviews-api/sharing/open",
+ body: { url: u4 },
+ });
+});
+
it("polls only while sign-in is pending and stops when the control unmounts", async () => {
vi.useFakeTimers({
toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"],
diff --git a/packages/review/app/src/share-control.tsx b/packages/review/app/src/share-control.tsx
index dbf6b150e8..d0fbf626fa 100644
--- a/packages/review/app/src/share-control.tsx
+++ b/packages/review/app/src/share-control.tsx
@@ -1,12 +1,15 @@
import { layer, space } from "@canvas/scale.stylex";
-import { Button, IconButton } from "@canvas/ui/button";
+import { Button, IconButton, buttonStyles } from "@canvas/ui/button";
import { useCopy } from "@canvas/ui/copy";
+import { focusStyles } from "@canvas/ui/focus";
import { iconStyles } from "@canvas/ui/icon-styles";
import { ShareIcon } from "@canvas/ui/icons";
+import { listRowStyles } from "@canvas/ui/list-row";
import { Popover } from "@canvas/ui/popover";
import { textStyles } from "@canvas/ui/text";
import { TextField } from "@canvas/ui/text-field";
import { useTooltip } from "@canvas/ui/tooltip";
+import type { ShareRevisions } from "@dev.fast/review-share-protocol";
import {
type ReviewApiClient,
ReviewApiError,
@@ -29,6 +32,7 @@ import {
} from "react";
import { canvasQueryKeys } from "./canvas-query";
+import { formatBytes, formatRelativeTime } from "./format";
import { useOptionalReviewSession } from "./host/review-session";
import { topbarActionsMarker } from "./markers.stylex";
import { useSharingAccount, useSignIn } from "./sharing-account";
@@ -119,10 +123,14 @@ export function ShareControl({
requestId,
}),
// An older version's upload can land after a newer one's.
- onSuccess: (shared) =>
+ onSuccess: (shared) => {
queryClient.setQueryData(linkKey, (cached) =>
cached && cached.version > shared.version ? cached : shared,
- ),
+ );
+ void queryClient.invalidateQueries({
+ queryKey: canvasQueryKeys.shareHistory(shared.shareId),
+ });
+ },
onError: (error) => {
// The host forgot a stale login; show sign-in and upload again after it.
if (error instanceof ReviewApiError && error.status === 401)
@@ -130,6 +138,11 @@ export function ShareControl({
},
});
+ const openRevision = useMutation({
+ mutationFn: (url: string) =>
+ context!.client.post<{ opened: true }>("/sharing/open", { url }),
+ });
+
const upload = (next: ShareTarget) => {
login.reset();
publish.mutate(next);
@@ -146,6 +159,7 @@ export function ShareControl({
});
publish.reset();
login.reset();
+ openRevision.reset();
setCopyError(undefined);
}
@@ -164,6 +178,20 @@ export function ShareControl({
const current =
link && target && link.version >= target.version ? link : undefined;
+ const historyQuery = useQuery({
+ queryKey: canvasQueryKeys.shareHistory(current?.shareId ?? ""),
+ queryFn:
+ open && current
+ ? ({ signal }) =>
+ context!.client.read(
+ `/sharing/history/${current.shareId}`,
+ signal,
+ )
+ : skipToken,
+ });
+
+ const history = historyQuery.data;
+
// Upload once per opening; a failure waits for Retry or a new sign-in.
useEffect(() => {
if (open && signedIn && target && publish.isIdle && !current)
@@ -172,7 +200,11 @@ export function ShareControl({
if (!context) return null;
- const error = copyError ?? actionError(publish.error ?? login.error);
+ const error =
+ copyError ??
+ actionError(
+ publish.error ?? login.error ?? openRevision.error ?? historyQuery.error,
+ );
const accountError = accountQuery.isError
? "Could not read sign-in status."
@@ -233,6 +265,30 @@ export function ShareControl({
+ {history && history.items.length > 0 && (
+
+ {history.items.map((revision) => (
+ -
+
+
+ ))}
+
+ )}
>
) : error ? (
)}
{signedIn ? (
- current ? (
+ revoked ? (
+
+ Link revoked. Sharing this board again creates a new link.
+
+ ) : current ? (
<>
)}
+ {confirm && (
+
+ )}
>
) : error ? (