diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewShareLink.test.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewShareLink.test.ts
new file mode 100644
index 0000000000..c50db3be52
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewShareLink.test.ts
@@ -0,0 +1,19 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { shareUrlFromDeepLink } from "./reviewShareLink.js";
+
+test("a share deep link keeps its origin and revision", () => {
+ const t = "a".repeat(43),
+ rev = "5b0e9a52-4a43-4f7e-9d8e-3c1f0e7b2a11";
+ assert.equal(
+ shareUrlFromDeepLink(`/${t}`, `origin=${encodeURIComponent("https://stg.dev.fast")}&rev=${rev}`),
+ `https://stg.dev.fast/s/${t}?rev=${rev}`,
+ );
+ assert.equal(shareUrlFromDeepLink(`/${t}`, ""), `https://app.dev.fast/s/${t}`);
+});
diff --git a/apps/review-desktop/code-oss/src/vs/review/common/reviewShareLink.ts b/apps/review-desktop/code-oss/src/vs/review/common/reviewShareLink.ts
new file mode 100644
index 0000000000..b78484f6f6
--- /dev/null
+++ b/apps/review-desktop/code-oss/src/vs/review/common/reviewShareLink.ts
@@ -0,0 +1,12 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) dev.fast. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the repository root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+export function shareUrlFromDeepLink(path: string, query: string): string {
+ const params = new URLSearchParams(query);
+ const url = new URL(`/s${path}`, params.get("origin") ?? "https://app.dev.fast");
+ const rev = params.get("rev");
+ if (rev) url.searchParams.set("rev", rev);
+ return url.href;
+}
diff --git a/apps/review-desktop/code-oss/src/vs/review/contrib/sharing/reviewSharing.contribution.ts b/apps/review-desktop/code-oss/src/vs/review/contrib/sharing/reviewSharing.contribution.ts
index 5befc07b61..300d5a560d 100644
--- a/apps/review-desktop/code-oss/src/vs/review/contrib/sharing/reviewSharing.contribution.ts
+++ b/apps/review-desktop/code-oss/src/vs/review/contrib/sharing/reviewSharing.contribution.ts
@@ -17,6 +17,7 @@ import {
WorkbenchPhase,
} from "../../../workbench/common/contributions.js";
import { ReviewApiClient } from "../../common/reviewProtocol.js";
+import { shareUrlFromDeepLink } from "../../common/reviewShareLink.js";
import { IReviewCanvasEditorTabsService } from "../../services/reviewCanvasEditorTabsService.js";
import { IReviewDesktopConnectionService } from "../../services/reviewDesktopConnectionService.js";
@@ -81,8 +82,7 @@ class SharedReviewUrlHandler extends Disposable implements IURLHandler {
async handleURL(uri: URI): Promise {
if (uri.authority !== "share") return false;
try {
- const origin = new URLSearchParams(uri.query).get("origin") ?? "https://app.dev.fast";
- await openShare({ url: new URL(`/s${uri.path}`, origin).href }, this.session, this.tabs, this.notifications, this.progress);
+ await openShare({ url: shareUrlFromDeepLink(uri.path, uri.query) }, this.session, this.tabs, this.notifications, this.progress);
} catch {
this.notifications.error("Invalid Whiteboard share link.");
}
diff --git a/apps/review-desktop/scripts/e2e/fixtures/share-service.mjs b/apps/review-desktop/scripts/e2e/fixtures/share-service.mjs
new file mode 100644
index 0000000000..936ddc63bc
--- /dev/null
+++ b/apps/review-desktop/scripts/e2e/fixtures/share-service.mjs
@@ -0,0 +1,147 @@
+/** A share service for journeys: one link per board, a revision per newer version. */
+import { randomBytes, randomUUID } from "node:crypto";
+import { createServer } from "node:http";
+import { createServer as createSecureServer } from "node:https";
+
+export async function startShareService({ tls } = {}) {
+ const posts = [];
+ // Board id to { shareId, capability, revisions: [{ id, version, size, createdAt, bundle }] }, newest revision first.
+ const links = new Map();
+
+ const server = tls
+ ? createSecureServer(
+ tls,
+ (request, response) => void handle(request, response),
+ )
+ : createServer((request, response) => void handle(request, response));
+
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ server.unref();
+
+ const origin = `${tls ? "https" : "http"}://localhost:${server.address().port}`;
+
+ const linkUrl = (link, rev) =>
+ `${origin}/s/${link.capability}${rev ? `?rev=${rev}` : ""}`;
+
+ function publish(reviewId, bundle) {
+ let link = links.get(reviewId);
+
+ if (!link) {
+ link = {
+ shareId: randomUUID(),
+ capability: randomBytes(32).toString("base64url"),
+ revisions: [],
+ };
+ links.set(reviewId, link);
+ }
+
+ if (!link.revisions.length || bundle.version > link.revisions[0].version)
+ link.revisions.unshift({
+ id: randomUUID(),
+ version: bundle.version,
+ size: Buffer.byteLength(JSON.stringify(bundle)),
+ createdAt: Date.now(),
+ bundle,
+ });
+
+ return { shareId: link.shareId, url: linkUrl(link) };
+ }
+
+ const byShareId = (id) =>
+ [...links.values()].find((link) => link.shareId === id);
+
+ const byCapability = (cap) =>
+ [...links.values()].find((link) => link.capability === cap);
+
+ async function handle(request, response) {
+ const url = new URL(request.url, origin);
+
+ const send = (status, body) => {
+ response.writeHead(status, { "content-type": "application/json" });
+ response.end(JSON.stringify(body ?? { error: "not found" }));
+ };
+
+ let body = "";
+
+ for await (const chunk of request) body += chunk;
+
+ const parts = url.pathname.split("/").slice(1);
+ const route = `${request.method} /${parts.slice(0, 3).join("/")}`;
+
+ if (route === "GET /api/auth/token") return send(404);
+
+ if (url.pathname === "/api/v2/shares" && request.method === "GET")
+ return send(200, []);
+
+ if (url.pathname === "/api/v2/shares" && request.method === "POST") {
+ const input = JSON.parse(body);
+
+ posts.push(input);
+
+ return send(200, publish(input.bundle.reviewId, input.bundle));
+ }
+
+ if (route === "GET /api/v2/shares" && parts[4] === "revisions") {
+ const link = byShareId(parts[3]);
+
+ return link
+ ? send(200, {
+ latest: link.revisions[0].id,
+ items: link.revisions.map(({ bundle: _, ...revision }) => ({
+ ...revision,
+ url: linkUrl(link, revision.id),
+ })),
+ })
+ : send(404);
+ }
+
+ if (route === "DELETE /api/v2/shares") {
+ const link = byShareId(parts[3]);
+
+ if (!link) return send(404);
+
+ for (const [key, value] of links) if (value === link) links.delete(key);
+
+ return send(200, { shareId: link.shareId, revoked: true });
+ }
+
+ if (route === "GET /api/v2/shared") {
+ const link = byCapability(parts[3]);
+ const rev = url.searchParams.get("rev");
+
+ const revision = rev
+ ? link?.revisions.find((candidate) => candidate.id === rev)
+ : link?.revisions[0];
+
+ if (!revision) return send(404);
+
+ if (parts[4] === "bundle") return send(200, revision.bundle);
+
+ return send(200, {
+ sender: { login: "e2e" },
+ sharedAt: revision.createdAt,
+ size: revision.size,
+ revision: {
+ id: revision.id,
+ version: revision.version,
+ createdAt: revision.createdAt,
+ },
+ latest: link.revisions[0].id,
+ });
+ }
+
+ send(404);
+ }
+
+ return {
+ origin,
+ posts,
+ links,
+ publish,
+ stop: () =>
+ new Promise((resolve) => {
+ server.closeAllConnections();
+ server.close(() => resolve());
+ }),
+ };
+}
diff --git a/apps/review-desktop/scripts/e2e/journeys/share-link.mjs b/apps/review-desktop/scripts/e2e/journeys/share-link.mjs
new file mode 100644
index 0000000000..64ec90d1d2
--- /dev/null
+++ b/apps/review-desktop/scripts/e2e/journeys/share-link.mjs
@@ -0,0 +1,96 @@
+/** A board shared, edited and shared again keeps one link, with a revision per version. */
+import assert from "node:assert/strict";
+import { writeFile } from "node:fs/promises";
+import path from "node:path";
+
+import { startShareService } from "../fixtures/share-service.mjs";
+import { createReview, sleep } from "../harness.mjs";
+
+export const name = "share-link";
+
+export const phase = 1;
+
+export const options = {
+ async beforeLaunch(ctx) {
+ ctx.service = await startShareService();
+ await writeFile(
+ path.join(ctx.home, "auth.json"),
+ JSON.stringify({
+ origin: ctx.service.origin,
+ token: "e2e",
+ login: "e2e",
+ savedAt: new Date().toISOString(),
+ }),
+ );
+ },
+};
+
+export async function run(ctx) {
+ const { service } = ctx;
+
+ try {
+ const { reviewId, canvas } = await createReview(ctx, {
+ title: "Shared link",
+ blocks: [{ type: "markdown", markdown: "First" }],
+ });
+
+ const page = canvas.page();
+ const button = page.getByRole("button", { name: "Share review" });
+ const input = page.getByLabel("Share link");
+
+ await button.click();
+ await input.waitFor();
+
+ const u1 = await input.inputValue();
+
+ assert.equal(service.posts.length, 1);
+ await page
+ .getByText("This link always opens the latest share of this board.")
+ .waitFor();
+ ctx.check("the first share uploads once and shows its link");
+ await page.keyboard.press("Escape");
+ await input.waitFor({ state: "detached" });
+
+ await ctx.apiOk("/reviews-api/commands", "POST", {
+ operation: {
+ type: "edit",
+ reviewId,
+ edit: {
+ type: "insert",
+ content: { type: "markdown", markdown: "Second" },
+ },
+ },
+ });
+ await canvas.getByText("Second", { exact: true }).waitFor();
+ await button.click();
+ await ctx.until(
+ async () =>
+ service.posts.length === 2 &&
+ (await input.inputValue().catch(() => "")) === u1,
+ "the reshared link",
+ );
+ ctx.check("an edited board uploads again behind the same link");
+
+ const { shareId } = service.links.get(reviewId);
+
+ const revisions = await (
+ await fetch(`${service.origin}/api/v2/shares/${shareId}/revisions`)
+ ).json();
+
+ assert.equal(revisions.items.length, 2);
+ assert.ok(revisions.items[0].version > revisions.items[1].version);
+ assert.equal(revisions.latest, revisions.items[0].id);
+ ctx.check("the link holds two revisions, newest first");
+
+ await page.keyboard.press("Escape");
+ await input.waitFor({ state: "detached" });
+ await button.click();
+ await input.waitFor();
+ await sleep(2000);
+ assert.equal(service.posts.length, 2);
+ assert.equal(await input.inputValue(), u1);
+ ctx.check("reopening an unchanged board uploads nothing");
+ } finally {
+ await service.stop();
+ }
+}
diff --git a/apps/review-desktop/scripts/e2e/journeys/shared-review.mjs b/apps/review-desktop/scripts/e2e/journeys/shared-review.mjs
index a4ece94a83..e6fcf9a842 100644
--- a/apps/review-desktop/scripts/e2e/journeys/shared-review.mjs
+++ b/apps/review-desktop/scripts/e2e/journeys/shared-review.mjs
@@ -1,12 +1,13 @@
-/** A share's copy renders from its own pinned checkout after the sender's repository is gone. */
+/** A share link opens as a copy that renders from its own pinned checkout, refreshes in place while unedited, and copies beside an edited one. */
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
-import { readFile } from "node:fs/promises";
+import { readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { assertNoBlockedReviewRequests } from "../../review-network-policy.mjs";
-import { sourcePackage } from "../harness.mjs";
+import { startShareService } from "../fixtures/share-service.mjs";
+import { openHome, sourcePackage } from "../harness.mjs";
export const name = "shared-review";
@@ -22,21 +23,105 @@ export const options = {
async beforeLaunch(ctx) {
const root = path.join(ctx.root, "share");
- await exec(
- "pnpm",
- ["exec", "tsx", "scripts/seed-share-fixture.ts", root, ctx.home],
- { cwd: sourcePackage, env: ctx.env },
+ const read = async (file) =>
+ JSON.parse(await readFile(path.join(root, file), "utf8"));
+
+ await exec("pnpm", ["exec", "tsx", "scripts/seed-share-fixture.ts", root], {
+ cwd: sourcePackage,
+ env: ctx.env,
+ });
+
+ const key = path.join(ctx.root, "key.pem");
+ const cert = path.join(ctx.root, "cert.pem");
+
+ await exec("openssl", [
+ "req",
+ "-x509",
+ "-newkey",
+ "rsa:2048",
+ "-nodes",
+ "-subj",
+ "/CN=localhost",
+ "-addext",
+ "subjectAltName=DNS:localhost",
+ "-days",
+ "1",
+ "-keyout",
+ key,
+ "-out",
+ cert,
+ ]);
+ ctx.env.NODE_EXTRA_CA_CERTS = cert;
+
+ ctx.share = await read("fixture.json");
+ ctx.bundles = [await read("bundle-1.json"), await read("bundle-2.json")];
+
+ // The bundle names its GitHub remote; the recipient fetches the sender's checkout instead.
+ await writeFile(
+ path.join(ctx.home, ".gitconfig"),
+ `[url "${ctx.share.repo}"]\n\tinsteadOf = https://github.com/fixture/review.git\n`,
);
- ctx.share = JSON.parse(
- await readFile(path.join(root, "fixture.json"), "utf8"),
+
+ ctx.service = await startShareService({
+ tls: {
+ key: await readFile(key, "utf8"),
+ cert: await readFile(cert, "utf8"),
+ },
+ });
+ ctx.share.url = ctx.service.publish(ctx.share.reviewId, ctx.bundles[0]).url;
+ await writeFile(
+ path.join(ctx.home, "auth.json"),
+ JSON.stringify({
+ origin: ctx.service.origin,
+ token: "e2e",
+ login: "e2e",
+ savedAt: new Date().toISOString(),
+ }),
);
},
};
export async function run(ctx) {
- const { share } = ctx;
+ const { share, service, bundles } = ctx;
+
+ try {
+ await journey(ctx, share, service, bundles);
+ } finally {
+ await service.stop();
+ }
+}
+
+async function journey(ctx, share, service, bundles) {
+ const revisions = () => service.links.get(share.reviewId).revisions;
+
+ const receive = async () => {
+ const started = await ctx.api("/reviews-api/sharing/import", "POST", {
+ url: share.url,
+ });
+
+ assert.equal(started.status, 202, JSON.stringify(started.value));
+
+ const { reviewId } = await ctx.until(async () => {
+ const status = await ctx.apiOk(
+ `/reviews-api/sharing/import/${started.value.reviewId}`,
+ );
+
+ assert.notEqual(status.stage, "error", status.error);
+
+ return status.stage === "ready" && status;
+ }, "the share import");
+
+ return reviewId;
+ };
+
+ const board = (reviewId) => ctx.apiOk(`/reviews-api/${reviewId}?full=true`);
+
+ const a = await receive();
+ const first = await board(a);
- await ctx.apiOk(`/reviews-api/${share.reviewId}/open`, "POST", {});
+ assert.equal(first.shared.revisionId, revisions()[0].id);
+
+ await ctx.apiOk(`/reviews-api/${a}/open`, "POST", {});
const page = await ctx.apiCanvasFor(HEADING);
@@ -47,6 +132,11 @@ export async function run(ctx) {
await canvas.getByText("The answer is 42.").first().waitFor();
ctx.check("the shared document and trace quote render");
+ await rename(
+ share.repo,
+ path.join(path.dirname(share.repo), "sender-unavailable"),
+ );
+
// Native inline widgets expose their rendered code through Monaco's view lines.
const code = canvas
.locator(".view-lines")
@@ -57,21 +147,76 @@ export async function run(ctx) {
await code.waitFor();
const source = await ctx.apiOk(
- `/reviews-api/${share.reviewId}/file?side=head&file=${encodeURIComponent(share.sourceFile)}`,
+ `/reviews-api/${a}/file?side=head&file=${encodeURIComponent(share.sourceFile)}`,
);
assert.ok(
source.text.includes(share.sourceText),
`the retained checkout served ${JSON.stringify(source.text)}`,
);
-
- const summary = await ctx.apiOk(`/reviews-api/${share.reviewId}?full=true`);
-
- assert.equal(summary.version, share.version);
ctx.check(
"pinned source renders from the recipient checkout with the sender's repository gone",
);
+ service.publish(share.reviewId, bundles[1]);
+ assert.equal(await receive(), a);
+
+ const refreshed = await board(a);
+
+ assert.equal(refreshed.version, first.version + 1);
+ assert.equal(refreshed.shared.revisionId, revisions()[0].id);
+ await canvas.getByText(share.note).first().waitFor();
+ ctx.check("an unedited copy refreshes in place and its open tab updates");
+
+ await openHome(ctx);
+
+ const rows = ctx.page
+ .locator("main.review-home [data-home-row]")
+ .filter({ hasText: first.title });
+
+ const copies = (count) =>
+ ctx.until(
+ async () => (await rows.count()) === count,
+ `${count} copies on Home`,
+ );
+
+ await copies(1);
+ ctx.check("Home lists one copy after the refresh");
+
+ await ctx.apiOk("/reviews-api/commands", "POST", {
+ operation: {
+ type: "edit",
+ reviewId: a,
+ edit: {
+ type: "insert",
+ content: { type: "markdown", markdown: "A reader's own note." },
+ },
+ },
+ });
+
+ const edited = await board(a);
+
+ // Revision 3 is revision 2 again at the sender's next version.
+ const version = bundles[1].version + 1;
+
+ service.publish(share.reviewId, {
+ ...bundles[1],
+ version,
+ snapshot: { ...bundles[1].snapshot, version },
+ });
+
+ const b = await receive();
+
+ assert.notEqual(b, a);
+ assert.equal((await board(b)).shared.revisionId, revisions()[0].id);
+ assert.equal((await board(a)).version, edited.version);
+ await copies(2);
+ ctx.check("a newer revision copies beside an edited copy, leaving it as is");
+
+ await service.stop();
+ assert.equal(await receive(), b);
+ ctx.check("offline, the link opens its newest copy");
+
// A received share renders from what is on disk: nothing leaves the machine.
assertNoBlockedReviewRequests(ctx.requestUrls);
assert.deepEqual(
diff --git a/packages/review-share-protocol/package.json b/packages/review-share-protocol/package.json
index ea15c605fc..eaa50f4c07 100644
--- a/packages/review-share-protocol/package.json
+++ b/packages/review-share-protocol/package.json
@@ -1,6 +1,6 @@
{
"name": "@dev.fast/review-share-protocol",
- "version": "0.4.1",
+ "version": "0.5.0",
"description": "Portable immutable Review sharing envelope.",
"license": "MIT",
"repository": {
diff --git a/packages/review-share-protocol/src/index.test.ts b/packages/review-share-protocol/src/index.test.ts
index 7222737cec..1d268e01c3 100644
--- a/packages/review-share-protocol/src/index.test.ts
+++ b/packages/review-share-protocol/src/index.test.ts
@@ -29,6 +29,33 @@ it("puts the capability in the path and rejects credentials, fragments or other
);
});
+it("carries a revision as ?rev= and rejects any other query", () => {
+ const token = "a".repeat(43),
+ rev = "5b0e9a52-4a43-4f7e-9d8e-3c1f0e7b2a11";
+
+ expect(shareLink("https://app.dev.fast", token, rev)).toBe(
+ `https://app.dev.fast/s/${token}?rev=${rev}`,
+ );
+ expect(parseShareLink(`https://app.dev.fast/s/${token}?rev=${rev}`)).toEqual({
+ origin: "https://app.dev.fast",
+ capability: token,
+ revision: rev,
+ });
+ expect(
+ parseShareLink(`https://app.dev.fast/s/${token}?app=preview&rev=${rev}`)
+ .revision,
+ ).toBe(rev);
+ expect(() =>
+ parseShareLink(`https://app.dev.fast/s/${token}?rev=nope`),
+ ).toThrow("Invalid UUID");
+ expect(() => parseShareLink(`https://app.dev.fast/s/${token}?x=1`)).toThrow(
+ "HTTPS",
+ );
+ expect(parseShareLink(`https://app.dev.fast/s/${token}`)).not.toHaveProperty(
+ "revision",
+ );
+});
+
it("rejects traversal paths and platform-specific separators", () => {
for (const file of [
"../secret",
diff --git a/packages/review-share-protocol/src/index.ts b/packages/review-share-protocol/src/index.ts
index 9f31a0dd6c..9f1dd072eb 100644
--- a/packages/review-share-protocol/src/index.ts
+++ b/packages/review-share-protocol/src/index.ts
@@ -139,11 +139,61 @@ export const createShareSchema = z.object({
bundle: shareBundleSchema,
});
-export function shareLink(origin: string, capability: string): string {
- return new URL(`/s/${capabilitySchema.parse(capability)}`, origin).href;
+export const shareCreatedSchema = z.strictObject({
+ shareId: shareIdSchema,
+ url: z.url(),
+});
+
+const shareSizeSchema = z.number().int().positive().max(MAX_SHARE_BYTES);
+
+export const shareRevisionRefSchema = z.object({
+ id: shareIdSchema,
+ version: z.number().int().nonnegative(),
+ createdAt: z.number(),
+});
+
+export const shareRevisionSchema = shareRevisionRefSchema.extend({
+ size: shareSizeSchema,
+ url: z.url(),
+});
+
+export const shareRevisionsSchema = z.object({
+ latest: shareIdSchema,
+ items: z.array(shareRevisionSchema),
+});
+
+export type ShareRevisions = z.infer;
+
+export const sharedMetaSchema = z.object({
+ sender: z.strictObject({ login: z.string().min(1).max(256) }),
+ sharedAt: z.number(),
+ size: shareSizeSchema,
+ revision: shareRevisionRefSchema,
+ latest: shareIdSchema,
+});
+
+export type SharedMeta = z.infer;
+
+export function shareLink(
+ origin: string,
+ capability: string,
+ revision?: string,
+): string {
+ const url = new URL(`/s/${capabilitySchema.parse(capability)}`, origin);
+
+ if (revision !== undefined)
+ url.searchParams.set("rev", shareIdSchema.parse(revision));
+
+ return url.href;
}
-export function parseShareLink(value: string) {
+export type ShareLink = {
+ origin: string;
+ capability: string;
+ revision?: string;
+};
+
+export function parseShareLink(value: string): ShareLink {
const url = new URL(value);
if (
@@ -151,14 +201,20 @@ export function parseShareLink(value: string) {
url.username ||
url.password ||
url.hash ||
- [...url.searchParams.keys()].some((key) => key !== "app")
+ [...url.searchParams.keys()].some((key) => key !== "app" && key !== "rev")
)
throw new Error("Use an HTTPS Whiteboard share link.");
- return {
+ const link: ShareLink = {
origin: url.origin,
capability: capabilitySchema.parse(
/^\/s\/([^/]+)$/.exec(url.pathname)?.[1],
),
};
+
+ const revision = url.searchParams.get("rev");
+
+ if (revision !== null) link.revision = shareIdSchema.parse(revision);
+
+ return link;
}
diff --git a/packages/review/app/src/canvas-query.tsx b/packages/review/app/src/canvas-query.tsx
index 5e6517ebd1..88b6aeda1d 100644
--- a/packages/review/app/src/canvas-query.tsx
+++ b/packages/review/app/src/canvas-query.tsx
@@ -34,8 +34,7 @@ export function createCanvasQueryClient(): QueryClient {
/** Desktop shares one client across boards, so board keys name the board. */
export const canvasQueryKeys = {
sharingAccount: () => ["sharing", "account"] as const,
- shareLink: (board: string, version: number | undefined) =>
- ["sharing", "link", board, version] as const,
+ shareLink: (board: string) => ["sharing", "link", board] 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 af1dca61dd..f8814709c1 100644
--- a/packages/review/app/src/share-control.test.tsx
+++ b/packages/review/app/src/share-control.test.tsx
@@ -93,7 +93,9 @@ function mount(options: {
);
return Response.json({
+ shareId: "6f1c2b0e-8e0a-4b8e-9a51-0d6f1e2c3b4a",
url: "https://app.dev.fast/s/capability",
+ version: JSON.parse(String(init!.body)).version,
});
},
);
diff --git a/packages/review/app/src/share-control.tsx b/packages/review/app/src/share-control.tsx
index f506da262d..dbf6b150e8 100644
--- a/packages/review/app/src/share-control.tsx
+++ b/packages/review/app/src/share-control.tsx
@@ -51,6 +51,12 @@ interface ShareTarget {
requestId: string;
}
+interface SharedLink {
+ shareId: string;
+ url: string;
+ version: number;
+}
+
const actionError = (error: Error | null) =>
error &&
(error instanceof ReviewApiError && error.status < 500
@@ -88,12 +94,11 @@ export function ShareControl({
const label = "Share review";
const tooltip = useTooltip(label);
- // Links created this canvas, so reopening a shared version skips the upload.
- const link = useQuery({
- queryKey: canvasQueryKeys.shareLink(
- context?.reviewId ?? "",
- target?.version,
- ),
+ // The board's one link; a new version uploads behind the same URL.
+ const linkKey = canvasQueryKeys.shareLink(context?.reviewId ?? "");
+
+ const link = useQuery({
+ queryKey: linkKey,
queryFn: skipToken,
staleTime: Infinity,
gcTime: Infinity,
@@ -108,15 +113,15 @@ export function ShareControl({
const publish = useMutation({
mutationFn: ({ version, requestId }: ShareTarget) =>
- context!.client.post<{ url: string }>("/sharing/publish", {
+ context!.client.post("/sharing/publish", {
reviewId: context!.reviewId,
version,
requestId,
}),
- onSuccess: ({ url }, { version }) =>
- queryClient.setQueryData(
- canvasQueryKeys.shareLink(context!.reviewId, version),
- url,
+ // An older version's upload can land after a newer one's.
+ onSuccess: (shared) =>
+ queryClient.setQueryData(linkKey, (cached) =>
+ cached && cached.version > shared.version ? cached : shared,
),
onError: (error) => {
// The host forgot a stale login; show sign-in and upload again after it.
@@ -156,10 +161,14 @@ export function ShareControl({
},
}));
+ const current =
+ link && target && link.version >= target.version ? link : undefined;
+
// Upload once per opening; a failure waits for Retry or a new sign-in.
useEffect(() => {
- if (open && signedIn && target && publish.isIdle && !link) upload(target);
- }, [open, signedIn, target, publish.isIdle, link]);
+ if (open && signedIn && target && publish.isIdle && !current)
+ upload(target);
+ }, [open, signedIn, target, publish.isIdle, current]);
if (!context) return null;
@@ -209,17 +218,20 @@ export function ShareControl({
)}
{signedIn ? (
- link ? (
+ current ? (
<>
event.target.select()}
/>
-