diff --git a/desktop/src/features/messages/lib/messageLink.test.mjs b/desktop/src/features/messages/lib/messageLink.test.mjs index 410cafb44..ca7742614 100644 --- a/desktop/src/features/messages/lib/messageLink.test.mjs +++ b/desktop/src/features/messages/lib/messageLink.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { buildMessageLink, + isBuzzUrl, isMessageLink, parseMessageLink, } from "./messageLink.ts"; @@ -118,3 +119,28 @@ test("isMessageLink matches buzz://message and legacy buzz://message", () => { assert.equal(isMessageLink(undefined), false); assert.equal(isMessageLink(""), false); }); + +test("isBuzzUrl matches any well-formed buzz:// URL", () => { + // Message deep-links. + assert.equal( + isBuzzUrl(`buzz://message?channel=${CHANNEL}&id=${MESSAGE}`), + true, + ); + // Non-message buzz:// URLs (tho wants ALL buzz:// links to wrap). + assert.equal(isBuzzUrl("buzz://connect?relay=wss://x"), true); + assert.equal(isBuzzUrl("buzz://channel?id=abc"), true); + // Surrounding whitespace is tolerated. + assert.equal( + isBuzzUrl(` buzz://message?channel=${CHANNEL}&id=${MESSAGE} `), + true, + ); + // Standard URLs and non-buzz schemes are rejected. + assert.equal(isBuzzUrl("https://example.com"), false); + assert.equal(isBuzzUrl("mailto:x@example.com"), false); + // Malformed / empty input is rejected. + assert.equal(isBuzzUrl("buzz"), false); + assert.equal(isBuzzUrl("not a url at all"), false); + assert.equal(isBuzzUrl(undefined), false); + assert.equal(isBuzzUrl(null), false); + assert.equal(isBuzzUrl(""), false); +}); diff --git a/desktop/src/features/messages/lib/messageLink.ts b/desktop/src/features/messages/lib/messageLink.ts index 02e892aaf..398f8154b 100644 --- a/desktop/src/features/messages/lib/messageLink.ts +++ b/desktop/src/features/messages/lib/messageLink.ts @@ -102,3 +102,17 @@ export function isMessageLink(href: string | undefined | null): boolean { if (!href) return false; return href.startsWith("buzz://message?") || href === "buzz://message"; } + +/** + * Returns true if `text` is any well-formed `buzz://` URL (not just the + * message-link format) — used by the composer's wrap-on-paste handler. + */ +export function isBuzzUrl(text: string | undefined | null): boolean { + if (!text) return false; + const trimmed = text.trim(); + try { + return new URL(trimmed).protocol === MESSAGE_LINK_SCHEME; + } catch { + return false; + } +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index e293aa9ab..5f3fb526a 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -6,7 +6,12 @@ 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 { Selection, TextSelection } from "@tiptap/pm/state"; +import { + Plugin, + PluginKey, + Selection, + TextSelection, +} from "@tiptap/pm/state"; import { isMacPlatform } from "@/shared/lib/platform"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; @@ -23,6 +28,7 @@ import { handleCodeFenceEnter, insertNewlineInCodeBlock, } from "./codeBlockExtensions"; +import { isBuzzUrl } from "./messageLink"; /** * Plain-text edit descriptor returned by autocomplete hooks @@ -300,6 +306,37 @@ export function useRichTextEditor({ inclusive() { return false; }, + // linkifyjs (which TipTap's `linkOnPaste` uses) doesn't recognise + // `buzz://`, so wrap such links ourselves. Runs before the parent + // plugins; defers to TipTap's defaults otherwise. + addProseMirrorPlugins() { + const linkType = this.type; + const buzzLinkPaste = new Plugin({ + key: new PluginKey("buzzLinkOnPaste"), + props: { + handlePaste: (view, _event, slice) => { + const { selection } = view.state; + if (selection.empty) return false; + + let textContent = ""; + slice.content.forEach((node) => { + textContent += node.textContent; + }); + + // Only wrap when the whole payload is a single buzz:// URL. + const href = textContent.trim(); + if (!isBuzzUrl(href)) return false; + + return this.editor + .chain() + .setMark(linkType, { href }) + .run(); + }, + }, + }); + + return [buzzLinkPaste, ...(this.parent?.() ?? [])]; + }, }).configure({ openOnClick: false, autolink: true,