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:
@@ -1,31 +1,68 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMemo, useState, type FormEvent, type KeyboardEvent } from 'react';
|
||||
import { useMessages } from '../hooks/use-messages';
|
||||
import { useMembers } from '../hooks/use-members';
|
||||
import {
|
||||
SPECIAL_MENTIONS,
|
||||
highlightMentions,
|
||||
insertMention,
|
||||
resolveMentions,
|
||||
trailingMentionQuery,
|
||||
} from '../mentions';
|
||||
|
||||
interface Suggestion {
|
||||
key: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One conversation: message list + composer, driven entirely by useMessages (which
|
||||
* handles history, live subscribe, optimistic send, typing, and seen state). Renders
|
||||
* nothing transport-specific — swap the adapter and this is unchanged.
|
||||
* One conversation: message list + composer, driven by useMessages. Adds @mention autocomplete
|
||||
* (from the thread's members) and highlights mentions in message bodies. Transport-agnostic.
|
||||
*/
|
||||
export function Thread({ threadId }: { threadId: string | null }) {
|
||||
const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId);
|
||||
const members = useMembers(threadId);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
||||
const query = trailingMentionQuery(draft);
|
||||
const suggestions = useMemo<Suggestion[]>(() => {
|
||||
if (query === null) return [];
|
||||
const q = query.toLowerCase();
|
||||
const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s }));
|
||||
const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name }));
|
||||
return [...specials, ...people].slice(0, 6);
|
||||
}, [query, members]);
|
||||
const showSuggest = query !== null && suggestions.length > 0;
|
||||
|
||||
function pick(insert: string): void {
|
||||
setDraft((d) => insertMention(d, insert));
|
||||
}
|
||||
|
||||
async function submit(e?: FormEvent): Promise<void> {
|
||||
e?.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
const mentions = resolveMentions(text, members);
|
||||
setDraft('');
|
||||
setSending(true);
|
||||
try {
|
||||
await send(text);
|
||||
await send(text, mentions.length ? { mentions } : undefined);
|
||||
} catch {
|
||||
// The error is surfaced via the hook's `error`; keep the composer usable.
|
||||
// surfaced via the hook's error
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
|
||||
if (showSuggest && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pick(suggestions[0]!.insert);
|
||||
}
|
||||
}
|
||||
|
||||
if (!threadId) {
|
||||
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
|
||||
}
|
||||
@@ -37,7 +74,7 @@ export function Thread({ threadId }: { threadId: string | null }) {
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.pending ? ' is-pending' : ''}`}>
|
||||
<div className="miu-bubble">{m.text}</div>
|
||||
<div className="miu-bubble">{highlightMentions(m.text, memberNames)}</div>
|
||||
{m.reactions && m.reactions.length > 0 ? (
|
||||
<div className="miu-reactions">
|
||||
{m.reactions.map((r) => (
|
||||
@@ -56,15 +93,27 @@ export function Thread({ threadId }: { threadId: string | null }) {
|
||||
</div>
|
||||
|
||||
<form className="miu-composer" onSubmit={submit}>
|
||||
{showSuggest ? (
|
||||
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
|
||||
{suggestions.map((s) => (
|
||||
<li key={s.key}>
|
||||
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<input
|
||||
className="miu-input"
|
||||
value={draft}
|
||||
placeholder="Type a message…"
|
||||
placeholder="Type a message… @ to mention"
|
||||
aria-label="Message"
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
sendTyping();
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>
|
||||
Send
|
||||
|
||||
Reference in New Issue
Block a user