fix(desktop): preserve sends with invalid link previews

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-04 21:52:45 -07:00
committed by Carl
parent 2e339c0d09
commit aa4f76188b
5 changed files with 94 additions and 8 deletions

View File

@@ -192,6 +192,7 @@ export function useComposerLinkPreviews(content: string) {
faviconUrl: favicon.url,
faviconSha256: favicon.sha256,
});
if (!tag) return;
setReadyTags((current) => ({ ...current, [preview.href]: tag }));
})
.catch(() => {})

View File

@@ -70,6 +70,7 @@ test("snapshot tags sanitize control characters and enforce UTF-8 byte limits",
faviconSha256: "",
});
assert.ok(tag);
for (const value of tag.slice(4, 7)) {
assert.ok(
Array.from(value).every((char) => {
@@ -83,6 +84,30 @@ test("snapshot tags sanitize control characters and enforce UTF-8 byte limits",
assert.ok(Buffer.byteLength(tag[6], "utf8") <= 1000);
});
test("snapshot tags omit canonical URLs rejected by native validation", () => {
for (const canonicalUrl of [
`${URL}#`,
`${URL}#details`,
`http://linear.app/acme/issue/ABC-123/example`,
`https://user@linear.app/acme/issue/ABC-123/example`,
]) {
assert.equal(
buildLinkPreviewSnapshotTag({
canonicalUrl,
title: "Example",
siteName: "Linear",
description: "Description",
imageUrl: "",
imageSha256: "",
faviconUrl: "",
faviconSha256: "",
}),
null,
canonicalUrl,
);
}
});
test("messages without authored snapshots never create recipient previews", () => {
assert.deepEqual(parseLinkPreviewSnapshots([], CONTENT, ORIGIN), []);
assert.deepEqual(parseLinkPreviewSnapshots(undefined, CONTENT, ORIGIN), []);

View File

@@ -9,6 +9,18 @@ const MAX_SNAPSHOTS = 8;
const SHA256_RE = /^[0-9a-f]{64}$/;
const IMAGE_EXT_RE = /^(?:jpg|png|gif|webp)$/;
export function isValidLinkPreviewSnapshotCanonicalUrl(value: string): boolean {
if (value.includes("#")) return false;
try {
const url = new URL(value);
return (
url.protocol === "https:" && !url.username && !url.password && !url.hash
);
} catch {
return false;
}
}
export type LinkPreviewSnapshot = {
canonicalUrl: string;
title: string;
@@ -40,7 +52,7 @@ function sanitizeSnapshotText(value: string, maxBytes: number): string {
function validText(value: string, max: number): boolean {
return (
value.length <= max &&
new TextEncoder().encode(value).length <= max &&
!Array.from(value).some((char) => {
const code = char.charCodeAt(0);
return code <= 0x1f || code === 0x7f;
@@ -144,7 +156,9 @@ export function parseLinkPreviewSnapshots(
export function buildLinkPreviewSnapshotTag(
snapshot: LinkPreviewSnapshot,
): string[] {
): string[] | null {
if (!isValidLinkPreviewSnapshotCanonicalUrl(snapshot.canonicalUrl))
return null;
return [
"link-preview",
"snapshot",

View File

@@ -62,6 +62,10 @@ import type {
RuntimeFileConfigSubset,
} from "@/shared/api/tauri";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
isValidLinkPreviewSnapshotCanonicalUrl,
parseLinkPreviewSnapshots,
} from "@/shared/lib/linkPreviewSnapshot";
type TestIdentity = {
privateKey: string;
@@ -8976,6 +8980,31 @@ async function handleSendChannelMessage(
// real command and echoed on the stored event. Preserve them in the mock so
// E2E recipient rendering exercises the same authored-snapshot path.
const linkPreviewTags = args.linkPreviewTags ?? [];
if (linkPreviewTags.length > 0) {
const suppression = linkPreviewTags.some(
(tag) =>
tag.length === 2 && tag[0] === "link-preview" && tag[1] === "none",
);
const snapshots = linkPreviewTags.filter(
(tag) => tag[0] === "link-preview" && tag[1] === "snapshot",
);
const validSnapshots = parseLinkPreviewSnapshots(
snapshots,
args.content,
new URL(getRelayHttpUrl(config)).origin,
);
if (
(suppression && linkPreviewTags.length !== 1) ||
(!suppression &&
(snapshots.length !== linkPreviewTags.length ||
validSnapshots.length !== snapshots.length ||
snapshots.some(
(tag) => !isValidLinkPreviewSnapshotCanonicalUrl(tag[3] ?? ""),
)))
) {
throw new Error("invalid link-preview snapshot tag");
}
}
// Both kinds end up on the stored event's tag set, just like the real relay.
const extraTags = [
...mediaTags,

View File

@@ -532,14 +532,31 @@ for (const [pasteShape, wrapUrl] of [
});
}
test("completed link preview sends once and leaves the composer cleared", async ({
test("completed link previews send when one URL has an unsnapshotable fragment", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?send=complete";
const previewUrls = [
"https://twitter.com/tellaho",
"https://github.com/block/buzz/pull/3246",
"https://x.com/tellaho/status/1884289176381841506#",
];
const pastedText = previewUrls.join("\n");
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("message-input").fill(previewUrl);
await waitForReadyComposerSnapshots(page);
const input = page.getByTestId("message-input");
await input.focus();
await input.evaluate((element, text) => {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", text);
element.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
}, pastedText);
await waitForReadyComposerSnapshots(page, 2);
const send = page.getByTestId("send-message");
await expect(send).toBeEnabled();
@@ -558,8 +575,8 @@ test("completed link preview sends once and leaves the composer cleared", async
expect(
(
calls[0]?.payload as { linkPreviewTags?: string[][] | null } | undefined
)?.linkPreviewTags?.[0]?.slice(0, 4),
).toEqual(["link-preview", "snapshot", "1", previewUrl]);
)?.linkPreviewTags?.map((tag) => tag[3]),
).toEqual(previewUrls.slice(0, 2));
});
test("send does not wait for a pending link preview snapshot", async ({