fix(composer): preserve completed preview sends

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-08-03 16:56:25 -07:00
parent 8c2e0b6c92
commit 975323e2e8
5 changed files with 90 additions and 37 deletions

View File

@@ -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({

View File

@@ -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), []);

View File

@@ -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,

View File

@@ -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" });

View File

@@ -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,
}) => {