mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: mentions survive copy/paste from chat into composer (#328)
This commit is contained in:
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
padding: 0.125rem 0.375rem;
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user