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:
@@ -6,6 +6,7 @@ import type {
|
|||||||
Membership,
|
Membership,
|
||||||
Message,
|
Message,
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
|
Person,
|
||||||
SendOpts,
|
SendOpts,
|
||||||
Unsubscribe,
|
Unsubscribe,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -56,6 +57,10 @@ export interface MessagingAdapter {
|
|||||||
/** Absent => treated as always connected (e.g. a pure-REST adapter). */
|
/** Absent => treated as always connected (e.g. a pure-REST adapter). */
|
||||||
isConnected?(): boolean;
|
isConnected?(): boolean;
|
||||||
|
|
||||||
|
/** A thread's members (id + display name), for @mention autocomplete + highlighting.
|
||||||
|
* Absent => the composer offers no autocomplete (you can still type @text). */
|
||||||
|
listMembers?(threadId: string): Promise<Person[]>;
|
||||||
|
|
||||||
// ── Channels (optional capability) ──────────────────────────────
|
// ── Channels (optional capability) ──────────────────────────────
|
||||||
// A channel is just a third membership beyond dm/group: a discoverable, joinable room.
|
// A channel is just a third membership beyond dm/group: a discoverable, joinable room.
|
||||||
// Implement all four to enable the channels UI; absent => the UI hides channels entirely.
|
// Implement all four to enable the channels UI; absent => the UI hides channels entirely.
|
||||||
|
|||||||
@@ -133,11 +133,11 @@ export class KernelClientAdapter implements MessagingAdapter {
|
|||||||
// Attachments are intentionally not forwarded here: kernel-client exposes no media/presign yet,
|
// Attachments are intentionally not forwarded here: kernel-client exposes no media/presign yet,
|
||||||
// and the SDK Attachment carries a display `url`, not a storage `contentRef`. Media is a follow-up
|
// and the SDK Attachment carries a display `url`, not a storage `contentRef`. Media is a follow-up
|
||||||
// (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today.
|
// (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today.
|
||||||
const m = await this.socket.sendMessage(
|
const sendOpts = {
|
||||||
threadId,
|
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||||
content,
|
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
|
||||||
opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : undefined,
|
};
|
||||||
);
|
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
|
||||||
return this.toMessage(m);
|
return this.toMessage(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ export class MockAdapter implements MessagingAdapter {
|
|||||||
if (t) t.participants = t.participants.filter((p) => p !== ME);
|
if (t) t.participants = t.participants.filter((p) => p !== ME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listMembers(threadId: string): Promise<Person[]> {
|
||||||
|
const t = this.threads.get(threadId);
|
||||||
|
if (!t) return [];
|
||||||
|
return t.participants.map((id) => {
|
||||||
|
if (id === ME) return { id: ME, name: 'You', kind: 'staff' };
|
||||||
|
return MOCK_PEOPLE.find((p) => p.id === id) ?? { id, name: id, kind: 'staff' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
|
||||||
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
|
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
|
||||||
const threadId = `th_mock_${this.seq++}`;
|
const threadId = `th_mock_${this.seq++}`;
|
||||||
|
|||||||
@@ -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 { 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
|
* One conversation: message list + composer, driven by useMessages. Adds @mention autocomplete
|
||||||
* handles history, live subscribe, optimistic send, typing, and seen state). Renders
|
* (from the thread's members) and highlights mentions in message bodies. Transport-agnostic.
|
||||||
* nothing transport-specific — swap the adapter and this is unchanged.
|
|
||||||
*/
|
*/
|
||||||
export function Thread({ threadId }: { threadId: string | null }) {
|
export function Thread({ threadId }: { threadId: string | null }) {
|
||||||
const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId);
|
const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId);
|
||||||
|
const members = useMembers(threadId);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
async function submit(e: FormEvent): Promise<void> {
|
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
|
||||||
e.preventDefault();
|
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();
|
const text = draft.trim();
|
||||||
if (!text || sending) return;
|
if (!text || sending) return;
|
||||||
|
const mentions = resolveMentions(text, members);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await send(text);
|
await send(text, mentions.length ? { mentions } : undefined);
|
||||||
} catch {
|
} catch {
|
||||||
// The error is surfaced via the hook's `error`; keep the composer usable.
|
// surfaced via the hook's error
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
|
||||||
|
if (showSuggest && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
pick(suggestions[0]!.insert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
|
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}
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||||
{messages.map((m) => (
|
{messages.map((m) => (
|
||||||
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.pending ? ' is-pending' : ''}`}>
|
<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 ? (
|
{m.reactions && m.reactions.length > 0 ? (
|
||||||
<div className="miu-reactions">
|
<div className="miu-reactions">
|
||||||
{m.reactions.map((r) => (
|
{m.reactions.map((r) => (
|
||||||
@@ -56,15 +93,27 @@ export function Thread({ threadId }: { threadId: string | null }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="miu-composer" onSubmit={submit}>
|
<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
|
<input
|
||||||
className="miu-input"
|
className="miu-input"
|
||||||
value={draft}
|
value={draft}
|
||||||
placeholder="Type a message…"
|
placeholder="Type a message… @ to mention"
|
||||||
aria-label="Message"
|
aria-label="Message"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setDraft(e.target.value);
|
setDraft(e.target.value);
|
||||||
sendTyping();
|
sendTyping();
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>
|
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>
|
||||||
Send
|
Send
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAdapter } from '../provider';
|
||||||
|
import type { Person } from '../types';
|
||||||
|
|
||||||
|
/** A thread's members (for @mention autocomplete + highlighting). Empty if the adapter
|
||||||
|
* doesn't implement listMembers, or while loading. */
|
||||||
|
export function useMembers(threadId: string | null): Person[] {
|
||||||
|
const adapter = useAdapter();
|
||||||
|
const [members, setMembers] = useState<Person[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threadId || !adapter.listMembers) {
|
||||||
|
setMembers([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
adapter
|
||||||
|
.listMembers(threadId)
|
||||||
|
.then((m) => {
|
||||||
|
if (alive) setMembers(m);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setMembers([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [adapter, threadId]);
|
||||||
|
|
||||||
|
return members;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export { MessagingProvider, useAdapter } from './provider';
|
|||||||
export { useConversations } from './hooks/use-conversations';
|
export { useConversations } from './hooks/use-conversations';
|
||||||
export { useMessages } from './hooks/use-messages';
|
export { useMessages } from './hooks/use-messages';
|
||||||
export { useChannels } from './hooks/use-channels';
|
export { useChannels } from './hooks/use-channels';
|
||||||
|
export { useMembers } from './hooks/use-members';
|
||||||
export { isOwnMessage } from './types';
|
export { isOwnMessage } from './types';
|
||||||
|
|
||||||
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
|
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { MessagingProvider } from './provider';
|
||||||
|
import { Messenger } from './components/messenger';
|
||||||
|
import { MockAdapter } from './adapters/mock';
|
||||||
|
import { insertMention, resolveMentions, trailingMentionQuery } from './mentions';
|
||||||
|
import type { Person } from './types';
|
||||||
|
|
||||||
|
const people: Person[] = [
|
||||||
|
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
|
||||||
|
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('mention helpers', () => {
|
||||||
|
it('trailingMentionQuery finds the @token being typed', () => {
|
||||||
|
expect(trailingMentionQuery('hey @Sof')).toBe('Sof');
|
||||||
|
expect(trailingMentionQuery('@')).toBe('');
|
||||||
|
expect(trailingMentionQuery('no mention here')).toBeNull();
|
||||||
|
expect(trailingMentionQuery('done @Sofia Ramirez ')).toBeNull(); // completed, trailing space
|
||||||
|
});
|
||||||
|
|
||||||
|
it('insertMention replaces the trailing query, keeping the boundary', () => {
|
||||||
|
expect(insertMention('hey @Sof', 'Sofia Ramirez')).toBe('hey @Sofia Ramirez ');
|
||||||
|
expect(insertMention('@ch', 'channel')).toBe('@channel ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveMentions maps names to ids; @channel expands to everyone', () => {
|
||||||
|
expect(resolveMentions('ping @Sofia Ramirez', people)).toEqual(['pp_sofia']);
|
||||||
|
expect(resolveMentions('nobody here', people)).toEqual([]);
|
||||||
|
expect(resolveMentions('@channel ship it', people).sort()).toEqual(['pp_dan', 'pp_sofia']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<Thread /> @mention autocomplete', () => {
|
||||||
|
it('picking a suggestion inserts the name and send carries the mention id', async () => {
|
||||||
|
const adapter = new MockAdapter();
|
||||||
|
const sendSpy = vi.spyOn(adapter, 'send');
|
||||||
|
render(
|
||||||
|
<MessagingProvider adapter={adapter}>
|
||||||
|
<Messenger />
|
||||||
|
</MessagingProvider>,
|
||||||
|
);
|
||||||
|
const input = (await screen.findByLabelText('Message')) as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: 'hey @Sof' } });
|
||||||
|
|
||||||
|
// The suggestion (role=option) is distinct from the sidebar row of the same name.
|
||||||
|
fireEvent.click(await screen.findByRole('option', { name: 'Sofia Ramirez' }));
|
||||||
|
expect(input.value).toBe('hey @Sofia Ramirez ');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Send'));
|
||||||
|
await waitFor(() => expect(sendSpy).toHaveBeenCalled());
|
||||||
|
const opts = sendSpy.mock.calls[0]![2];
|
||||||
|
expect(opts?.mentions).toContain('pp_sofia');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -175,13 +175,55 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* mentions */
|
||||||
|
.miu-mention {
|
||||||
|
color: var(--miu-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.miu-msg.is-mine .miu-bubble .miu-mention {
|
||||||
|
color: var(--miu-accent-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
/* composer */
|
/* composer */
|
||||||
.miu-composer {
|
.miu-composer {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid var(--miu-border);
|
border-top: 1px solid var(--miu-border);
|
||||||
}
|
}
|
||||||
|
.miu-suggest {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: calc(100% - 4px);
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
list-style: none;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
border-radius: var(--miu-radius);
|
||||||
|
background: var(--miu-panel);
|
||||||
|
box-shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.miu-suggest-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--miu-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.miu-suggest-item:hover {
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
}
|
||||||
.miu-input {
|
.miu-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export interface Message {
|
|||||||
export interface SendOpts {
|
export interface SendOpts {
|
||||||
parentInteractionId?: string;
|
parentInteractionId?: string;
|
||||||
attachment?: Attachment;
|
attachment?: Attachment;
|
||||||
|
/** Opaque userId notify-list (from @mentions). The app parses "@"; the kernel just forwards it. */
|
||||||
|
mentions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageEvent =
|
export type MessageEvent =
|
||||||
|
|||||||
Reference in New Issue
Block a user