diff --git a/desktop/package.json b/desktop/package.json index 426668e8304..1e93fd76a85 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -66,6 +66,7 @@ "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", + "linkifyjs": "^4.3.2", "lucide-react": "^1.0.0", "mdast-util-from-markdown": "^2.0.3", "motion": "^12.38.0", diff --git a/desktop/src/features/messages/lib/composerLinkPastePipeline.test.mjs b/desktop/src/features/messages/lib/composerLinkPastePipeline.test.mjs new file mode 100644 index 00000000000..55d6a4d4c42 --- /dev/null +++ b/desktop/src/features/messages/lib/composerLinkPastePipeline.test.mjs @@ -0,0 +1,244 @@ +/** + * Composed-editor regression coverage for selected-text link paste. + * + * The unit tests in `composerMessageLinkNode.test.mjs` drive + * `createComposerLinkPasteHandler` against a hand-built schema and a mock + * view — they cannot see what happens *after* the handler declines. That gap + * hid a real bug: `false` from `editorProps.handleDOMEvents.paste` does not end + * paste handling, it hands the event to ProseMirror's built-in paste, which + * runs every plugin's `handlePaste`. TipTap's Link plugin recognised the same + * URLs one layer down and partially linked selections the composer had + * deliberately refused. + * + * So these tests build the *production* editor from `useRichTextEditor` and + * dispatch a real DOM `paste` event at `view.dom`. If someone re-enables + * `linkOnPaste` or slots another URL-aware plugin into the chain, this fails. + */ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; +import { find as findLinks } from "linkifyjs"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const CHANNEL_HREF = `buzz://channel/${CHANNEL_ID}`; +const MESSAGE_LINK_CHANNELS = [{ id: CHANNEL_ID, name: "general" }]; + +before(() => { + dom.window.HTMLElement.prototype.scrollIntoView = () => {}; + // `navigator` is a getter-only global from Node 21 on, so Object.assign + // throws on it. prosemirror-view reads userAgent for browser quirks. + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + }); + Object.assign(globalThis, { + ClipboardEvent: dom.window.Event, + CustomEvent: dom.window.CustomEvent, + DOMParser: dom.window.DOMParser, + document: dom.window.document, + Element: dom.window.Element, + Event: dom.window.Event, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: dom.window.MutationObserver, + Node: dom.window.Node, + Range: dom.window.Range, + ResizeObserver: class { + disconnect() {} + observe() {} + unobserve() {} + }, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +/** + * Mounts the production composer editor and returns its Tiptap instance. + */ +async function mountComposerEditor() { + const React = await import("react"); + const { act, render, waitFor } = await import("@testing-library/react"); + const { EditorContent } = await import("@tiptap/react"); + const { useRichTextEditor } = await import("./useRichTextEditor.ts"); + + let editor = null; + function Harness() { + const instance = useRichTextEditor({ + messageLinkChannels: MESSAGE_LINK_CHANNELS, + }).editor; + editor = instance; + return instance + ? React.createElement(EditorContent, { editor: instance }) + : null; + } + + await act(async () => { + render(React.createElement(Harness)); + }); + // Tiptap emits `create` from a `setTimeout(…, 0)`, and Link's `onCreate` is + // what teaches linkify the `buzz` protocol. Paste before that lands and a + // `buzz://` assertion passes for the wrong reason. + await waitFor(() => + assert.ok(editor?.isInitialized, "composer editor never emitted `create`"), + ); + + // Check that precondition rather than trust the wait. It has to be checked + // *after* `create`, never as the poll itself: `find` initialises linkify's + // scanner on first call, and `registerCustomProtocol` after that only warns, + // so polling on `find` would break the registration it is watching for. + assert.equal( + findLinks(CHANNEL_HREF)[0]?.href, + CHANNEL_HREF, + "expected Link's onCreate to register the buzz protocol with linkify", + ); + return editor; +} + +const paragraph = (...content) => ({ type: "paragraph", content }); +const codeBlock = (text) => ({ + type: "codeBlock", + content: [{ type: "text", text }], +}); +const text = (value, marks) => ({ + type: "text", + text: value, + ...(marks && { marks }), +}); + +/** + * Seeds the document from ProseMirror JSON. Not an HTML string — + * `tiptap-markdown` parses `setContent` input as Markdown, so HTML arrives as + * literal text and the test silently exercises the wrong document. + */ +function seedDocument(editor, ...content) { + editor.commands.setContent({ type: "doc", content }); +} + +function selectAll(editor) { + editor.commands.setTextSelection({ + from: 0, + to: editor.state.doc.content.size, + }); +} + +function pasteText(editor, value) { + const event = new dom.window.Event("paste", { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, "clipboardData", { + value: { + types: ["text/plain"], + getData: (type) => (type === "text/plain" ? value : ""), + }, + }); + editor.view.dom.dispatchEvent(event); +} + +function linkHrefs(editor) { + const hrefs = []; + editor.state.doc.descendants((node) => { + for (const mark of node.marks) { + if (mark.type.name === "link") hrefs.push(mark.attrs.href); + } + }); + return hrefs; +} + +function nodeTypeNames(editor) { + const names = []; + editor.state.doc.descendants((node) => { + names.push(node.type.name); + }); + return names; +} + +test("mixed paragraph and code-block selection is replaced, never part-linked", async () => { + const editor = await mountComposerEditor(); + seedDocument( + editor, + paragraph(text("ordinary")), + codeBlock("const value = 1;"), + ); + selectAll(editor); + + pasteText(editor, "https://example.com"); + + // The whole selection goes, exactly as it would for any non-link paste. + assert.equal(editor.state.doc.textContent, "https://example.com"); + assert.ok(!nodeTypeNames(editor).includes("codeBlock")); + // Crucially, "ordinary" is not left behind wearing a link mark. + assert.ok(!editor.state.doc.textContent.includes("ordinary")); +}); + +test("mixed selection paste of a Buzz link becomes a chip, not a part-link", async () => { + const editor = await mountComposerEditor(); + seedDocument( + editor, + paragraph(text("ordinary")), + codeBlock("const value = 1;"), + ); + selectAll(editor); + + pasteText(editor, CHANNEL_HREF); + + const names = nodeTypeNames(editor); + assert.ok(names.includes("composerMessageLink")); + assert.ok(!names.includes("codeBlock")); + assert.ok(!editor.state.doc.textContent.includes("ordinary")); + assert.deepEqual(linkHrefs(editor), []); +}); + +test("mixed plain and inline-code selection is replaced, never part-linked", async () => { + const editor = await mountComposerEditor(); + seedDocument( + editor, + paragraph(text("plain "), text("inline", [{ type: "code" }])), + ); + selectAll(editor); + + pasteText(editor, "https://example.com"); + + assert.ok(!editor.state.doc.textContent.includes("plain ")); + assert.equal(editor.state.doc.textContent.trim(), "https://example.com"); +}); + +test("fully markable selection keeps its label and gains the link", async () => { + const editor = await mountComposerEditor(); + seedDocument(editor, paragraph(text("read this"))); + selectAll(editor); + + pasteText(editor, "https://example.com"); + + assert.equal(editor.state.doc.textContent, "read this"); + assert.deepEqual(linkHrefs(editor), ["https://example.com"]); +}); + +test("fully markable selection keeps its label for linkify-only URL shapes", async () => { + for (const [pasted, expectedHref] of [ + ["www.example.com", "http://www.example.com"], + ["foo@example.com", "mailto:foo@example.com"], + ]) { + const editor = await mountComposerEditor(); + seedDocument(editor, paragraph(text("read this"))); + selectAll(editor); + + pasteText(editor, pasted); + + assert.equal(editor.state.doc.textContent, "read this"); + assert.deepEqual(linkHrefs(editor), [expectedHref]); + } +}); diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs index 57212aeafeb..9cd476af577 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -2,10 +2,16 @@ import assert from "node:assert/strict"; import { createRequire } from "node:module"; import test from "node:test"; +import { Schema } from "@tiptap/pm/model"; +import { AllSelection, EditorState, TextSelection } from "@tiptap/pm/state"; + import { ComposerMessageLinkNode, + createComposerLinkPasteHandler, registerComposerMessageLinkMarkdownIt, resolveComposerMessageLinkAttributes, + resolveExactLinkPaste, + resolveSelectionLinkPaste, } from "./composerMessageLinkNode.ts"; const requireFromTiptap = createRequire(import.meta.resolve("tiptap-markdown")); @@ -71,6 +77,408 @@ test("resolves channel and entity links as composer chips", () => { ); }); +const resolveKnownChannel = (channelId) => + channelId === CHANNEL_ID ? "general" : undefined; +const exactLinkPaste = (text) => + resolveExactLinkPaste(text, resolveKnownChannel); + +const EXACT_LINK_PASTE_ACCEPTED_CASES = [ + ["exact http", "https://example.com", "https://example.com"], + [ + "wrapped http", + "", + "https://example.com/docs?q=1", + ], + ["exact message", HREF, HREF], + ["wrapped message", `<${HREF}>`, HREF], + ["channel", CHANNEL_HREF, CHANNEL_HREF], + [ + "channel message", + CHANNEL_MESSAGE_HREF, + `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + ], + ["repo", REPO_HREF, REPO_HREF], + ["project", PROJECT_HREF, PROJECT_HREF], + ["pull request", PR_HREF, PR_HREF], + ["issue", ISSUE_HREF, ISSUE_HREF], +]; + +for (const [label, input, expectedHref] of EXACT_LINK_PASTE_ACCEPTED_CASES) { + test(`exact link paste resolves ${label}`, () => { + assert.deepEqual(exactLinkPaste(input), { href: expectedHref }); + }); +} + +test("exact link paste canonicalizes Buzz links", () => { + assert.deepEqual( + exactLinkPaste( + `BUZZ://channel/${CHANNEL_ID.toUpperCase()}/${CHANNEL_MESSAGE_ID.toUpperCase()}`, + ), + { + href: `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + }, + ); +}); + +for (const input of [ + "https://example.com and words", + " https://example.com", + "https://example.com ", + "https://example.com\n", + " trailing", + "www.example.com", + "ftp://example.com", + "not a url", + `See ${HREF}`, + `buzz://channel/${CHANNEL_ID}/not-a-message-id`, +]) { + test(`exact link paste rejects ${input}`, () => { + assert.equal(exactLinkPaste(input), null); + }); +} + +// The selection branch is the composer's alone now that `linkOnPaste` is off, +// so it has to accept everything linkify accepted — otherwise turning the +// second handler off would silently narrow which URLs preserve their label. +for (const [label, input, expectedHref] of [ + ["exact http", "https://example.com", "https://example.com"], + ["wrapped http", "", "https://example.com"], + [ + "canonical Buzz link", + CHANNEL_MESSAGE_HREF, + `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + ], + ["scheme-less host", "www.example.com", "http://www.example.com"], + ["bare host with path", "example.com/docs", "http://example.com/docs"], + ["email address", "foo@example.com", "mailto:foo@example.com"], + ["ftp", "ftp://example.com", "ftp://example.com"], +]) { + test(`selection link paste resolves ${label}`, () => { + assert.deepEqual(resolveSelectionLinkPaste(input, resolveKnownChannel), { + href: expectedHref, + }); + }); +} + +for (const input of [ + "https://example.com and words", + " https://example.com", + "read this", + "", +]) { + test(`selection link paste rejects ${JSON.stringify(input)}`, () => { + assert.equal(resolveSelectionLinkPaste(input, resolveKnownChannel), null); + }); +} + +const editorSchema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { content: "inline*", group: "block" }, + text: { group: "inline" }, + composerMessageLink: { + atom: true, + attrs: { channelName: { default: "" }, href: { default: "" } }, + group: "inline", + inline: true, + selectable: true, + }, + codeBlock: { content: "text*", group: "block", marks: "" }, + }, + marks: { + // `excludes: "_"` mirrors StarterKit's `code` mark: it silently drops any + // other mark added over it, so a link applied to code-marked text never + // lands even though the parent paragraph allows link marks. + code: { excludes: "_" }, + link: { attrs: { href: {} }, inclusive: false }, + }, +}); + +const paragraph = (...content) => + editorSchema.nodes.paragraph.create(null, content); +const codeBlock = (...content) => + editorSchema.nodes.codeBlock.create(null, content); +const document = (...content) => editorSchema.nodes.doc.create(null, content); +const text = (value, marks = []) => editorSchema.text(value, marks); +const composerChip = (href = HREF) => + editorSchema.nodes.composerMessageLink.create({ + channelName: "general", + href, + }); + +function createPasteEvent(value) { + let prevented = false; + return { + clipboardData: { getData: (type) => (type === "text/plain" ? value : "") }, + get defaultPrevented() { + return prevented; + }, + preventDefault() { + prevented = true; + }, + }; +} + +function createMockView(state) { + const view = { + dispatch(transaction) { + view.state = view.state.apply(transaction); + }, + focusCalled: false, + focus() { + view.focusCalled = true; + }, + state, + }; + return view; +} + +function stateFromDocument(doc, from, to = from) { + return EditorState.create({ + doc, + selection: TextSelection.create(doc, from, to), + }); +} + +function allSelectionStateFromDocument(doc) { + return EditorState.create({ + doc, + selection: new AllSelection(doc), + }); +} + +function toPlainJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +test("paste handler links selected text instead of replacing it", () => { + const doc = document(paragraph(text("read this"))); + const view = createMockView(stateFromDocument(doc, 1, 10)); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(event.defaultPrevented, true); + assert.equal(view.focusCalled, true); + assert.equal(view.state.doc.textContent, "read this"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { attrs: { href: "https://example.com" }, type: "link" }, + ]); + assert.equal(view.state.selection.empty, true); + assert.equal(view.state.selection.from, 10); + assert.deepEqual(view.state.storedMarks, []); +}); + +test("paste handler canonicalizes Buzz links over selected text", () => { + const doc = document(paragraph(text("selected"))); + const view = createMockView(stateFromDocument(doc, 1, 9)); + const event = createPasteEvent(CHANNEL_MESSAGE_HREF); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "selected"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { + attrs: { + href: `buzz://message?channel=${CHANNEL_ID}&id=${CHANNEL_MESSAGE_ID}`, + }, + type: "link", + }, + ]); +}); + +test("paste handler consumes idempotent wrapped link over selected text", () => { + const href = "https://example.com"; + const linkMark = editorSchema.marks.link.create({ href }); + const doc = document(paragraph(text("already linked", [linkMark]))); + const view = createMockView(stateFromDocument(doc, 1, 15)); + const event = createPasteEvent(`<${href}>`); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(event.defaultPrevented, true); + assert.equal(view.focusCalled, true); + assert.equal(view.state.doc.textContent, "already linked"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { attrs: { href }, type: "link" }, + ]); + assert.equal(view.state.selection.empty, true); + assert.equal(view.state.selection.from, 15); + assert.deepEqual(view.state.storedMarks, []); +}); + +test("paste handler falls through when selected text cannot carry link marks", () => { + const doc = document(codeBlock(text("const value = 1;"))); + const view = createMockView(stateFromDocument(doc, 1, 17)); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, false); + assert.equal(event.defaultPrevented, false); + assert.equal(view.focusCalled, false); + assert.equal(view.state.doc.textContent, "const value = 1;"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0], { + text: "const value = 1;", + type: "text", + }); +}); + +test("paste handler falls through when any selected text cannot carry link marks", () => { + const doc = document( + paragraph(text("ordinary")), + codeBlock(text("const value = 1;")), + ); + const view = createMockView(stateFromDocument(doc, 1, doc.content.size - 1)); + const initialDoc = toPlainJson(view.state.doc); + const initialSelection = view.state.selection.toJSON(); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, false); + assert.equal(event.defaultPrevented, false); + assert.equal(view.focusCalled, false); + assert.deepEqual(toPlainJson(view.state.doc), initialDoc); + assert.deepEqual(view.state.selection.toJSON(), initialSelection); +}); + +test("paste handler falls through when selected text mixes plain and inline code", () => { + const doc = document( + paragraph( + text("plain "), + text("inline", [editorSchema.marks.code.create()]), + ), + ); + const view = createMockView(stateFromDocument(doc, 1, doc.content.size - 1)); + const initialDoc = toPlainJson(view.state.doc); + const initialSelection = view.state.selection.toJSON(); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, false); + assert.equal(event.defaultPrevented, false); + assert.equal(view.focusCalled, false); + assert.deepEqual(toPlainJson(view.state.doc), initialDoc); + assert.deepEqual(view.state.selection.toJSON(), initialSelection); +}); + +test("paste handler links selected text for linkify-only URL shapes", () => { + for (const [pasted, expectedHref] of [ + ["www.example.com", "http://www.example.com"], + ["foo@example.com", "mailto:foo@example.com"], + ]) { + const doc = document(paragraph(text("read this"))); + const view = createMockView(stateFromDocument(doc, 1, 10)); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + createPasteEvent(pasted), + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "read this"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { attrs: { href: expectedHref }, type: "link" }, + ]); + } +}); + +test("caret paste stays plain for linkify-only URL shapes", () => { + // Only the selection branch widened. A caret paste of `www.example.com` must + // still fall through so it arrives as text for `autolink` to pick up, rather + // than being inserted as a pre-linked node. + const doc = document(paragraph(text("go "))); + const view = createMockView(stateFromDocument(doc, 4)); + const event = createPasteEvent("www.example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, false); + assert.equal(event.defaultPrevented, false); + assert.equal(view.state.doc.textContent, "go "); +}); + +test("paste handler collapses an all-selection to inline content", () => { + const doc = document(paragraph(text("select all"))); + const view = createMockView(allSelectionStateFromDocument(doc)); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "select all"); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content[0].marks, [ + { attrs: { href: "https://example.com" }, type: "link" }, + ]); + assert.equal(view.state.selection.empty, true); + assert.equal(view.state.selection.from, 11); + assert.equal(view.state.selection.$from.parent.type.name, "paragraph"); +}); + +test("paste handler replaces selected text when it contains a composer chip", () => { + const doc = document( + paragraph(text("before "), composerChip(), text(" after")), + ); + const view = createMockView(stateFromDocument(doc, 1, doc.content.size - 1)); + const event = createPasteEvent("https://example.com"); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "https://example.com "); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content, [ + { + marks: [{ attrs: { href: "https://example.com" }, type: "link" }], + text: "https://example.com", + type: "text", + }, + { text: " ", type: "text" }, + ]); +}); + +test("paste handler preserves caret paste behavior", () => { + const doc = document(paragraph(text("go "))); + const view = createMockView(stateFromDocument(doc, 4)); + const event = createPasteEvent(CHANNEL_HREF); + const handled = createComposerLinkPasteHandler(resolveKnownChannel)( + view, + event, + ); + + assert.equal(handled, true); + assert.equal(view.state.doc.textContent, "go "); + assert.deepEqual(toPlainJson(view.state.doc).content[0].content, [ + { text: "go ", type: "text" }, + { + attrs: { channelName: "general", href: CHANNEL_HREF }, + type: "composerMessageLink", + }, + { text: " ", type: "text" }, + ]); +}); + function captureMarkdownRule() { let capturedAnchor = null; let capturedRule = null; diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index 59b15c78ba6..bd6a9067a9d 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -1,7 +1,8 @@ import { mergeAttributes, Node } from "@tiptap/core"; -import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { TextSelection } from "@tiptap/pm/state"; +import type { Mark, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Selection, TextSelection } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; +import { find as findLinks } from "linkifyjs"; import { buildIssueLink, @@ -117,10 +118,118 @@ function unwrapExactBuzzLink(text: string): string | null { } function unwrapExactHttpLink(text: string): string | null { + if (!text || /\s/.test(text)) return null; const match = /^(?:<(https?:\/\/[^\s<>]+)>|(https?:\/\/\S+))$/i.exec(text); return match?.[1] ?? match?.[2] ?? null; } +/** + * Resolves a clipboard payload that is exactly a supported link into the href + * the composer should apply when linkifying selected text on paste. + */ +export function resolveExactLinkPaste( + text: string, + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +): { href: string } | null { + const buzzHref = unwrapExactBuzzLink(text); + if (buzzHref) { + const attrs = resolveComposerMessageLinkAttributes( + buzzHref, + resolveChannelName, + ); + return attrs ? { href: attrs.href } : null; + } + + const httpHref = unwrapExactHttpLink(text); + return httpHref ? { href: httpHref } : null; +} + +/** + * Resolves a clipboard payload for the *selected text* branch of paste + * handling, where this handler is the only one that runs. + * + * The exact Buzz/http matchers win first, so Buzz links keep their canonical + * form. Anything else falls back to linkify with `defaultProtocol: "http"` — + * the same matcher TipTap's `linkOnPaste` used before the composer took sole + * ownership of this branch, so `www.example.com`, `foo@example.com` and + * `ftp://…` still hyperlink the selection instead of replacing it. + * + * Deliberately scoped to the selection branch: broadening + * `resolveExactLinkPaste` would also change caret paste, where these shapes + * must keep arriving as plain text for `autolink` to pick up. + */ +export function resolveSelectionLinkPaste( + text: string, + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +): { href: string } | null { + const exactLinkPaste = resolveExactLinkPaste(text, resolveChannelName); + if (exactLinkPaste) return exactLinkPaste; + + const link = findLinks(text, { defaultProtocol: "http" }).find( + (candidate) => candidate.isLink && candidate.value === text, + ); + return link ? { href: link.href } : null; +} + +function selectionContainsComposerMessageLinkNode(view: EditorView): boolean { + const { from, to } = view.state.selection; + let containsMessageLink = false; + view.state.doc.nodesBetween(from, to, (node) => { + if (containsMessageLink) return false; + if (node.type.name === COMPOSER_MESSAGE_LINK_NODE_NAME) { + containsMessageLink = true; + return false; + } + return true; + }); + return containsMessageLink; +} + +/** + * Checks the *outcome* of an `addMark` rather than predicting it: every inline + * node in the range must have come out carrying `mark`. Predicting is what a + * parent-level `allowsMarkType` probe does, and it misses mark exclusion — + * `code`'s `excludes: "_"` makes `Mark.addToSet` silently drop a link, so a + * selection spanning plain text and an inline code span passes the prediction + * but only gets partially linked. + */ +function everyInlineNodeCarriesMark( + doc: ProseMirrorNode, + from: number, + to: number, + mark: Mark, +): boolean { + let containsInlineContent = false; + let allInlineContentCarriesMark = true; + doc.nodesBetween(from, to, (node) => { + if (!node.isInline) return true; + containsInlineContent = true; + if (!mark.isInSet(node.marks)) allInlineContentCarriesMark = false; + return true; + }); + return containsInlineContent && allInlineContentCarriesMark; +} + +function applyLinkToSelection(view: EditorView, href: string): boolean { + const { from, to } = view.state.selection; + const linkMark = view.state.schema.marks.link; + if (!linkMark) return false; + + const mark = linkMark.create({ href }); + let transaction = view.state.tr.addMark(from, to, mark); + // Bail before dispatching, so the document and selection are untouched and + // the paste falls through to normal replacement. + if (!everyInlineNodeCarriesMark(transaction.doc, from, to, mark)) + return false; + + transaction = transaction.setSelection( + Selection.near(transaction.doc.resolve(transaction.mapping.map(to)), -1), + ); + view.dispatch(transaction.setStoredMarks([]).scrollIntoView()); + view.focus(); + return true; +} + function replaceSelectionWithNode(view: EditorView, node: ProseMirrorNode) { const { from, to } = view.state.selection; let transaction = view.state.tr.replaceRangeWith(from, to, node); @@ -140,6 +249,21 @@ export function createComposerLinkPasteHandler( ) { return (view: EditorView, event: ClipboardEvent): boolean => { const text = event.clipboardData?.getData("text/plain") ?? ""; + if ( + !view.state.selection.empty && + !selectionContainsComposerMessageLinkNode(view) + ) { + const selectionLinkPaste = resolveSelectionLinkPaste( + text, + resolveChannelName, + ); + if (selectionLinkPaste) { + if (!applyLinkToSelection(view, selectionLinkPaste.href)) return false; + event.preventDefault(); + return true; + } + } + const buzzHref = unwrapExactBuzzLink(text); const buzzLinkType = view.state.schema.nodes[COMPOSER_MESSAGE_LINK_NODE_NAME]; diff --git a/desktop/src/features/messages/lib/linkPasteTrailingSpace.ts b/desktop/src/features/messages/lib/linkPasteTrailingSpace.ts new file mode 100644 index 00000000000..0b6d5c53cf4 --- /dev/null +++ b/desktop/src/features/messages/lib/linkPasteTrailingSpace.ts @@ -0,0 +1,64 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, TextSelection } from "@tiptap/pm/state"; + +const PASTED_LINK_AT_END_RE = + /(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i; + +function shouldAppendSpaceAfterPaste(text: string): boolean { + const trimmedEnd = text.trimEnd(); + if (!trimmedEnd || trimmedEnd.length !== text.length) return false; + return PASTED_LINK_AT_END_RE.test(trimmedEnd); +} + +/** + * Appends a trailing space after a paste that ends in a bare link, so the + * caret lands outside the autolinked mark and the next typed character is + * not swallowed into the link. + */ +export const LinkPasteTrailingSpace = Extension.create({ + name: "linkPasteTrailingSpace", + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + handlePaste(view, event) { + const pastedText = event.clipboardData?.getData("text/plain") ?? ""; + if (!shouldAppendSpaceAfterPaste(pastedText)) return false; + + window.setTimeout(() => { + if (!view.dom.isConnected) return; + const { state } = view; + if (!state.selection.empty) return; + + const from = state.selection.from; + if (from < state.doc.content.size) { + const nextText = state.doc.textBetween( + from, + Math.min(state.doc.content.size, from + 1), + "\n", + "\n", + ); + if (/^\s$/.test(nextText)) return; + } + + let transaction = state.tr.insertText(" ", from, from); + const linkMark = state.schema.marks.link; + if (linkMark) { + transaction = transaction.removeMark(from, from + 1, linkMark); + } + transaction = transaction.setSelection( + TextSelection.create(transaction.doc, from + 1), + ); + transaction.setStoredMarks([]); + view.dispatch(transaction.scrollIntoView()); + view.focus(); + }, 0); + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 87574e79720..fa9644fa61b 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -6,7 +6,7 @@ import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import Link from "@tiptap/extension-link"; import { Extension, type KeyboardShortcutCommand } from "@tiptap/core"; -import { Plugin, Selection, TextSelection } from "@tiptap/pm/state"; +import { Selection, TextSelection } from "@tiptap/pm/state"; import type { ResolvedPos } from "@tiptap/pm/model"; import { readTextFromSystemClipboard } from "@/shared/api/tauriMedia"; @@ -34,6 +34,7 @@ import { buildPlainTextProjection } from "./plainTextProjection"; import { parseSnapshotClipboardHtml } from "./agentSnapshotClipboard"; import { buildPreviewUpdate } from "./linkPreviewContent"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; +import { LinkPasteTrailingSpace } from "./linkPasteTrailingSpace"; import { CodeBlockAfterHardBreak, handleCodeFenceEnter, @@ -136,63 +137,6 @@ export type RichTextEditorOptions = { onLinkShortcut?: () => boolean; }; -const PASTED_LINK_AT_END_RE = - /(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i; - -function shouldAppendSpaceAfterPaste(text: string): boolean { - const trimmedEnd = text.trimEnd(); - if (!trimmedEnd || trimmedEnd.length !== text.length) return false; - return PASTED_LINK_AT_END_RE.test(trimmedEnd); -} - -const LinkPasteTrailingSpace = Extension.create({ - name: "linkPasteTrailingSpace", - - addProseMirrorPlugins() { - return [ - new Plugin({ - props: { - handlePaste(view, event) { - const pastedText = event.clipboardData?.getData("text/plain") ?? ""; - if (!shouldAppendSpaceAfterPaste(pastedText)) return false; - - window.setTimeout(() => { - if (!view.dom.isConnected) return; - const { state } = view; - if (!state.selection.empty) return; - - const from = state.selection.from; - if (from < state.doc.content.size) { - const nextText = state.doc.textBetween( - from, - Math.min(state.doc.content.size, from + 1), - "\n", - "\n", - ); - if (/^\s$/.test(nextText)) return; - } - - let transaction = state.tr.insertText(" ", from, from); - const linkMark = state.schema.marks.link; - if (linkMark) { - transaction = transaction.removeMark(from, from + 1, linkMark); - } - transaction = transaction.setSelection( - TextSelection.create(transaction.doc, from + 1), - ); - transaction.setStoredMarks([]); - view.dispatch(transaction.scrollIntoView()); - view.focus(); - }, 0); - - return false; - }, - }, - }), - ]; - }, -}); - /** * Creates and manages a Tiptap editor configured for Markdown output. * @@ -478,7 +422,13 @@ export function useRichTextEditor({ }).configure({ openOnClick: false, autolink: true, - linkOnPaste: true, + // The composer's own paste handler owns every selected-text link + // paste (`createComposerLinkPasteHandler`). TipTap's `linkOnPaste` + // recognises the same URLs one layer down, and when our handler + // declines a selection it can't link cleanly, `linkOnPaste` still + // fires and partially links it. No ordering trick removes a second + // handler — the only fix is not to register it. + linkOnPaste: false, // Allow Buzz message links through TipTap's URL sanitiser. // http(s) and mailto are accepted by default; non-listed protocols are // stripped on paste/typed input. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11fef7c534d..c38989115cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: jdenticon: specifier: ^3.3.0 version: 3.3.0 + linkifyjs: + specifier: ^4.3.2 + version: 4.3.2 lucide-react: specifier: ^1.0.0 version: 1.16.0(react@19.2.8)