diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index 0b46a3082a..22098ae76c 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -113,36 +113,12 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { [richText.editor], ); - const showCard = React.useCallback( - (info: LinkSelectionInfo | null) => { - if (!info) { - setCardState(null); - return; - } - - const rect = getLinkRect(info); - if (!rect) { - setCardState(null); - return; - } - - setCardState((prev) => { - const sameLink = - prev?.info.href === info.href && - prev.info.from === info.from && - prev.info.to === info.to && - prev.info.text === info.text; - const sameRect = - prev?.rect.left === rect.left && - prev.rect.top === rect.top && - prev.rect.width === rect.width && - prev.rect.height === rect.height; - if (sameLink && sameRect) return prev; - return { info, rect }; - }); - }, - [getLinkRect], - ); + const showCard = React.useCallback((_info: LinkSelectionInfo | null) => { + // Keep clicks and caret movement inside composer links focused in the + // editor without opening contextual controls. Link editing remains + // available through the formatting toolbar and Cmd/Ctrl+K. + setCardState(null); + }, []); const openDialogFromInfo = React.useCallback((info: LinkSelectionInfo) => { setDraft({ diff --git a/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs index a9c9f7724d..2a84e2b766 100644 --- a/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs +++ b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { parseLinkPreviewSnapshots } from "./linkPreviewSnapshot.ts"; +import { + buildLinkPreviewSnapshotTag, + parseLinkPreviewSnapshots, +} from "./linkPreviewSnapshot.ts"; const HASH = "a".repeat(64); const ORIGIN = "https://relay.example"; @@ -55,6 +58,31 @@ test("authored snapshots reject remote, malformed, credentialed, and hash-mismat ); }); +test("snapshot tags sanitize control characters and enforce UTF-8 byte limits", () => { + const tag = buildLinkPreviewSnapshotTag({ + canonicalUrl: URL, + title: `Title\n${"😀".repeat(100)}`, + siteName: `Linear\u0000${"é".repeat(100)}`, + description: `First\nSecond${"😀".repeat(300)}`, + imageUrl: "", + imageSha256: "", + faviconUrl: "", + faviconSha256: "", + }); + + for (const value of tag.slice(4, 7)) { + assert.ok( + Array.from(value).every((char) => { + const code = char.charCodeAt(0); + return code > 0x1f && code !== 0x7f; + }), + ); + } + assert.ok(Buffer.byteLength(tag[4], "utf8") <= 300); + assert.ok(Buffer.byteLength(tag[5], "utf8") <= 100); + assert.ok(Buffer.byteLength(tag[6], "utf8") <= 1000); +}); + test("messages without authored snapshots never create recipient previews", () => { assert.deepEqual(parseLinkPreviewSnapshots([], CONTENT, ORIGIN), []); assert.deepEqual(parseLinkPreviewSnapshots(undefined, CONTENT, ORIGIN), []); diff --git a/desktop/src/shared/lib/linkPreviewSnapshot.ts b/desktop/src/shared/lib/linkPreviewSnapshot.ts index 7bac796b72..6ed73df179 100644 --- a/desktop/src/shared/lib/linkPreviewSnapshot.ts +++ b/desktop/src/shared/lib/linkPreviewSnapshot.ts @@ -20,6 +20,24 @@ export type LinkPreviewSnapshot = { faviconSha256: string; }; +function isControlCharacter(char: string): boolean { + const code = char.charCodeAt(0); + return code <= 0x1f || code === 0x7f; +} + +function sanitizeSnapshotText(value: string, maxBytes: number): string { + let result = ""; + let byteLength = 0; + for (const rawChar of value) { + const char = isControlCharacter(rawChar) ? " " : rawChar; + const charBytes = new TextEncoder().encode(char).length; + if (byteLength + charBytes > maxBytes) break; + result += char; + byteLength += charBytes; + } + return result; +} + function validText(value: string, max: number): boolean { return ( value.length <= max && @@ -132,9 +150,9 @@ export function buildLinkPreviewSnapshotTag( "snapshot", LINK_PREVIEW_SNAPSHOT_VERSION, snapshot.canonicalUrl, - snapshot.title, - snapshot.siteName, - snapshot.description, + sanitizeSnapshotText(snapshot.title, 300), + sanitizeSnapshotText(snapshot.siteName, 100), + sanitizeSnapshotText(snapshot.description, 1000), snapshot.imageUrl, snapshot.imageSha256, snapshot.faviconUrl, diff --git a/desktop/tests/e2e/composer-link-shortcut.spec.ts b/desktop/tests/e2e/composer-link-shortcut.spec.ts index 81f8896f7d..43a6646f4c 100644 --- a/desktop/tests/e2e/composer-link-shortcut.spec.ts +++ b/desktop/tests/e2e/composer-link-shortcut.spec.ts @@ -61,10 +61,11 @@ test("⌘K with caret inside an existing link opens the edit-link dialog", async "docs", ); - // Click into the linked text to place the caret inside it, then re-trigger - // the shortcut. (The click also surfaces the composer link hover card — - // ⌘K must open the full dialog from that state.) + // Click into the linked text to place the caret inside it. This must not + // surface contextual controls; editing stays accessible through ⌘K. await input.locator('a[href="https://example.com"]').click(); + await expect(page.getByRole("button", { name: "Edit link" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Unlink" })).toHaveCount(0); await page.keyboard.press("ControlOrMeta+k"); const editDialog = page.getByRole("dialog", { name: "Edit link" }); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 6397eacedd..3ba70ecc67 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -472,6 +472,36 @@ test("link preview style defaults to compact and Rich unfurls descriptions", asy await page.getByTestId("link-preview-style-compact").click(); }); +test("completed link preview sends once and leaves the composer cleared", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?send=complete"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(previewUrl); + await waitForReadyComposerSnapshots(page); + + const send = page.getByTestId("send-message"); + await expect(send).toBeEnabled(); + await send.click(); + await expect(page.getByTestId("message-input")).toHaveText(""); + await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0); + await page.waitForTimeout(250); + await expect(page.getByTestId("message-input")).toHaveText(""); + + const calls = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ), + ); + expect(calls).toHaveLength(1); + expect( + ( + calls[0]?.payload as { linkPreviewTags?: string[][] | null } | undefined + )?.linkPreviewTags?.[0]?.slice(0, 4), + ).toEqual(["link-preview", "snapshot", "1", previewUrl]); +}); + test("send does not wait for a pending link preview snapshot", async ({ page, }) => {