From 1140a4c77f5562d0778b451126f01208cbf93764 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sun, 14 Jun 2026 11:11:53 -0700 Subject: [PATCH 1/2] feat(composer): wrap selection on buzz:// link paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting a buzz:// URL over a text selection clobbered it with raw text instead of wrapping it in a link, unlike standard https:// links. The cause: TipTap's built-in linkOnPaste handler uses linkifyjs to detect URLs, and linkifyjs doesn't recognise the buzz scheme. Add a custom ProseMirror paste plugin (prepended before TipTap's defaults) that detects any well-formed buzz:// URL and wraps the highlighted selection in a link mark — matching the standard-URL UX. Detection lives in a new isBuzzUrl() helper alongside the existing message-link parsing. Per tho: ALL buzz:// URLs wrap, not just the message-link format. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../messages/lib/messageLink.test.mjs | 26 +++++++++++ .../src/features/messages/lib/messageLink.ts | 19 ++++++++ .../messages/lib/useRichTextEditor.ts | 46 ++++++++++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) 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..9fe1be551 100644 --- a/desktop/src/features/messages/lib/messageLink.ts +++ b/desktop/src/features/messages/lib/messageLink.ts @@ -102,3 +102,22 @@ 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 `buzz://` URL (not just the message-link + * format). Used by the composer's wrap-on-paste handler: linkifyjs (which + * TipTap's built-in paste handler relies on) doesn't recognise the `buzz` + * scheme, so we detect it ourselves and wrap the highlighted selection. + * + * Validated via `URL` so we only treat genuinely well-formed `buzz://` URLs as + * links — a bare `buzz:` or malformed string won't match. + */ +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..3e88d530a 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,44 @@ export function useRichTextEditor({ inclusive() { return false; }, + // The built-in `linkOnPaste` handler detects URLs with linkifyjs, + // which only knows standard schemes (http/https/mailto) — so pasting + // a `buzz://` URL over a text selection clobbers it with raw text + // instead of wrapping it in a link. Prepend our own paste handler + // that recognises `buzz://` URLs and wraps the highlighted selection, + // matching the standard-URL UX. Runs before the parent plugins so it + // claims the paste first; otherwise we defer to TipTap's defaults. + addProseMirrorPlugins() { + const linkType = this.type; + const buzzLinkPaste = new Plugin({ + key: new PluginKey("buzzLinkOnPaste"), + props: { + handlePaste: (view, _event, slice) => { + const { selection } = view.state; + // Only wrap when there's a selection to wrap — empty + // selections fall through to TipTap's normal insert path. + if (selection.empty) return false; + + let textContent = ""; + slice.content.forEach((node) => { + textContent += node.textContent; + }); + + const href = textContent.trim(); + // The whole clipboard payload must be a single buzz:// URL — + // same contract as the built-in handler (`value === text`). + if (!isBuzzUrl(href)) return false; + + return this.editor + .chain() + .setMark(linkType, { href }) + .run(); + }, + }, + }); + + return [buzzLinkPaste, ...(this.parent?.() ?? [])]; + }, }).configure({ openOnClick: false, autolink: true, From c1d43210ffa38fddfc722117fabb1b239e4c33a6 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Sun, 14 Jun 2026 11:16:11 -0700 Subject: [PATCH 2/2] docs(composer): trim verbose buzz:// paste comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only pass — logic unchanged. Keeps the why (linkifyjs doesn't know buzz://) and drops the play-by-play. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/features/messages/lib/messageLink.ts | 9 ++------- .../features/messages/lib/useRichTextEditor.ts | 15 ++++----------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/messages/lib/messageLink.ts b/desktop/src/features/messages/lib/messageLink.ts index 9fe1be551..398f8154b 100644 --- a/desktop/src/features/messages/lib/messageLink.ts +++ b/desktop/src/features/messages/lib/messageLink.ts @@ -104,13 +104,8 @@ export function isMessageLink(href: string | undefined | null): boolean { } /** - * Returns true if `text` is any `buzz://` URL (not just the message-link - * format). Used by the composer's wrap-on-paste handler: linkifyjs (which - * TipTap's built-in paste handler relies on) doesn't recognise the `buzz` - * scheme, so we detect it ourselves and wrap the highlighted selection. - * - * Validated via `URL` so we only treat genuinely well-formed `buzz://` URLs as - * links — a bare `buzz:` or malformed string won't match. + * 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; diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 3e88d530a..5f3fb526a 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -306,13 +306,9 @@ export function useRichTextEditor({ inclusive() { return false; }, - // The built-in `linkOnPaste` handler detects URLs with linkifyjs, - // which only knows standard schemes (http/https/mailto) — so pasting - // a `buzz://` URL over a text selection clobbers it with raw text - // instead of wrapping it in a link. Prepend our own paste handler - // that recognises `buzz://` URLs and wraps the highlighted selection, - // matching the standard-URL UX. Runs before the parent plugins so it - // claims the paste first; otherwise we defer to TipTap's defaults. + // 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({ @@ -320,8 +316,6 @@ export function useRichTextEditor({ props: { handlePaste: (view, _event, slice) => { const { selection } = view.state; - // Only wrap when there's a selection to wrap — empty - // selections fall through to TipTap's normal insert path. if (selection.empty) return false; let textContent = ""; @@ -329,9 +323,8 @@ export function useRichTextEditor({ textContent += node.textContent; }); + // Only wrap when the whole payload is a single buzz:// URL. const href = textContent.trim(); - // The whole clipboard payload must be a single buzz:// URL — - // same contract as the built-in handler (`value === text`). if (!isBuzzUrl(href)) return false; return this.editor