diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx
index 8d54889a2..867c7104c 100644
--- a/desktop/src/features/forum/ui/ForumComposer.tsx
+++ b/desktop/src/features/forum/ui/ForumComposer.tsx
@@ -10,7 +10,11 @@ import {
hasMentionClipboardHtml,
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
-import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
+import {
+ type LinkSelectionInfo,
+ useRichTextEditor,
+} from "@/features/messages/lib/useRichTextEditor";
+import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
@@ -77,6 +81,15 @@ export function ForumComposer({
const submitMessageRef = React.useRef<() => void>(() => {});
+ // Set after `useLinkEditor` exists; the editor's link-click handler
+ // delegates through this ref to break the hook ordering cycle.
+ const onEditLinkRef = React.useRef<
+ ((info: LinkSelectionInfo) => void) | null
+ >(null);
+ const onLinkSelectionChangeRef = React.useRef<
+ ((info: LinkSelectionInfo | null) => void) | null
+ >(null);
+
const richText = useRichTextEditor({
placeholder,
editable: !disabled,
@@ -84,6 +97,8 @@ export function ForumComposer({
channelNames: channelLinks.knownChannelNames,
onSubmit: () => submitMessageRef.current(),
isAutocompleteOpen: isAutocompleteOpenRef,
+ onEditLink: (info) => onEditLinkRef.current?.(info),
+ onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onUpdate: ({ markdown, text }) => {
setContent(markdown);
contentRef.current = markdown;
@@ -94,6 +109,10 @@ export function ForumComposer({
},
});
+ const linkEditor = useLinkEditor(richText);
+ onEditLinkRef.current = linkEditor.openFromClick;
+ onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
+
// ── Mention / channel autocomplete insertion ────────────────────────
// Native ProseMirror transactions — no markdown round-trip.
const applyMentionInsert = React.useCallback(
@@ -263,12 +282,22 @@ export function ForumComposer({
}
return;
}
+
+ if (event.key === "Tab" && !event.shiftKey && linkEditor.isCardOpen) {
+ event.preventDefault();
+ if (!linkEditor.focusCardFirstControl()) {
+ requestAnimationFrame(linkEditor.focusCardFirstControl);
+ }
+ return;
+ }
},
[
channelLinks.handleChannelKeyDown,
applyChannelInsert,
mentions.handleMentionKeyDown,
applyMentionInsert,
+ linkEditor.isCardOpen,
+ linkEditor.focusCardFirstControl,
],
);
@@ -379,97 +408,104 @@ export function ForumComposer({
}, [compact, isCompactExpanded, richText.focus]);
const autocompletePosition = autocompleteBelow ? "below" : "above";
return (
-
+
+ Cancel
+
+ ) : undefined
+ }
+ formattingDisabled={disabled ?? false}
+ isEmojiPickerOpen={isEmojiPickerOpen}
+ isFormattingOpen={isFormattingOpen}
+ isSending={isSending ?? false}
+ isUploading={media.isUploading}
+ onCaptureSelection={handleToolbarMouseDown}
+ onEmojiPickerOpenChange={setIsEmojiPickerOpen}
+ onEmojiSelect={insertEmoji}
+ onFormattingToggle={handleFormattingToggle}
+ onLinkButton={linkEditor.openFromToolbar}
+ onOpenMentionPicker={openMentionPicker}
+ onPaperclip={handlePaperclipClick}
+ sendDisabled={sendDisabled}
+ />
+ >
+ )}
+
+ {linkEditor.card}
+ {linkEditor.dialog}
+ >
);
}
diff --git a/desktop/src/features/messages/lib/linkEditorFocus.test.mjs b/desktop/src/features/messages/lib/linkEditorFocus.test.mjs
new file mode 100644
index 000000000..89823a939
--- /dev/null
+++ b/desktop/src/features/messages/lib/linkEditorFocus.test.mjs
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { getLinkEditorInitialFocus } from "./linkEditorFocus.ts";
+
+test("focuses URL when adding a link to selected text", () => {
+ assert.equal(
+ getLinkEditorInitialFocus({
+ href: "",
+ text: "selected text",
+ from: 3,
+ to: 16,
+ }),
+ "url",
+ );
+});
+
+test("focuses display text when editing an existing link", () => {
+ assert.equal(
+ getLinkEditorInitialFocus({
+ href: "https://example.com",
+ text: "existing link",
+ from: 3,
+ to: 16,
+ }),
+ "text",
+ );
+});
+
+test("focuses display text when inserting a link with no selected text", () => {
+ assert.equal(
+ getLinkEditorInitialFocus({
+ href: "",
+ text: "",
+ from: 3,
+ to: 3,
+ }),
+ "text",
+ );
+});
+
+test("focuses display text when the selected text is whitespace only", () => {
+ assert.equal(
+ getLinkEditorInitialFocus({
+ href: "",
+ text: " ",
+ from: 3,
+ to: 6,
+ }),
+ "text",
+ );
+});
diff --git a/desktop/src/features/messages/lib/linkEditorFocus.ts b/desktop/src/features/messages/lib/linkEditorFocus.ts
new file mode 100644
index 000000000..508ad5d8a
--- /dev/null
+++ b/desktop/src/features/messages/lib/linkEditorFocus.ts
@@ -0,0 +1,14 @@
+import type { LinkSelectionInfo } from "./useRichTextEditor";
+
+export type LinkEditorInitialFocus = "text" | "url";
+
+export function getLinkEditorInitialFocus(
+ info: LinkSelectionInfo,
+): LinkEditorInitialFocus {
+ const isSelectedTextLinkInsert =
+ info.href.length === 0 &&
+ info.text.trim().length > 0 &&
+ info.from !== info.to;
+
+ return isSelectedTextLinkInsert ? "url" : "text";
+}
diff --git a/desktop/src/features/messages/lib/linkInteractionExtension.ts b/desktop/src/features/messages/lib/linkInteractionExtension.ts
new file mode 100644
index 000000000..033f603a1
--- /dev/null
+++ b/desktop/src/features/messages/lib/linkInteractionExtension.ts
@@ -0,0 +1,142 @@
+import { Extension } from "@tiptap/core";
+import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
+import type { EditorState } from "@tiptap/pm/state";
+import type { EditorView } from "@tiptap/pm/view";
+
+import { resolveLinkAt, type LinkSelectionInfo } from "./resolveLinkAt";
+
+type LinkInteractionExtensionOptions = {
+ getEditLinkHandler: () => ((info: LinkSelectionInfo) => void) | undefined;
+ getSelectionChangeHandler: () =>
+ | ((info: LinkSelectionInfo | null) => void)
+ | undefined;
+};
+
+export const linkInteractionKey = new PluginKey("linkInteraction");
+
+/**
+ * Centralises composer link interactions that depend on ProseMirror editor
+ * state: click interception, click-vs-drag selection preservation, and active
+ * link reporting as the selection moves.
+ */
+export function createLinkInteractionExtension({
+ getEditLinkHandler,
+ getSelectionChangeHandler,
+}: LinkInteractionExtensionOptions) {
+ return Extension.create({
+ name: "linkInteraction",
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: linkInteractionKey,
+ view(view) {
+ notifyLinkSelection(view.state, getSelectionChangeHandler);
+
+ return {
+ update(updatedView, previousState) {
+ const nextState = updatedView.state;
+ if (
+ previousState.selection.eq(nextState.selection) &&
+ previousState.doc.eq(nextState.doc)
+ ) {
+ return;
+ }
+ notifyLinkSelection(nextState, getSelectionChangeHandler);
+ },
+ };
+ },
+ props: {
+ handleDOMEvents: {
+ // Native anchor default can still win in the WebView before
+ // ProseMirror's semantic click hook runs, so intercept editor
+ // links at the DOM event layer and route them to composer-local
+ // controls.
+ click(view, event) {
+ if (!(event instanceof MouseEvent)) return false;
+ const target = event.target;
+ if (!(target instanceof Element)) return false;
+ const anchor = target.closest("a[href]");
+ if (!anchor || !view.dom.contains(anchor)) return false;
+
+ const position = view.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ });
+ if (!position) {
+ event.preventDefault();
+ event.stopPropagation();
+ return true;
+ }
+ return handleLinkClick({
+ event,
+ getEditLinkHandler,
+ pos: position.pos,
+ view,
+ });
+ },
+ },
+ // Click on an existing link -> surface composer-local link controls.
+ // The link extension is configured `openOnClick: false` (never
+ // navigate away from a chat composer), so without this hook a click
+ // on a link does nothing.
+ handleClick(view, pos, event) {
+ return handleLinkClick({
+ event,
+ getEditLinkHandler,
+ pos,
+ view,
+ });
+ },
+ },
+ }),
+ ];
+ },
+ });
+}
+
+function notifyLinkSelection(
+ state: EditorState,
+ getSelectionChangeHandler: LinkInteractionExtensionOptions["getSelectionChangeHandler"],
+) {
+ const handler = getSelectionChangeHandler();
+ if (!handler) return;
+ if (!state.selection.empty) {
+ handler(null);
+ return;
+ }
+ handler(resolveLinkAt(state, state.selection.from));
+}
+
+function handleLinkClick({
+ event,
+ getEditLinkHandler,
+ pos,
+ view,
+}: {
+ event: MouseEvent;
+ getEditLinkHandler: LinkInteractionExtensionOptions["getEditLinkHandler"];
+ pos: number;
+ view: EditorView;
+}): boolean {
+ const handler = getEditLinkHandler();
+ if (!handler) return false;
+
+ const info = resolveLinkAt(view.state, pos);
+ if (!info) return false;
+
+ event.preventDefault();
+ event.stopPropagation();
+ if (!view.state.selection.empty) {
+ return true;
+ }
+
+ view.dispatch(
+ view.state.tr
+ .setSelection(TextSelection.create(view.state.doc, pos))
+ .scrollIntoView(),
+ );
+ view.focus();
+ handler(info);
+ return true;
+}
diff --git a/desktop/src/features/messages/lib/openPopoverLink.test.mjs b/desktop/src/features/messages/lib/openPopoverLink.test.mjs
new file mode 100644
index 000000000..b9d827378
--- /dev/null
+++ b/desktop/src/features/messages/lib/openPopoverLink.test.mjs
@@ -0,0 +1,53 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { openPopoverLink } from "./openPopoverLink.ts";
+
+const CHANNEL = "f570339f-8f8a-4e08-a779-8d954aa44109";
+const MESSAGE =
+ "b04819ffc1f7c8ffb49c6d30b5899f470198264680d02e78894a658e30a9059f";
+
+function makeSpies() {
+ const external = [];
+ const inApp = [];
+ return {
+ handlers: {
+ openExternal: (url) => external.push(url),
+ openMessageLink: (link) => inApp.push(link),
+ },
+ external,
+ inApp,
+ };
+}
+
+test("buzz://message deep-link routes in-app, not the OS opener", () => {
+ const { handlers, external, inApp } = makeSpies();
+ openPopoverLink(`buzz://message?channel=${CHANNEL}&id=${MESSAGE}`, handlers);
+ assert.equal(external.length, 0);
+ assert.deepEqual(inApp, [
+ { channelId: CHANNEL, messageId: MESSAGE, threadRootId: null },
+ ]);
+});
+
+test("http(s) URLs go to the OS opener", () => {
+ const { handlers, external, inApp } = makeSpies();
+ openPopoverLink("https://example.com/path", handlers);
+ assert.deepEqual(external, ["https://example.com/path"]);
+ assert.equal(inApp.length, 0);
+});
+
+test("non-message buzz:// URLs fall through to the OS opener", () => {
+ const { handlers, external, inApp } = makeSpies();
+ openPopoverLink("buzz://channel?foo=bar", handlers);
+ assert.deepEqual(external, ["buzz://channel?foo=bar"]);
+ assert.equal(inApp.length, 0);
+});
+
+test("malformed buzz://message URL falls back to the OS opener", () => {
+ const { handlers, external, inApp } = makeSpies();
+ // Matches isMessageLink (starts with buzz://message?) but is missing the
+ // required channel/id params, so parse fails and we don't navigate in-app.
+ openPopoverLink("buzz://message?nope=1", handlers);
+ assert.deepEqual(external, ["buzz://message?nope=1"]);
+ assert.equal(inApp.length, 0);
+});
diff --git a/desktop/src/features/messages/lib/openPopoverLink.ts b/desktop/src/features/messages/lib/openPopoverLink.ts
new file mode 100644
index 000000000..8440abd09
--- /dev/null
+++ b/desktop/src/features/messages/lib/openPopoverLink.ts
@@ -0,0 +1,25 @@
+import { isMessageLink, parseMessageLink } from "./messageLink";
+import type { ParsedMessageLink } from "./messageLink";
+
+/**
+ * Open a link the same way the rendered-message link path does:
+ * `buzz://message?…` deep-links navigate in-app, everything else (http(s),
+ * other buzz:// URLs) goes to the OS opener. Mirrors `markdown.tsx`'s `a`
+ * renderer so the composer popover and the rendered link behave identically.
+ */
+export function openPopoverLink(
+ url: string,
+ handlers: {
+ openExternal: (url: string) => void;
+ openMessageLink: (link: ParsedMessageLink) => void;
+ },
+): void {
+ if (isMessageLink(url)) {
+ const parsed = parseMessageLink(url);
+ if (parsed.ok) {
+ handlers.openMessageLink(parsed.value);
+ return;
+ }
+ }
+ handlers.openExternal(url);
+}
diff --git a/desktop/src/features/messages/lib/resolveLinkAt.test.mjs b/desktop/src/features/messages/lib/resolveLinkAt.test.mjs
new file mode 100644
index 000000000..a0f6505a2
--- /dev/null
+++ b/desktop/src/features/messages/lib/resolveLinkAt.test.mjs
@@ -0,0 +1,124 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { Schema } from "@tiptap/pm/model";
+import { EditorState } from "@tiptap/pm/state";
+
+import { resolveLinkAt } from "./resolveLinkAt.ts";
+
+// Minimal schema mirroring the editor's relevant pieces: a link mark with an
+// href plus a bold mark, so a single link can be split across text nodes.
+const schema = new Schema({
+ nodes: {
+ doc: { content: "block+" },
+ paragraph: { group: "block", content: "inline*" },
+ text: { group: "inline" },
+ },
+ marks: {
+ link: { attrs: { href: {} }, inclusive: false },
+ bold: {},
+ },
+});
+
+const link = (href) => schema.marks.link.create({ href });
+const bold = schema.marks.bold.create();
+
+function stateFromParagraph(nodes) {
+ const doc = schema.node("doc", null, [schema.node("paragraph", null, nodes)]);
+ return EditorState.create({ doc });
+}
+
+// ── resolveLinkAt ─────────────────────────────────────────────────────
+
+test("resolves a plain single-node link", () => {
+ const href = "https://example.com";
+ const state = stateFromParagraph([schema.text("click here", [link(href)])]);
+
+ // pos 1 is the start of the paragraph content (inside the link text).
+ const info = resolveLinkAt(state, 3);
+ assert.ok(info);
+ assert.equal(info.href, href);
+ assert.equal(info.text, "click here");
+ assert.equal(info.from, 1);
+ assert.equal(info.to, 1 + "click here".length);
+});
+
+test("resolves a link split across mixed-formatting text nodes", () => {
+ const href = "https://example.com";
+ // "see " + bold "this" + " link" — three text nodes, one visual link.
+ const state = stateFromParagraph([
+ schema.text("see ", [link(href)]),
+ schema.text("this", [link(href), bold]),
+ schema.text(" link", [link(href)]),
+ ]);
+
+ const full = "see this link";
+ // Click lands inside the bold fragment in the middle.
+ const info = resolveLinkAt(state, 7);
+ assert.ok(info);
+ assert.equal(info.href, href);
+ // The whole link must be recovered, not just the bold fragment.
+ assert.equal(info.text, full);
+ assert.equal(info.from, 1);
+ assert.equal(info.to, 1 + full.length);
+});
+
+test("does not extend across an adjacent link with a different href", () => {
+ const a = "https://a.com";
+ const b = "https://b.com";
+ const state = stateFromParagraph([
+ schema.text("alpha", [link(a)]),
+ schema.text("beta", [link(b)]),
+ ]);
+
+ const info = resolveLinkAt(state, 3);
+ assert.ok(info);
+ assert.equal(info.href, a);
+ assert.equal(info.text, "alpha");
+ assert.equal(info.from, 1);
+ assert.equal(info.to, 1 + "alpha".length);
+});
+
+test("anchors on the left link at the seam between two adjacent links", () => {
+ const a = "https://a.com";
+ const b = "https://b.com";
+ // "alpha" (link a) immediately followed by "beta" (link b). The seam sits
+ // at pos 6 (paragraph start 1 + "alpha".length). Both child spans touch
+ // that position; the caret there belongs to the character before it, so we
+ // must resolve to link `a`, not whichever span the iteration visits last.
+ const state = stateFromParagraph([
+ schema.text("alpha", [link(a)]),
+ schema.text("beta", [link(b)]),
+ ]);
+
+ const seam = 1 + "alpha".length;
+ const info = resolveLinkAt(state, seam);
+ assert.ok(info);
+ assert.equal(info.href, a);
+ assert.equal(info.text, "alpha");
+ assert.equal(info.from, 1);
+ assert.equal(info.to, seam);
+});
+
+test("resolves a link when the caret sits at its very start", () => {
+ const href = "https://example.com";
+ // Plain text, then the link — caret at the link's left edge has no link
+ // mark on the character *before* it, exercising the `pos + 1` fallback.
+ const state = stateFromParagraph([
+ schema.text("go "),
+ schema.text("here", [link(href)]),
+ ]);
+
+ const linkStart = 1 + "go ".length;
+ const info = resolveLinkAt(state, linkStart);
+ assert.ok(info);
+ assert.equal(info.href, href);
+ assert.equal(info.text, "here");
+ assert.equal(info.from, linkStart);
+ assert.equal(info.to, linkStart + "here".length);
+});
+
+test("returns null when position is not inside a link", () => {
+ const state = stateFromParagraph([schema.text("no link here")]);
+ assert.equal(resolveLinkAt(state, 3), null);
+});
diff --git a/desktop/src/features/messages/lib/resolveLinkAt.ts b/desktop/src/features/messages/lib/resolveLinkAt.ts
new file mode 100644
index 000000000..769a1f4bf
--- /dev/null
+++ b/desktop/src/features/messages/lib/resolveLinkAt.ts
@@ -0,0 +1,92 @@
+import type { EditorState } from "@tiptap/pm/state";
+
+export type LinkSelectionInfo = {
+ href: string;
+ text: string;
+ from: number;
+ to: number;
+};
+
+/**
+ * Resolve the link mark covering position `pos`, expanded to the full
+ * contiguous range of that same link. Returns the href, the covered text,
+ * and the `from`/`to` document positions, or `null` when `pos` is not
+ * inside a link.
+ *
+ * ProseMirror stores links as marks on text nodes, so one visual link can
+ * span several adjacent text nodes when its text carries mixed formatting
+ * (bold/italic/code). We extend outward from the child under `pos` across
+ * every contiguous sibling carrying the same link href, so Edit/Remove
+ * operate on the whole link rather than a single fragment.
+ */
+export function resolveLinkAt(
+ state: EditorState,
+ pos: number,
+): LinkSelectionInfo | null {
+ const linkType = state.schema.marks.link;
+ if (!linkType) return null;
+
+ const $pos = state.doc.resolve(pos);
+ // A caret belongs to the character *before* it, so resolve the link from
+ // `nodeBefore` first. This disambiguates the seam between two adjacent
+ // links: at that boundary both `marks()` and `nodeAfter` point at the
+ // right-hand link, but the caret should anchor on the left one. When the
+ // position is inside a text node (not on a boundary) `nodeBefore`/
+ // `nodeAfter` are null, so we fall back to `marks()` for the mid-node case
+ // and to `nodeAfter` for the caret at a link's very left edge.
+ const markBefore = $pos.nodeBefore
+ ? linkType.isInSet($pos.nodeBefore.marks)
+ : null;
+ const markAfter = $pos.nodeAfter
+ ? linkType.isInSet($pos.nodeAfter.marks)
+ : null;
+ const onBoundary = $pos.nodeBefore != null || $pos.nodeAfter != null;
+ const mark = onBoundary
+ ? markBefore || markAfter
+ : linkType.isInSet($pos.marks());
+ if (!mark) return null;
+
+ const rawHref = mark.attrs.href;
+ if (typeof rawHref !== "string") return null;
+ const href = rawHref;
+ const parent = $pos.parent;
+ const parentStart = $pos.start();
+
+ type ChildSpan = { from: number; to: number; hasLink: boolean };
+ const spans: ChildSpan[] = [];
+ // Adjacent text nodes share a boundary (span N's `to` equals span N+1's
+ // `from`), so a `pos` landing exactly on a seam falls inside both. Picking
+ // the last match there would anchor on the wrong node when two links with
+ // different hrefs abut. We resolve the ambiguity in two passes: first try
+ // the span that both contains `pos` and carries our target link; only if
+ // none does (e.g. caret resting just past a link) fall back to a plain
+ // containment test with an exclusive right edge so the seam belongs to the
+ // node on its left.
+ let anchorIndex = -1;
+ let linkAnchorIndex = -1;
+ parent.forEach((child, childOffset) => {
+ const childFrom = parentStart + childOffset;
+ const childTo = childFrom + child.nodeSize;
+ const childLink = linkType.isInSet(child.marks);
+ const hasLink = childLink != null && childLink.attrs.href === href;
+ const index = spans.length;
+ if (childFrom <= pos && pos < childTo) anchorIndex = index;
+ if (hasLink && childFrom <= pos && pos <= childTo) linkAnchorIndex = index;
+ spans.push({ from: childFrom, to: childTo, hasLink });
+ });
+ if (linkAnchorIndex !== -1) anchorIndex = linkAnchorIndex;
+
+ if (anchorIndex === -1) return { href, text: "", from: pos, to: pos };
+
+ let from = spans[anchorIndex].from;
+ let to = spans[anchorIndex].to;
+ for (let i = anchorIndex - 1; i >= 0 && spans[i].hasLink; i--) {
+ from = spans[i].from;
+ }
+ for (let i = anchorIndex + 1; i < spans.length && spans[i].hasLink; i++) {
+ to = spans[i].to;
+ }
+
+ const text = state.doc.textBetween(from, to, "\n", "\n");
+ return { href, text, from, to };
+}
diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx
new file mode 100644
index 000000000..a623afc2b
--- /dev/null
+++ b/desktop/src/features/messages/lib/useLinkEditor.tsx
@@ -0,0 +1,441 @@
+import * as React from "react";
+import { openUrl } from "@tauri-apps/plugin-opener";
+import { Pencil, Unlink } from "lucide-react";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/ui/dialog";
+import { Button } from "@/shared/ui/button";
+import { Input } from "@/shared/ui/input";
+import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
+import { useAppNavigation } from "@/app/navigation/useAppNavigation";
+
+import type {
+ LinkSelectionInfo,
+ UseRichTextEditorResult,
+} from "./useRichTextEditor";
+import {
+ getLinkEditorInitialFocus,
+ type LinkEditorInitialFocus,
+} from "./linkEditorFocus";
+import { openPopoverLink } from "./openPopoverLink";
+
+type DraftState = {
+ text: string;
+ url: string;
+ from: number;
+ to: number;
+ /**
+ * Whether `from`/`to` point at a real document range. `false` for an
+ * empty-caret toolbar insert, where the range is resolved from the live
+ * selection at save time instead of the (placeholder) draft positions.
+ */
+ hasRange: boolean;
+ /** Whether the targeted range already carried a link (enables Remove). */
+ isExistingLink: boolean;
+ initialFocus: LinkEditorInitialFocus;
+};
+
+type LinkCardState = {
+ info: LinkSelectionInfo;
+ rect: {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+ };
+};
+
+/**
+ * Owns composer link controls. Existing links show an anchored hover card on
+ * click/caret movement; the card can remove the link, open its URL, or enter
+ * the existing shadcn dialog to edit display text and URL. Toolbar-created
+ * links still open the dialog directly.
+ *
+ * Returns:
+ * - `openFromToolbar` — wire to the formatting toolbar's link button. Seeds
+ * the dialog from the current selection (existing link or selected text).
+ * - `openFromClick` — wire to `useRichTextEditor`'s `onEditLink`. Moves the
+ * clicked link into the hover-card state.
+ * - `showFromCursor` — wire to cursor/selection updates to show the same card
+ * when arrow-key movement lands inside a link.
+ * - `card`/`dialog` — render once inside the composer tree.
+ */
+export function useLinkEditor(richText: UseRichTextEditorResult) {
+ const { getLinkSelectionInfo, applyLink, removeLink } = richText;
+ const { goChannel } = useAppNavigation();
+ const [draft, setDraft] = React.useState(null);
+ const [cardState, setCardState] = React.useState(null);
+ const cardContentRef = React.useRef(null);
+ const textId = React.useId();
+ const urlId = React.useId();
+
+ const getLinkRect = React.useCallback(
+ (info: LinkSelectionInfo): LinkCardState["rect"] | null => {
+ const editor = richText.editor;
+ if (!editor) return null;
+
+ try {
+ const range = document.createRange();
+ const start = editor.view.domAtPos(info.from);
+ const end = editor.view.domAtPos(info.to);
+ range.setStart(start.node, start.offset);
+ range.setEnd(end.node, end.offset);
+
+ const rect = range.getBoundingClientRect();
+ range.detach();
+ if (rect.width > 0 || rect.height > 0) {
+ return {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ };
+ }
+ } catch {
+ // Fall through to the caret coordinates below.
+ }
+
+ const coords = editor.view.coordsAtPos(info.from);
+ return {
+ left: coords.left,
+ top: coords.top,
+ width: Math.max(1, coords.right - coords.left),
+ height: Math.max(1, coords.bottom - coords.top),
+ };
+ },
+ [richText.editor],
+ );
+
+ const showCard = React.useCallback(
+ (info: LinkSelectionInfo | null) => {
+ if (!info) {
+ setCardState(null);
+ return;
+ }
+
+ const rect = getLinkRect(info);
+ if (!rect) {
+ setCardState(null);
+ return;
+ }
+
+ setCardState((prev) => {
+ const sameLink =
+ prev?.info.href === info.href &&
+ prev.info.from === info.from &&
+ prev.info.to === info.to &&
+ prev.info.text === info.text;
+ const sameRect =
+ prev?.rect.left === rect.left &&
+ prev.rect.top === rect.top &&
+ prev.rect.width === rect.width &&
+ prev.rect.height === rect.height;
+ if (sameLink && sameRect) return prev;
+ return { info, rect };
+ });
+ },
+ [getLinkRect],
+ );
+
+ const openDialogFromInfo = React.useCallback((info: LinkSelectionInfo) => {
+ setDraft({
+ text: info.text,
+ url: info.href,
+ from: info.from,
+ to: info.to,
+ hasRange: true,
+ isExistingLink: info.href.length > 0,
+ initialFocus: getLinkEditorInitialFocus(info),
+ });
+ }, []);
+
+ const openFromClick = React.useCallback(
+ (info: LinkSelectionInfo) => {
+ showCard(info);
+ },
+ [showCard],
+ );
+
+ const openFromToolbar = React.useCallback(() => {
+ const info = getLinkSelectionInfo();
+ if (info) {
+ openDialogFromInfo(info);
+ return;
+ }
+ // No selection and no link under the caret — open an empty modal that
+ // inserts a fresh link at the caret on save.
+ setDraft({
+ text: "",
+ url: "",
+ from: 0,
+ to: 0,
+ hasRange: false,
+ isExistingLink: false,
+ initialFocus: "text",
+ });
+ }, [getLinkSelectionInfo, openDialogFromInfo]);
+
+ const close = React.useCallback(() => setDraft(null), []);
+
+ const closeCard = React.useCallback(() => setCardState(null), []);
+
+ const save = React.useCallback(() => {
+ if (!draft) return;
+ const url = draft.url.trim();
+ if (!url) return;
+ if (draft.hasRange) {
+ applyLink({
+ href: url,
+ text: draft.text,
+ from: draft.from,
+ to: draft.to,
+ });
+ } else {
+ // Empty-caret insert: prefer the live selection range; if there's no
+ // selection, omit the range so `applyLink` inserts at the caret rather
+ // than at the placeholder doc position 0.
+ const info = getLinkSelectionInfo();
+ applyLink({
+ href: url,
+ text: draft.text,
+ from: info?.from,
+ to: info?.to,
+ });
+ }
+ close();
+ }, [draft, applyLink, getLinkSelectionInfo, close]);
+
+ const remove = React.useCallback(() => {
+ if (!draft) return;
+ removeLink({ from: draft.from, to: draft.to });
+ close();
+ }, [draft, removeLink, close]);
+
+ const removeFromCard = React.useCallback(() => {
+ if (!cardState) return;
+ removeLink({ from: cardState.info.from, to: cardState.info.to });
+ closeCard();
+ }, [cardState, removeLink, closeCard]);
+
+ const editFromCard = React.useCallback(() => {
+ if (!cardState) return;
+ openDialogFromInfo(cardState.info);
+ closeCard();
+ }, [cardState, openDialogFromInfo, closeCard]);
+
+ // Card URL click: route `buzz://message?…` deep-links in-app (matching the
+ // rendered-message link path), everything else to the OS opener.
+ const openCardUrl = React.useCallback(
+ (event: React.MouseEvent) => {
+ const href = cardState?.info.href.trim();
+ if (!href) return;
+ event.preventDefault();
+ openPopoverLink(href, {
+ openExternal: (url) => void openUrl(url),
+ openMessageLink: (link) =>
+ void goChannel(link.channelId, {
+ messageId: link.messageId,
+ threadRootId: link.threadRootId,
+ }),
+ });
+ },
+ [cardState, goChannel],
+ );
+
+ const refreshCardRect = React.useCallback(() => {
+ setCardState((prev) => {
+ if (!prev) return prev;
+ const rect = getLinkRect(prev.info);
+ return rect ? { ...prev, rect } : null;
+ });
+ }, [getLinkRect]);
+
+ React.useEffect(() => {
+ if (!cardState) return;
+
+ window.addEventListener("resize", refreshCardRect);
+ window.addEventListener("scroll", refreshCardRect, true);
+
+ return () => {
+ window.removeEventListener("resize", refreshCardRect);
+ window.removeEventListener("scroll", refreshCardRect, true);
+ };
+ }, [cardState, refreshCardRect]);
+
+ const focusCardFirstControl = React.useCallback((): boolean => {
+ if (!cardState) return false;
+ const target = cardContentRef.current?.querySelector(
+ "a[href], button:not([disabled])",
+ );
+ if (!target) return false;
+ target.focus();
+ return true;
+ }, [cardState]);
+
+ const card = (
+ {
+ if (!open) closeCard();
+ }}
+ >
+ {cardState ? (
+
+
+
+ ) : null}
+ {cardState ? (
+ event.preventDefault()}
+ onInteractOutside={(event) => {
+ const target = event.detail.originalEvent.target;
+ if (
+ target instanceof Element &&
+ target.closest(".rich-text-composer a[href]")
+ ) {
+ event.preventDefault();
+ }
+ }}
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ ref={cardContentRef}
+ side="top"
+ sideOffset={8}
+ >
+
+
+ ) : null}
+
+ );
+
+ const dialog = (
+
+ );
+
+ return {
+ openFromToolbar,
+ openFromClick,
+ showFromCursor: showCard,
+ focusCardFirstControl,
+ isCardOpen: cardState !== null,
+ card,
+ dialog,
+ };
+}
diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts
index d54e3c8a2..9b294f9d6 100644
--- a/desktop/src/features/messages/lib/useRichTextEditor.ts
+++ b/desktop/src/features/messages/lib/useRichTextEditor.ts
@@ -11,6 +11,10 @@ import { Selection, TextSelection } from "@tiptap/pm/state";
import { isMacPlatform } from "@/shared/lib/platform";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
+import { resolveLinkAt, type LinkSelectionInfo } from "./resolveLinkAt";
+
+export type { LinkSelectionInfo } from "./resolveLinkAt";
+
import { MESSAGE_MARKDOWN_CLASS } from "@/shared/ui/mentionChip";
import {
@@ -20,6 +24,7 @@ import {
import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode";
import { useComposerCustomEmoji } from "./useComposerCustomEmoji";
import { buildPlainTextProjection } from "./plainTextProjection";
+import { createLinkInteractionExtension } from "./linkInteractionExtension";
import {
CodeBlockAfterHardBreak,
handleCodeFenceEnter,
@@ -74,6 +79,19 @@ export type RichTextEditorOptions = {
onEditLastOwnMessage?: () => boolean;
/** When true, plain Enter is passed through (e.g. to select an autocomplete item). */
isAutocompleteOpen?: React.RefObject;
+ /**
+ * Called when the user clicks an existing link in the editor. The link
+ * extension runs with `openOnClick: false` (a chat composer must not
+ * navigate away on click), so we route the click here instead: the owner
+ * can surface composer-local link controls. `from`/`to` bound the full link
+ * mark range so the owner can apply edits without re-selecting.
+ */
+ onEditLink?: (info: LinkSelectionInfo) => void;
+ /**
+ * Called when the caret/selection moves onto or away from a link. Owners use
+ * this for link affordances that follow keyboard cursor movement.
+ */
+ onLinkSelectionChange?: (info: LinkSelectionInfo | null) => void;
};
/**
@@ -96,6 +114,8 @@ export function useRichTextEditor({
onSubmit,
onEditLastOwnMessage,
isAutocompleteOpen,
+ onEditLink,
+ onLinkSelectionChange,
}: RichTextEditorOptions) {
const onUpdateRef = React.useRef(onUpdate);
onUpdateRef.current = onUpdate;
@@ -106,6 +126,12 @@ export function useRichTextEditor({
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
onEditLastOwnMessageRef.current = onEditLastOwnMessage;
+ const onEditLinkRef = React.useRef(onEditLink);
+ onEditLinkRef.current = onEditLink;
+
+ const onLinkSelectionChangeRef = React.useRef(onLinkSelectionChange);
+ onLinkSelectionChangeRef.current = onLinkSelectionChange;
+
const placeholderRef = React.useRef(placeholder);
placeholderRef.current = placeholder;
@@ -313,9 +339,13 @@ export function useRichTextEditor({
// stripped on paste/typed input.
protocols: ["buzz"],
HTMLAttributes: {
- class: "text-primary underline underline-offset-4 cursor-pointer",
+ class: "text-primary underline underline-offset-4 cursor-text",
},
}),
+ createLinkInteractionExtension({
+ getEditLinkHandler: () => onEditLinkRef.current,
+ getSelectionChangeHandler: () => onLinkSelectionChangeRef.current,
+ }),
TiptapMarkdown.configure({
html: false,
transformPastedText: true,
@@ -585,6 +615,81 @@ export function useRichTextEditor({
[editor, customEmojiWiring.resolveUrl],
);
+ /**
+ * Link mark info for the current selection — its href and the covered
+ * text, expanded to the full link range when the caret merely sits inside
+ * a link. Returns `null` when there is no link. Used to prefill the
+ * link-edit modal when the user clicks the link toolbar button.
+ */
+ const getLinkSelectionInfo =
+ React.useCallback((): LinkSelectionInfo | null => {
+ if (!editor) return null;
+ const { from, to } = editor.state.selection;
+ const onLink = resolveLinkAt(editor.state, from);
+ if (onLink) return onLink;
+ if (from === to) return null;
+ // No existing link, but text is selected — seed the modal with the
+ // selected text as the display value and the selection range.
+ const text = editor.state.doc.textBetween(from, to, "\n", "\n");
+ return { href: "", text, from, to };
+ }, [editor]);
+
+ /**
+ * Apply a link to the given range, replacing the covered text with
+ * `text` and marking it with `href`. When `range` is omitted (an
+ * empty-caret insert with no live selection), the link is inserted at the
+ * current caret — never at the placeholder position `0`, which sits
+ * outside the document content. When `from === to`, the linked text is
+ * inserted at that point. Used by both the toolbar button and the
+ * click-to-edit modal.
+ */
+ const applyLink = React.useCallback(
+ ({
+ href,
+ text,
+ from,
+ to,
+ }: {
+ href: string;
+ text: string;
+ from?: number;
+ to?: number;
+ }) => {
+ if (!editor) return;
+ // Default to the live caret when no range is supplied, so an
+ // empty-caret insert lands at the cursor rather than doc position 0.
+ const selection = editor.state.selection;
+ const start = from ?? selection.from;
+ const end = to ?? start;
+ const label = text.trim().length > 0 ? text : href;
+ const linkMark = editor.schema.marks.link.create({ href });
+ const node = editor.schema.text(label, [linkMark]);
+ const tr = editor.state.tr.replaceRangeWith(start, end, node);
+ const cursorPM = tr.mapping.map(end);
+ tr.setSelection(TextSelection.create(tr.doc, cursorPM));
+ editor.view.dispatch(tr);
+ editor.view.focus();
+ },
+ [editor],
+ );
+
+ /**
+ * Remove the link mark across the given range, leaving the text in place.
+ */
+ const removeLink = React.useCallback(
+ ({ from, to }: { from: number; to: number }) => {
+ if (!editor) return;
+ editor
+ .chain()
+ .focus()
+ .setTextSelection({ from, to })
+ .unsetLink()
+ .setTextSelection(to)
+ .run();
+ },
+ [editor],
+ );
+
return {
editor,
getMarkdown,
@@ -596,6 +701,9 @@ export function useRichTextEditor({
focusPreserve,
getPlainTextAndCursor,
replacePlainTextRange,
+ getLinkSelectionInfo,
+ applyLink,
+ removeLink,
};
}
diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx
index e1b5762d7..63d41145e 100644
--- a/desktop/src/features/messages/ui/FormattingToolbar.tsx
+++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx
@@ -20,6 +20,12 @@ import { SPOILER_MARK_NAME } from "@/features/messages/lib/spoilerMark";
type FormattingToolbarProps = {
editor: Editor | null;
disabled?: boolean;
+ /**
+ * Opens the link-edit modal for the current selection. When provided, the
+ * link button routes here instead of the legacy `window.prompt` flow
+ * (a no-op in the Tauri WebView).
+ */
+ onLinkButton?: () => void;
};
export type SpoilerToggleState = {
@@ -108,6 +114,7 @@ export function toggleSpoilerFormatting(editor: Editor): SpoilerToggleState {
export const FormattingToolbar = React.memo(function FormattingToolbar({
editor,
disabled = false,
+ onLinkButton,
}: FormattingToolbarProps) {
const [activeStates, setActiveStates] = React.useState(
() => (editor ? getActiveStates(editor) : null),
@@ -152,6 +159,15 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
const toggleLink = React.useCallback(() => {
if (!editor) return;
+ // Preferred path: open the link-edit modal, which handles add, edit, and
+ // remove with proper display-text + URL fields.
+ if (onLinkButton) {
+ onLinkButton();
+ return;
+ }
+
+ // Legacy fallback (no modal wired): the native prompts below are a no-op
+ // in the Tauri WebView, so this path effectively does nothing there.
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
return;
@@ -172,7 +188,7 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({
editor.chain().focus().insertContent(`[${label}](${url})`).run();
}
}
- }, [editor]);
+ }, [editor, onLinkButton]);
const toggleBulletList = React.useCallback(() => {
editor?.chain().focus().toggleBulletList().run();
diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index 8c02ae079..77320f556 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -31,8 +31,10 @@ import {
import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode";
import {
type AutocompleteEdit,
+ type LinkSelectionInfo,
useRichTextEditor,
} from "@/features/messages/lib/useRichTextEditor";
+import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
@@ -196,6 +198,16 @@ export function MessageComposer({
const submitMessageRef = React.useRef<() => void>(() => {});
const composerScrollRef = React.useRef(null);
+ // Set after `useLinkEditor` exists below; the editor's link-click handler
+ // delegates through this ref to break the hook ordering cycle (the editor
+ // needs `onEditLink`, but the link editor needs the editor's `richText`).
+ const onEditLinkRef = React.useRef<
+ ((info: LinkSelectionInfo) => void) | null
+ >(null);
+ const onLinkSelectionChangeRef = React.useRef<
+ ((info: LinkSelectionInfo | null) => void) | null
+ >(null);
+
const scrollComposerToBottom = React.useCallback(() => {
window.requestAnimationFrame(() => {
const scrollElement = composerScrollRef.current;
@@ -227,6 +239,8 @@ export function MessageComposer({
return handler ? handler() : false;
},
isAutocompleteOpen: isAutocompleteOpenRef,
+ onEditLink: (info) => onEditLinkRef.current?.(info),
+ onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onUpdate: ({ markdown, text }) => {
setContent(markdown);
contentRef.current = markdown;
@@ -245,6 +259,9 @@ export function MessageComposer({
},
});
+ const linkEditor = useLinkEditor(richText);
+ onEditLinkRef.current = linkEditor.openFromClick;
+ onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
useComposerSpoilerParticles(richText.editor, composerScrollRef);
const mentionSendFlow = useMentionSendFlow({
@@ -619,6 +636,14 @@ export function MessageComposer({
return;
}
+ if (event.key === "Tab" && !event.shiftKey && linkEditor.isCardOpen) {
+ event.preventDefault();
+ if (!linkEditor.focusCardFirstControl()) {
+ requestAnimationFrame(linkEditor.focusCardFirstControl);
+ }
+ return;
+ }
+
// Escape in edit mode
if (event.key === "Escape" && editTargetRef.current && onCancelEdit) {
event.preventDefault();
@@ -633,6 +658,8 @@ export function MessageComposer({
applyChannelInsert,
mentions.handleMentionKeyDown,
applyMentionInsert,
+ linkEditor.isCardOpen,
+ linkEditor.focusCardFirstControl,
onCancelEdit,
],
);
@@ -944,6 +971,7 @@ export function MessageComposer({
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}
+ onLinkButton={linkEditor.openFromToolbar}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
onSpoilerToggle={handleComposerSpoilerToggle}
@@ -963,6 +991,9 @@ export function MessageComposer({
onInvite={mentionSendFlow.inviteNonMembers}
open={mentionSendFlow.pendingNonMemberSend !== null}
/>
+
+ {linkEditor.card}
+ {linkEditor.dialog}
>
);
}
diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx
index 153fa8210..b47d2fa68 100644
--- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx
+++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx
@@ -42,6 +42,7 @@ export const MessageComposerToolbar = React.memo(
onEmojiPickerOpenChange,
onEmojiSelect,
onFormattingToggle,
+ onLinkButton,
onOpenMentionPicker,
onPaperclip,
onSpoilerToggle,
@@ -60,6 +61,7 @@ export const MessageComposerToolbar = React.memo(
onEmojiPickerOpenChange: (open: boolean) => void;
onEmojiSelect: (emoji: string) => void;
onFormattingToggle: (pressed: boolean) => void;
+ onLinkButton: () => void;
onOpenMentionPicker: () => void;
onPaperclip: () => void;
onSpoilerToggle?: (state: SpoilerToggleState) => void;
@@ -179,6 +181,7 @@ export const MessageComposerToolbar = React.memo(
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index 2c9353729..794c8389d 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -596,7 +596,7 @@
color: hsl(var(--primary));
text-decoration: underline;
text-underline-offset: 4px;
- cursor: pointer;
+ cursor: text;
}
.rich-text-composer .tiptap hr {
diff --git a/desktop/src/shared/ui/button.tsx b/desktop/src/shared/ui/button.tsx
index 577a56015..318642130 100644
--- a/desktop/src/shared/ui/button.tsx
+++ b/desktop/src/shared/ui/button.tsx
@@ -22,8 +22,10 @@ const buttonVariants = cva(
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
+ xs: "h-6 px-2 text-xs",
lg: "h-10 px-8",
icon: "h-8 w-8",
+ "icon-xs": "h-6 w-6 [&_svg]:size-3.5",
},
},
defaultVariants: {