feat(messaging-ui): @mentions — autocomplete, resolve to ids, highlight
- contract: SendOpts.mentions (opaque userId notify-list) + optional listMembers(threadId) for autocomplete/highlight (capability-degrading) - mock: listMembers; KernelClientAdapter.send forwards mentions to the socket (which already carries them → inbox MENTION items) - composer: type @ → member/@channel/@here autocomplete; Enter picks the first; on send, mentions resolve to ids; message bodies highlight @mentions - useMembers hook; mentions.tsx helpers (trailingMentionQuery/insertMention/ resolveMentions/highlightMentions) - 4 mention tests (helpers + render autocomplete→send carries id); 56 total green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Person } from './types';
|
||||
|
||||
/** Room-wide mention tokens, always offered alongside members. */
|
||||
export const SPECIAL_MENTIONS = ['channel', 'here'];
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** The @token currently being typed at the end of the draft (caret-at-end), or null. */
|
||||
export function trailingMentionQuery(draft: string): string | null {
|
||||
const m = draft.match(/(?:^|\s)@([\w]*)$/);
|
||||
return m ? (m[1] ?? '') : null;
|
||||
}
|
||||
|
||||
/** Replace the trailing @query with `@insert ` (keeping the leading boundary). */
|
||||
export function insertMention(draft: string, insert: string): string {
|
||||
return draft.replace(/(^|\s)@([\w]*)$/, (_full, lead: string) => `${lead}@${insert} `);
|
||||
}
|
||||
|
||||
/** Resolve the opaque mention userId list from the final text + known members. */
|
||||
export function resolveMentions(text: string, members: Person[]): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const p of members) if (text.includes(`@${p.name}`)) ids.add(p.id);
|
||||
if (/@channel\b/.test(text) || /@here\b/.test(text)) for (const p of members) ids.add(p.id);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Render text with @mentions (member names + @channel/@here) wrapped for highlighting. */
|
||||
export function highlightMentions(text: string, memberNames: string[]): ReactNode[] {
|
||||
// Longest-first so "@Sofia Ramirez" wins over a bare "@Sofia".
|
||||
const names = [...new Set([...memberNames, ...SPECIAL_MENTIONS])].filter(Boolean).sort((a, b) => b.length - a.length);
|
||||
if (names.length === 0) return [text];
|
||||
const re = new RegExp(`@(${names.map(esc).join('|')})`, 'g');
|
||||
const out: ReactNode[] = [];
|
||||
let last = 0;
|
||||
let key = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
out.push(
|
||||
<span key={`m${key++}`} className="miu-mention">
|
||||
{m[0]}
|
||||
</span>,
|
||||
);
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out.length > 0 ? out : [text];
|
||||
}
|
||||
Reference in New Issue
Block a user