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)