diff --git a/desktop/src-tauri/src/link_preview_tags.rs b/desktop/src-tauri/src/link_preview_tags.rs index b7e3ef5435..fc57e2e47c 100644 --- a/desktop/src-tauri/src/link_preview_tags.rs +++ b/desktop/src-tauri/src/link_preview_tags.rs @@ -6,8 +6,11 @@ const MAX_TITLE: usize = 300; const MAX_SITE: usize = 100; const MAX_DESCRIPTION: usize = 1000; -fn valid_text(value: &str, max: usize) -> bool { - value.len() <= max && !value.chars().any(char::is_control) +fn valid_text(value: &str, max: usize, allow_newlines: bool) -> bool { + value.len() <= max + && !value + .chars() + .any(|character| character.is_control() && !(allow_newlines && character == '\n')) } fn valid_sha256(value: &str) -> bool { @@ -78,9 +81,9 @@ pub fn append( && url.fragment().is_none() }) && seen.insert(preview_tag[3].clone()) - && valid_text(&preview_tag[4], MAX_TITLE) - && valid_text(&preview_tag[5], MAX_SITE) - && valid_text(&preview_tag[6], MAX_DESCRIPTION) + && valid_text(&preview_tag[4], MAX_TITLE, false) + && valid_text(&preview_tag[5], MAX_SITE, false) + && valid_text(&preview_tag[6], MAX_DESCRIPTION, true) && valid_media_pair(&preview_tag[7], &preview_tag[8], &base) && valid_media_pair(&preview_tag[9], &preview_tag[10], &base); if !valid { @@ -147,6 +150,20 @@ mod tests { .is_err()); } + #[test] + fn append_accepts_description_newlines() { + let mut preview_tag = tag("", ""); + preview_tag[6] = "First paragraph\n\nSecond paragraph".into(); + assert!(append(&[preview_tag], BASE, &mut Vec::new()).is_ok()); + } + + #[test] + fn append_rejects_other_control_characters() { + let mut preview_tag = tag("", ""); + preview_tag[6] = "Unsafe\tdescription".into(); + assert!(append(&[preview_tag], BASE, &mut Vec::new()).is_err()); + } + #[test] fn append_rejects_untrusted_or_malformed_snapshot_media() { for url in [ diff --git a/desktop/src/features/messages/lib/linkPreviewContent.ts b/desktop/src/features/messages/lib/linkPreviewContent.ts new file mode 100644 index 0000000000..ef7d57bccc --- /dev/null +++ b/desktop/src/features/messages/lib/linkPreviewContent.ts @@ -0,0 +1,24 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +import { buildPlainTextProjection } from "./plainTextProjection"; + +export function buildPreviewUpdate( + doc: ProseMirrorNode, + selectionAnchor: number, +) { + const projection = buildPlainTextProjection(doc); + const plainText = projection.text; + const hrefs = new Set(); + doc.descendants((node) => { + if (!node.isText || node.marks.some((mark) => mark.type.name === "spoiler")) + return; + const href = node.marks.find((mark) => mark.type.name === "link")?.attrs + .href; + if (typeof href === "string") hrefs.add(href); + }); + return { + cursor: projection.mapPMToTextOffset(selectionAnchor), + linkPreviewContent: [plainText, ...hrefs].join("\n"), + text: plainText, + }; +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index d755600835..6351d94e5f 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -29,6 +29,7 @@ import { import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; +import { buildPreviewUpdate } from "./linkPreviewContent"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; import { CodeBlockAfterHardBreak, @@ -76,7 +77,7 @@ export type AutocompleteEdit = { export type RichTextEditorOptions = { placeholder?: string; - onUpdate?: (info: { text: string; cursor: number }) => void; + onUpdate?: (info: ReturnType) => void; editable?: boolean; mentionNames?: string[]; agentMentionNames?: string[]; @@ -654,11 +655,9 @@ export function useRichTextEditor({ // still available through `getMarkdown()` for send/draft boundaries; // per-keystroke consumers only need textarea-shaped plain text for // autocomplete and empty/non-empty state. - const projection = buildPlainTextProjection(ed.state.doc); - onUpdateRef.current?.({ - cursor: projection.mapPMToTextOffset(ed.state.selection.anchor), - text: projection.text, - }); + onUpdateRef.current?.( + buildPreviewUpdate(ed.state.doc, ed.state.selection.anchor), + ); }, }, [], diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 2f6925ceea..d7c4b18b03 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -262,9 +262,9 @@ function MessageComposerImpl({ onEditLink: (info) => onEditLinkRef.current?.(info), onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, - onUpdate: ({ cursor, text }) => { + onUpdate: ({ cursor, linkPreviewContent, text }) => { setComposerContentFromText(text); - setPreviewContent(text); + setPreviewContent(linkPreviewContent); mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 39d5d9db8e..1694dd0c04 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -13,6 +13,16 @@ import { } from "@/shared/lib/mediaUrl"; import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; import { Attachment, AttachmentContent, @@ -133,6 +143,7 @@ async function uploadDataUrl( export function useComposerLinkPreviews(content: string) { const [suppressed, setSuppressed] = React.useState(false); + const [dismissDialogOpen, setDismissDialogOpen] = React.useState(false); const candidates = React.useMemo( () => extractSupportedLinkPreviews(content).filter((preview) => @@ -213,33 +224,62 @@ export function useComposerLinkPreviews(content: string) { : candidates.flatMap((candidate) => readyTags[candidate.href] ? [readyTags[candidate.href]] : [], ); - const hideAll = React.useCallback(() => setSuppressed(true), []); + const previewNoun = previews.length === 1 ? "preview" : "previews"; + const hideAll = React.useCallback(() => { + setSuppressed(true); + setDismissDialogOpen(false); + }, []); const previewList = previews.length ? ( -
-
- - {previews.map((preview) => ( - - ))} - - + <> +
+
+ + {previews.map((preview) => ( + + ))} + + +
-
+ + + + Hide link {previewNoun}? + + {previews.length === 1 ? "This preview" : "These previews"} won’t + be included when you send this message. + + + + + + + + + + + + + ) : null; const getReadyTags = React.useCallback(() => { if (suppressedRef.current) return [["link-preview", "none"]]; diff --git a/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs index 8b4bbf2756..c001e3fb6f 100644 --- a/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs +++ b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs @@ -63,7 +63,7 @@ test("snapshot tags sanitize control characters and enforce UTF-8 byte limits", canonicalUrl: URL, title: `Title\n${"πŸ˜€".repeat(100)}`, siteName: `Linear\u0000${"Γ©".repeat(100)}`, - description: `First\nSecond${"πŸ˜€".repeat(300)}`, + description: `First\nSecond\t${"πŸ˜€".repeat(300)}`, imageUrl: "", imageSha256: "", faviconUrl: "", @@ -71,7 +71,9 @@ test("snapshot tags sanitize control characters and enforce UTF-8 byte limits", }); assert.ok(tag); - for (const value of tag.slice(4, 7)) { + assert.equal(tag[6].startsWith("First\nSecond"), true); + assert.equal(tag[6].includes("\t"), false); + for (const value of tag.slice(4, 6)) { assert.ok( Array.from(value).every((char) => { const code = char.charCodeAt(0); diff --git a/desktop/src/shared/lib/linkPreviewSnapshot.ts b/desktop/src/shared/lib/linkPreviewSnapshot.ts index d3dc00eac2..59284f2616 100644 --- a/desktop/src/shared/lib/linkPreviewSnapshot.ts +++ b/desktop/src/shared/lib/linkPreviewSnapshot.ts @@ -32,16 +32,21 @@ export type LinkPreviewSnapshot = { faviconSha256: string; }; -function isControlCharacter(char: string): boolean { +function isControlCharacter(char: string, allowNewlines = false): boolean { + if (allowNewlines && char === "\n") return false; const code = char.charCodeAt(0); return code <= 0x1f || code === 0x7f; } -function sanitizeSnapshotText(value: string, maxBytes: number): string { +function sanitizeSnapshotText( + value: string, + maxBytes: number, + allowNewlines = false, +): string { let result = ""; let byteLength = 0; for (const rawChar of value) { - const char = isControlCharacter(rawChar) ? " " : rawChar; + const char = isControlCharacter(rawChar, allowNewlines) ? " " : rawChar; const charBytes = new TextEncoder().encode(char).length; if (byteLength + charBytes > maxBytes) break; result += char; @@ -50,12 +55,11 @@ function sanitizeSnapshotText(value: string, maxBytes: number): string { return result; } -function validText(value: string, max: number): boolean { +function validText(value: string, max: number, allowNewlines = false): boolean { return ( new TextEncoder().encode(value).length <= max && !Array.from(value).some((char) => { - const code = char.charCodeAt(0); - return code <= 0x1f || code === 0x7f; + return isControlCharacter(char, allowNewlines); }) ); } @@ -131,7 +135,7 @@ export function parseLinkPreviewSnapshots( if ( !validText(title, 300) || !validText(siteName, 100) || - !validText(description, 1000) + !validText(description, 1000, true) ) continue; if ( @@ -166,7 +170,7 @@ export function buildLinkPreviewSnapshotTag( snapshot.canonicalUrl, sanitizeSnapshotText(snapshot.title, 300), sanitizeSnapshotText(snapshot.siteName, 100), - sanitizeSnapshotText(snapshot.description, 1000), + sanitizeSnapshotText(snapshot.description, 1000, true), snapshot.imageUrl, snapshot.imageSha256, snapshot.faviconUrl, diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index fe1fb53ac1..0533bb5edb 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -206,32 +206,46 @@ test.beforeEach(async ({ page }, testInfo) => { }, linkPreviewMetadataDelayMs: 2_000, } - : testInfo.title.includes("link preview") || - testInfo.title.includes("supported Compact") + : testInfo.title.includes( + "rich link preview preserves description newlines", + ) ? { linkPreviewMetadata: { title: "Buzz pull request", siteName: "GitHub", - description: "A sender-authored preview snapshot.", + description: + "First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.", imageDataUrl: null, imageDomain: null, }, - linkPreviewMetadataDelayMs: testInfo.title.includes( - "loading card before cold resolver work", - ) - ? 10_000 - : testInfo.title.includes("style defaults") || - testInfo.title.includes("send does not wait") || - testInfo.title.includes("attachment-sized") - ? 1_500 - : undefined, - linkPreviewMetadataStartBlockMs: testInfo.title.includes( - "loading card before cold resolver work", - ) - ? 150 - : undefined, } - : undefined; + : testInfo.title.includes("link preview") || + testInfo.title.includes("supported Compact") + ? { + linkPreviewMetadata: { + title: "Buzz pull request", + siteName: "GitHub", + description: "A sender-authored preview snapshot.", + imageDataUrl: null, + imageDomain: null, + }, + linkPreviewMetadataDelayMs: testInfo.title.includes( + "loading card before cold resolver work", + ) + ? 10_000 + : testInfo.title.includes("style defaults") || + testInfo.title.includes("send does not wait") || + testInfo.title.includes("attachment-sized") + ? 1_500 + : undefined, + linkPreviewMetadataStartBlockMs: + testInfo.title.includes( + "loading card before cold resolver work", + ) + ? 150 + : undefined, + } + : undefined; const mock = testInfo.title.includes("unresolvable preview") ? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 150 } : baseMock; @@ -535,6 +549,69 @@ for (const [pasteShape, wrapUrl] of [ }); } +test("display-text link preview produces and sends its preview", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?display=text"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("review the pull request"); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("ControlOrMeta+k"); + const dialog = page.getByRole("dialog", { name: "Add link" }); + await dialog.getByLabel("URL").fill(previewUrl); + await dialog.getByRole("button", { name: "Save" }).click(); + + await expect(input.locator(`a[href="${previewUrl}"]`)).toHaveText( + "review the pull request", + ); + await expect(page.locator("[data-composer-link-previews]")).toBeVisible(); + await waitForReadyComposerSnapshots(page); + await page.getByTestId("send-message").click(); + + const row = page.getByTestId("message-row").last(); + await expect( + row.getByRole("link", { name: "review the pull request", exact: true }), + ).toHaveAttribute("href", previewUrl); + await expect(row.locator("[data-link-preview]")).toBeVisible(); + const linkPreviewTags = await page.evaluate(() => { + const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] + .reverse() + .find((entry) => entry.command === "send_channel_message"); + return ( + call?.payload as { linkPreviewTags?: string[][] | null } | undefined + )?.linkPreviewTags; + }); + expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]); +}); + +test("rich link preview preserves description newlines after sending", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?newlines=1"; + await page.addInitScript(() => + localStorage.setItem("buzz.appearance.linkPreviewStyle", "rich"), + ); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(previewUrl); + await waitForReadyComposerSnapshots(page); + await page.getByTestId("send-message").click(); + + const description = page + .getByTestId("message-row") + .last() + .locator('[data-link-preview-inline] [data-slot="attachment-description"]'); + await expect(description.locator("p")).toHaveCount(2); + await expect(description).toHaveText( + "First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.", + { useInnerText: true }, + ); +}); + test("completed link previews send when one URL has an unsnapshotable fragment", async ({ page, }) => { @@ -643,7 +720,7 @@ test("send does not wait for a pending link preview snapshot", async ({ expect(linkPreviewTags ?? []).toEqual([]); }); -test("hiding composer previews suppresses the whole draft and emits the blanket marker", async ({ +test("hiding composer link previews suppresses the whole draft and emits the blanket marker", async ({ page, }) => { const firstUrl = "https://github.com/block/buzz/pull/3246?hide=all"; @@ -652,8 +729,18 @@ test("hiding composer previews suppresses the whole draft and emits the blanket await page.getByTestId("channel-general").click(); await page.getByTestId("message-input").fill(firstUrl); await expect(page.locator("[data-composer-link-previews]")).toBeVisible(); + await waitForReadyComposerSnapshots(page); await page.getByTestId("composer-hide-link-previews").click(); + const dismissDialog = page.getByRole("alertdialog", { + name: "Hide link preview?", + }); + await expect(dismissDialog).toBeVisible(); + await dismissDialog.getByRole("button", { name: "Cancel" }).click(); + await expect(page.locator("[data-composer-link-previews]")).toBeVisible(); + + await page.getByTestId("composer-hide-link-previews").click(); + await dismissDialog.getByRole("button", { name: "Hide preview" }).click(); await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0); await page.getByTestId("message-input").fill(`${firstUrl} ${secondUrl}`); await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);