Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions desktop/src/features/messages/lib/messageLink.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import test from "node:test";

import {
buildMessageLink,
isBuzzUrl,
isMessageLink,
parseMessageLink,
} from "./messageLink.ts";
Expand Down Expand Up @@ -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);
});
14 changes: 14 additions & 0 deletions desktop/src/features/messages/lib/messageLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
39 changes: 38 additions & 1 deletion desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +28,7 @@ import {
handleCodeFenceEnter,
insertNewlineInCodeBlock,
} from "./codeBlockExtensions";
import { isBuzzUrl } from "./messageLink";

/**
* Plain-text edit descriptor returned by autocomplete hooks
Expand Down Expand Up @@ -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,
Expand Down
Loading