mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): highlight full multi-word display names in composer ove… (#163)
This commit is contained in:
@@ -3,20 +3,53 @@ import * as React from "react";
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import { useChannelMembersQuery } from "@/features/channels/hooks";
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import { escapeRegExp } from "@/shared/lib/mentionPattern";
|
||||
|
||||
function detectMentionQuery(
|
||||
value: string,
|
||||
cursorPosition: number,
|
||||
knownNamesLower: string[],
|
||||
): { query: string; startIndex: number } | null {
|
||||
const beforeCursor = value.slice(0, cursorPosition);
|
||||
const match = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
|
||||
// Fast path: single-word mention query (no spaces after @)
|
||||
const simpleMatch = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
|
||||
if (simpleMatch) {
|
||||
const query = simpleMatch[1];
|
||||
const startIndex = beforeCursor.length - query.length - 1; // -1 for @
|
||||
return { query, startIndex };
|
||||
}
|
||||
|
||||
const query = match[1];
|
||||
const startIndex = beforeCursor.length - query.length - 1; // -1 for @
|
||||
return { query, startIndex };
|
||||
// Multi-word path: scan backwards for an `@` and check if the text between
|
||||
// `@` and the cursor is a prefix of any known multi-word display name.
|
||||
const scanStart = Math.max(0, beforeCursor.length - 80);
|
||||
for (let i = beforeCursor.length - 1; i >= scanStart; i--) {
|
||||
const ch = beforeCursor[i];
|
||||
if (ch === "@") {
|
||||
// Ensure `@` is at start or preceded by whitespace
|
||||
if (i > 0 && !/\s/.test(beforeCursor[i - 1])) {
|
||||
continue;
|
||||
}
|
||||
const candidate = beforeCursor.slice(i + 1);
|
||||
if (candidate.length === 0) {
|
||||
break;
|
||||
}
|
||||
const lowerCandidate = candidate.toLowerCase();
|
||||
const isPrefix = knownNamesLower.some((name) =>
|
||||
name.startsWith(lowerCandidate),
|
||||
);
|
||||
if (isPrefix) {
|
||||
return { query: candidate, startIndex: i };
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Stop scanning if we hit a newline — mentions don't span lines
|
||||
if (ch === "\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useMentions(channelId: string | null) {
|
||||
@@ -26,7 +59,7 @@ export function useMentions(channelId: string | null) {
|
||||
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
|
||||
|
||||
const membersQuery = useChannelMembersQuery(channelId);
|
||||
const members = membersQuery.data ?? [];
|
||||
const members = membersQuery.data;
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const managedAgentNamesByPubkey = React.useMemo(
|
||||
() =>
|
||||
@@ -39,13 +72,33 @@ export function useMentions(channelId: string | null) {
|
||||
[managedAgentsQuery.data],
|
||||
);
|
||||
|
||||
const knownNames = React.useMemo<string[]>(() => {
|
||||
if (!members) return [];
|
||||
const names: string[] = [];
|
||||
for (const member of members) {
|
||||
const name =
|
||||
member.displayName ??
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
|
||||
if (name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [members, managedAgentNamesByPubkey]);
|
||||
|
||||
/** Lower-cased version of knownNames, used for case-insensitive prefix matching in detectMentionQuery. */
|
||||
const knownNamesLower = React.useMemo<string[]>(
|
||||
() => knownNames.map((n) => n.toLowerCase()),
|
||||
[knownNames],
|
||||
);
|
||||
|
||||
const suggestions = React.useMemo<MentionSuggestion[]>(() => {
|
||||
if (mentionQuery === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerQuery = mentionQuery.toLowerCase();
|
||||
return members
|
||||
return (members ?? [])
|
||||
.map((member) => {
|
||||
const fallbackName =
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
|
||||
@@ -95,7 +148,11 @@ export function useMentions(channelId: string | null) {
|
||||
|
||||
const updateMentionQuery = React.useCallback(
|
||||
(value: string, cursorPosition: number) => {
|
||||
const mention = detectMentionQuery(value, cursorPosition);
|
||||
const mention = detectMentionQuery(
|
||||
value,
|
||||
cursorPosition,
|
||||
knownNamesLower,
|
||||
);
|
||||
if (mention) {
|
||||
setMentionQuery(mention.query);
|
||||
setMentionStartIndex(mention.startIndex);
|
||||
@@ -104,7 +161,7 @@ export function useMentions(channelId: string | null) {
|
||||
setMentionQuery(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[knownNamesLower],
|
||||
);
|
||||
|
||||
const extractMentionPubkeys = React.useCallback(
|
||||
@@ -112,7 +169,7 @@ export function useMentions(channelId: string | null) {
|
||||
const pubkeys: string[] = [];
|
||||
|
||||
const hasMention = (name: string): boolean => {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escaped = escapeRegExp(name);
|
||||
const pattern = new RegExp(
|
||||
`(?:^|\\s)@${escaped}(?=[\\s,;.!?:)\\]}]|$)`,
|
||||
"i",
|
||||
@@ -126,7 +183,7 @@ export function useMentions(channelId: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
for (const member of members ?? []) {
|
||||
if (pubkeys.includes(member.pubkey)) {
|
||||
continue;
|
||||
}
|
||||
@@ -202,6 +259,7 @@ export function useMentions(channelId: string | null) {
|
||||
handleMentionKeyDown,
|
||||
insertMention,
|
||||
isMentionOpen,
|
||||
knownNames,
|
||||
mentionSelectedIndex,
|
||||
suggestions,
|
||||
updateMentionQuery,
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import type React from "react";
|
||||
import React from "react";
|
||||
|
||||
import { buildMentionPattern } from "@/shared/lib/mentionPattern";
|
||||
|
||||
type Segment = { type: "text" | "mention" | "channel"; value: string };
|
||||
|
||||
const MENTION_OR_CHANNEL_RE = /@\S+|#[a-zA-Z0-9][\w-]*/g;
|
||||
const CHANNEL_RE_PART = "#[a-zA-Z0-9][\\w-]*";
|
||||
|
||||
function parseSegments(text: string): Segment[] {
|
||||
/**
|
||||
* Extends the shared mention pattern to also match `#channel-name` references.
|
||||
*/
|
||||
function buildOverlayPattern(mentionNames: string[]): RegExp {
|
||||
const mentionSource = buildMentionPattern(mentionNames).source;
|
||||
return new RegExp(`${mentionSource}|${CHANNEL_RE_PART}`, "g");
|
||||
}
|
||||
|
||||
function parseSegments(text: string, pattern: RegExp): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(MENTION_OR_CHANNEL_RE)) {
|
||||
pattern.lastIndex = 0;
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const matchStart = match.index;
|
||||
if (matchStart > lastIndex) {
|
||||
segments.push({ type: "text", value: text.slice(lastIndex, matchStart) });
|
||||
@@ -31,14 +42,20 @@ function parseSegments(text: string): Segment[] {
|
||||
|
||||
type ComposerMentionOverlayProps = {
|
||||
content: string;
|
||||
mentionNames: string[];
|
||||
scrollTop: number;
|
||||
};
|
||||
|
||||
export function ComposerMentionOverlay({
|
||||
content,
|
||||
mentionNames,
|
||||
scrollTop,
|
||||
}: ComposerMentionOverlayProps) {
|
||||
const segments = parseSegments(content);
|
||||
const pattern = React.useMemo(
|
||||
() => buildOverlayPattern(mentionNames),
|
||||
[mentionNames],
|
||||
);
|
||||
const segments = parseSegments(content, pattern);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -39,21 +39,6 @@ type MessageComposerProps = {
|
||||
|
||||
const MAX_TEXTAREA_ROWS = 4;
|
||||
|
||||
function detectMentionQuery(
|
||||
value: string,
|
||||
cursorPosition: number,
|
||||
): { query: string; startIndex: number } | null {
|
||||
const beforeCursor = value.slice(0, cursorPosition);
|
||||
const match = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = match[1];
|
||||
const startIndex = beforeCursor.length - query.length - 1; // -1 for @
|
||||
return { query, startIndex };
|
||||
}
|
||||
|
||||
export function MessageComposer({
|
||||
channelId = null,
|
||||
channelName,
|
||||
@@ -192,8 +177,9 @@ export function MessageComposer({
|
||||
|
||||
const currentContent = contentRef.current;
|
||||
const cursorPosition = textarea.selectionStart ?? currentContent.length;
|
||||
const existingMention = detectMentionQuery(currentContent, cursorPosition);
|
||||
if (existingMention) {
|
||||
// Quick check: is there already an @-query in progress?
|
||||
const beforeCursor = currentContent.slice(0, cursorPosition);
|
||||
if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) {
|
||||
mentions.updateMentionQuery(currentContent, cursorPosition);
|
||||
textarea.focus();
|
||||
return;
|
||||
@@ -573,6 +559,7 @@ export function MessageComposer({
|
||||
>
|
||||
<ComposerMentionOverlay
|
||||
content={content}
|
||||
mentionNames={mentions.knownNames}
|
||||
scrollTop={composerScrollTop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Escape special regex characters in a string.
|
||||
*/
|
||||
export function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a regex that matches @mentions, trying known multi-word names first
|
||||
* (longest-first to avoid partial matches), then falling back to @\S+.
|
||||
*/
|
||||
export function buildMentionPattern(mentionNames: string[]): RegExp {
|
||||
const sorted = [...new Set(mentionNames)]
|
||||
.filter((name) => name.trim().length > 0)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return /@\S+/g;
|
||||
}
|
||||
|
||||
const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|");
|
||||
return new RegExp(`@(?:${nameAlternatives}|\\S+)`, "g");
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
* falls back to the generic `@\S+` pattern for unknown mentions.
|
||||
*/
|
||||
|
||||
import { buildMentionPattern } from "./mentionPattern";
|
||||
|
||||
type RemarkMentionsOptions = {
|
||||
mentionNames?: string[];
|
||||
};
|
||||
@@ -42,25 +44,6 @@ function walkChildren(node: any, mentionPattern: RegExp) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function buildMentionPattern(mentionNames: string[]): RegExp {
|
||||
// Deduplicate and sort longest-first so "John Doe" is matched before "John"
|
||||
const sorted = [...new Set(mentionNames)]
|
||||
.filter((name) => name.trim().length > 0)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return /@\S+/g;
|
||||
}
|
||||
|
||||
// Build alternation: try known names first, then fall back to @\S+
|
||||
const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|");
|
||||
return new RegExp(`@(?:${nameAlternatives}|\\S+)`, "g");
|
||||
}
|
||||
|
||||
function splitMentions(text: string, mentionPattern: RegExp) {
|
||||
// Reset lastIndex — the pattern is reused across text nodes with the `g` flag
|
||||
mentionPattern.lastIndex = 0;
|
||||
|
||||
Reference in New Issue
Block a user