fix: mentions survive copy/paste from chat into composer (#328)

This commit is contained in:
Taylor Ho
2026-04-15 13:48:36 -07:00
committed by GitHub
parent 3050531e7e
commit 0f93ef4471
8 changed files with 234 additions and 20 deletions
@@ -0,0 +1,25 @@
/**
* Escape special regex characters in a string.
*/
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Check whether `text` contains an @mention of `name`.
*
* Matches `@Name` preceded by start-of-string, whitespace, or markdown
* bold/italic markers (`*`, `**`, `***`, `_`, `__`, `___`). This handles
* the case where a mention is pasted from the chat area and TipTap's Bold
* extension wraps it in bold marks (font-weight >= 500 → bold).
*
* Exported separately so it can be unit-tested without importing React.
*/
export function hasMention(text: string, name: string): boolean {
const escaped = escapeRegExp(name);
const pattern = new RegExp(
`(?:^|\\s|[*_]{1,3})@${escaped}(?=[\\s,;.!?:)\\]}*_]|$)`,
"i",
);
return pattern.test(text);
}
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import { hasMentionClipboardHtml } from "./normalizeMentionClipboard.ts";
// NOTE: normalizeMentionClipboardHtml uses the browser DOMParser API which
// is not available in Node. Those paths are covered by the e2e paste tests.
// This file tests the pure string-matching detection function.
// ── hasMentionClipboardHtml ───────────────────────────────────────────
test("returns true when HTML contains data-mention", () => {
const html = '<span data-mention="" class="mention">@Alice</span>';
assert.equal(hasMentionClipboardHtml(html), true);
});
test("returns true when HTML contains data-channel-link", () => {
const html = '<button data-channel-link="">#general</button>';
assert.equal(hasMentionClipboardHtml(html), true);
});
test("returns true when HTML contains both markers", () => {
const html =
'<span data-mention="">@Alice</span> in <button data-channel-link="">#general</button>';
assert.equal(hasMentionClipboardHtml(html), true);
});
test("returns false for plain HTML without markers", () => {
const html = "<p>Hello world</p>";
assert.equal(hasMentionClipboardHtml(html), false);
});
test("returns false for empty string", () => {
assert.equal(hasMentionClipboardHtml(""), false);
});
test("returns false for text that mentions 'data-mention' as content", () => {
// Edge case: the literal string "data-mention" appears as text content,
// not as an attribute. hasMentionClipboardHtml does a simple string
// includes check, so this is a known false positive — acceptable because
// the normalization function is a no-op when no matching elements exist.
const html = "<p>The attribute is called data-mention</p>";
assert.equal(hasMentionClipboardHtml(html), true);
});
@@ -0,0 +1,30 @@
/**
* Detect whether clipboard HTML contains Sprout mention / channel-link
* elements (marked with `data-mention` or `data-channel-link` attributes).
*/
export function hasMentionClipboardHtml(html: string): boolean {
return html.includes("data-mention") || html.includes("data-channel-link");
}
/**
* Normalize clipboard HTML that contains Sprout mention / channel-link
* elements. Replaces the styled `<span data-mention>` and
* `<button data-channel-link>` wrappers with their plain text content so
* the resulting string is free of formatting that would confuse TipTap's
* Bold extension (which matches font-weight >= 500 as bold).
*
* Returns the flattened plain-text string ready for insertion into the
* editor.
*/
export function normalizeMentionClipboardHtml(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
for (const el of Array.from(
doc.querySelectorAll("[data-mention], [data-channel-link]"),
)) {
const text = doc.createTextNode(el.textContent ?? "");
el.replaceWith(text);
}
return doc.body.textContent ?? "";
}
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { hasMention } from "./hasMention.ts";
// ── Plain @mention ────────────────────────────────────────────────────
test("matches @Name at start of string", () => {
assert.equal(hasMention("@Alice hello", "Alice"), true);
});
test("matches @Name after whitespace", () => {
assert.equal(hasMention("hey @Alice", "Alice"), true);
});
test("matches @Name at end of string", () => {
assert.equal(hasMention("hello @Alice", "Alice"), true);
});
test("match is case-insensitive", () => {
assert.equal(hasMention("@alice", "Alice"), true);
assert.equal(hasMention("@ALICE", "Alice"), true);
});
test("does not match without @ prefix", () => {
assert.equal(hasMention("Alice hello", "Alice"), false);
});
test("does not match @Name embedded in a word (email-style)", () => {
assert.equal(hasMention("user@Alice.com", "Alice"), false);
});
// ── Bold-wrapped mentions (**@Name**) ─────────────────────────────────
test("matches **@Name** (bold-wrapped)", () => {
assert.equal(hasMention("**@Alice**", "Alice"), true);
});
test("matches **@Name** after whitespace", () => {
assert.equal(hasMention("hey **@Alice**", "Alice"), true);
});
test("matches *@Name* (italic-wrapped)", () => {
assert.equal(hasMention("*@Alice*", "Alice"), true);
});
test("matches ***@Name*** (bold+italic-wrapped)", () => {
assert.equal(hasMention("***@Alice***", "Alice"), true);
});
test("matches __@Name__ (underscore bold-wrapped)", () => {
assert.equal(hasMention("__@Alice__", "Alice"), true);
});
test("matches _@Name_ (underscore italic-wrapped)", () => {
assert.equal(hasMention("_@Alice_", "Alice"), true);
});
// ── Boundary conditions ───────────────────────────────────────────────
test("matches @Name followed by punctuation", () => {
assert.equal(hasMention("@Alice, hello", "Alice"), true);
assert.equal(hasMention("@Alice!", "Alice"), true);
assert.equal(hasMention("@Alice.", "Alice"), true);
assert.equal(hasMention("@Alice?", "Alice"), true);
});
test("matches multi-word display name", () => {
assert.equal(hasMention("@John Doe said hi", "John Doe"), true);
});
test("matches multi-word display name bold-wrapped", () => {
assert.equal(hasMention("**@John Doe**", "John Doe"), true);
});
test("handles regex special characters in name", () => {
assert.equal(hasMention("@alice (admin)", "alice (admin)"), true);
});
test("does not false-positive on partial name match", () => {
// "Al" should not match inside "@Alice"
assert.equal(hasMention("@Alice", "Al"), false);
});
@@ -7,7 +7,7 @@ import {
import { useChannelMembersQuery } from "@/features/channels/hooks";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
import { escapeRegExp } from "@/shared/lib/mentionPattern";
import { hasMention } from "./hasMention";
const MENTION_DEBOUNCE_MS = 120;
@@ -219,17 +219,8 @@ export function useMentions(channelId: string | null) {
(text: string): string[] => {
const pubkeys: string[] = [];
const hasMention = (name: string): boolean => {
const escaped = escapeRegExp(name);
const pattern = new RegExp(
`(?:^|\\s)@${escaped}(?=[\\s,;.!?:)\\]}]|$)`,
"i",
);
return pattern.test(text);
};
for (const [displayName, pubkey] of mentionMapRef.current) {
if (hasMention(displayName)) {
if (hasMention(text, displayName)) {
pubkeys.push(pubkey);
}
}
@@ -241,7 +232,7 @@ export function useMentions(channelId: string | null) {
const name =
member.displayName ??
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
if (name && hasMention(name)) {
if (name && hasMention(text, name)) {
pubkeys.push(member.pubkey);
}
}
@@ -10,6 +10,10 @@ import {
useMediaUpload,
} from "@/features/messages/lib/useMediaUpload";
import { useMentions } from "@/features/messages/lib/useMentions";
import {
hasMentionClipboardHtml,
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
import { cn } from "@/shared/lib/cn";
@@ -431,17 +435,46 @@ export function MessageComposer({
editorProps: {
...richText.editor.options.editorProps,
handlePaste: (_view, event) => {
// --- Media paste ---
const items = Array.from(event.clipboardData?.items ?? []);
const mediaItem = items.find((item) =>
ALLOWED_MEDIA_TYPES.includes(item.type),
);
if (!mediaItem) return false;
const file = mediaItem.getAsFile();
if (file) {
void uploadFileRef.current(file);
if (mediaItem) {
const file = mediaItem.getAsFile();
if (file) {
void uploadFileRef.current(file);
}
return true;
}
return true;
// --- Mention / channel-link normalization ---
// When copying from the chat area the browser puts styled HTML
// on the clipboard. TipTap's DOMParser doesn't understand our
// custom `data-mention` / `data-channel-link` spans, so the
// pasted text can arrive with stale formatting and without the
// `@` / `#` prefix. Detect this case, flatten the HTML to
// plain text and insert directly — bypassing TipTap's Bold
// extension which would otherwise wrap the mention in `**`.
// NOTE: This flattens *all* formatting in the pasted fragment
// when mentions are present. Acceptable for the primary use
// case (pasting a mention chip); a future refinement could
// preserve non-mention formatting.
const html = event.clipboardData?.getData("text/html");
if (html && hasMentionClipboardHtml(html)) {
const cleanText = normalizeMentionClipboardHtml(html);
event.preventDefault();
_view.dispatch(
_view.state.tr.insertText(
cleanText,
_view.state.selection.from,
_view.state.selection.to,
),
);
return true;
}
return false;
},
},
});
+1
View File
@@ -104,6 +104,7 @@
background: hsl(var(--primary) / 0.15);
padding: 0.125rem 0.375rem;
color: hsl(var(--primary));
font-weight: 600;
}
@layer base {
+9 -2
View File
@@ -255,7 +255,10 @@ function createMarkdownComponents(
<ul className={cn("list-disc", listClassName)}>{children}</ul>
),
mention: ({ children }: { children?: React.ReactNode }) => (
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm text-primary">
<span
data-mention=""
className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-semibold text-primary"
>
{children}
</span>
),
@@ -272,6 +275,7 @@ function createMarkdownComponents(
return (
<button
type="button"
data-channel-link=""
aria-label={`Open channel ${channelName}`}
className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary cursor-pointer hover:bg-primary/25 transition-colors"
onClick={() => {
@@ -284,7 +288,10 @@ function createMarkdownComponents(
}
return (
<span className="rounded-md bg-primary/15 px-1 py-0.5 text-sm text-primary">
<span
data-channel-link=""
className="rounded-md bg-primary/15 px-1 py-0.5 text-sm text-primary"
>
{children}
</span>
);