From cb9a0b925000b74dc316aa40919e54384038fdaf Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 20:10:38 +0530 Subject: [PATCH] =?UTF-8?q?feat(messaging-ui):=20@mentions=20=E2=80=94=20a?= =?UTF-8?q?utocomplete,=20resolve=20to=20ids,=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/iios-messaging-ui/src/adapter.ts | 5 ++ .../src/adapters/kernel-client.ts | 10 +-- .../iios-messaging-ui/src/adapters/mock.ts | 9 +++ .../src/components/thread.tsx | 69 ++++++++++++++++--- .../src/hooks/use-members.ts | 31 +++++++++ packages/iios-messaging-ui/src/index.ts | 1 + .../iios-messaging-ui/src/mentions.test.tsx | 55 +++++++++++++++ packages/iios-messaging-ui/src/mentions.tsx | 51 ++++++++++++++ packages/iios-messaging-ui/src/styles.css | 42 +++++++++++ packages/iios-messaging-ui/src/types.ts | 2 + 10 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 packages/iios-messaging-ui/src/hooks/use-members.ts create mode 100644 packages/iios-messaging-ui/src/mentions.test.tsx create mode 100644 packages/iios-messaging-ui/src/mentions.tsx diff --git a/packages/iios-messaging-ui/src/adapter.ts b/packages/iios-messaging-ui/src/adapter.ts index 6df76e5..1670e96 100644 --- a/packages/iios-messaging-ui/src/adapter.ts +++ b/packages/iios-messaging-ui/src/adapter.ts @@ -6,6 +6,7 @@ import type { Membership, Message, MessageEvent, + Person, SendOpts, Unsubscribe, } from './types'; @@ -56,6 +57,10 @@ export interface MessagingAdapter { /** Absent => treated as always connected (e.g. a pure-REST adapter). */ 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; + // ── Channels (optional capability) ────────────────────────────── // 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. diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.ts index 5c148bc..148fdbb 100644 --- a/packages/iios-messaging-ui/src/adapters/kernel-client.ts +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.ts @@ -133,11 +133,11 @@ export class KernelClientAdapter implements MessagingAdapter { // 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 // (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today. - const m = await this.socket.sendMessage( - threadId, - content, - opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : undefined, - ); + const sendOpts = { + ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), + ...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}), + }; + const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined); return this.toMessage(m); } diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts index 7d81433..d3c8ad7 100644 --- a/packages/iios-messaging-ui/src/adapters/mock.ts +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -150,6 +150,15 @@ export class MockAdapter implements MessagingAdapter { if (t) t.participants = t.participants.filter((p) => p !== ME); } + async listMembers(threadId: string): Promise { + 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 }> { const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group'); const threadId = `th_mock_${this.seq++}`; diff --git a/packages/iios-messaging-ui/src/components/thread.tsx b/packages/iios-messaging-ui/src/components/thread.tsx index 0f44569..a67911a 100644 --- a/packages/iios-messaging-ui/src/components/thread.tsx +++ b/packages/iios-messaging-ui/src/components/thread.tsx @@ -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 { - e.preventDefault(); + const memberNames = useMemo(() => members.map((m) => m.name), [members]); + const query = trailingMentionQuery(draft); + const suggestions = useMemo(() => { + 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 { + 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): void { + if (showSuggest && e.key === 'Enter') { + e.preventDefault(); + pick(suggestions[0]!.insert); + } + } + if (!threadId) { return
Select a conversation.
; } @@ -37,7 +74,7 @@ export function Thread({ threadId }: { threadId: string | null }) { {error ?
{error}
: null} {messages.map((m) => (
-
{m.text}
+
{highlightMentions(m.text, memberNames)}
{m.reactions && m.reactions.length > 0 ? (
{m.reactions.map((r) => ( @@ -56,15 +93,27 @@ export function Thread({ threadId }: { threadId: string | null }) {
+ {showSuggest ? ( +
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+ ) : null} { setDraft(e.target.value); sendTyping(); }} + onKeyDown={onKeyDown} />