feat(messaging-ui): start direct messages + groups (Slack-style people picker)

Add a 'New message' + on the Direct messages section that opens a people picker
(NewConversation): pick one person -> DM, two or more -> group with an optional
name, via the adapter's existing openThread({participantIds, membership, subject}).
Expose directory() on MessagingAdapter (org people list); MockAdapter implements
it + dedupes 1:1 DMs. 72 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 19:23:57 +05:30
parent 9882177c59
commit 4faf05fee9
7 changed files with 241 additions and 9 deletions
@@ -61,6 +61,10 @@ export interface MessagingAdapter {
* Absent => the composer offers no autocomplete (you can still type @text). */
listMembers?(threadId: string): Promise<Person[]>;
/** The people you can start a conversation with (org directory). Drives the "New message"
* people picker. Absent => the UI hides DM/group creation (you can still open channels). */
directory?(): Promise<Person[]>;
// ── 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.
@@ -159,7 +159,20 @@ export class MockAdapter implements MessagingAdapter {
});
}
async directory(): Promise<Person[]> {
return MOCK_PEOPLE.map((p) => ({ ...p }));
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
// A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door).
if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) {
const target = p.participantIds[0];
for (const [id, t] of this.threads) {
if (t.membership === 'dm' && t.participants.length === 2 && t.participants.includes(ME) && t.participants.includes(target)) {
return { threadId: id };
}
}
}
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
const threadId = `th_mock_${this.seq++}`;
this.threads.set(threadId, {
@@ -3,6 +3,7 @@ import { useConversations } from '../hooks/use-conversations';
import { useAdapter } from '../provider';
import { ConversationList } from './conversation-list';
import { ChannelBrowser } from './channel-browser';
import { NewConversation } from './new-conversation';
import { Thread } from './thread';
import { ThreadPane } from './thread-pane';
@@ -15,28 +16,41 @@ export function Messenger() {
const { conversations, loading, error, refetch } = useConversations();
const adapter = useAdapter();
const channelsSupported = typeof adapter.browseChannels === 'function';
const directorySupported = typeof adapter.directory === 'function';
const [selected, setSelected] = useState<string | null>(null);
const [browsing, setBrowsing] = useState(false);
const [composing, setComposing] = useState(false); // "New message" people picker open
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
useEffect(() => {
if (browsing) return;
if (browsing || composing) return;
if (selected && conversations.some((c) => c.threadId === selected)) return;
setSelected(conversations[0]?.threadId ?? null);
}, [conversations, selected, browsing]);
}, [conversations, selected, browsing, composing]);
// Switching conversations (or into browse) closes any open thread pane.
useEffect(() => setActiveRoot(null), [selected, browsing]);
// Switching conversations (or into browse/compose) closes any open thread pane.
useEffect(() => setActiveRoot(null), [selected, browsing, composing]);
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
function pick(threadId: string): void {
setBrowsing(false);
setComposing(false);
setSelected(threadId);
}
function openBrowse(): void {
setComposing(false);
setBrowsing(true);
}
function openCompose(): void {
setBrowsing(false);
setComposing(true);
}
return (
<div className="miu-messenger">
<aside className="miu-sidebar">
@@ -47,12 +61,12 @@ export function Messenger() {
<div className="miu-section">
<div className="miu-section-head">
<span>Channels</span>
<button type="button" className="miu-section-add" title="Browse channels" onClick={() => setBrowsing(true)}>
<button type="button" className="miu-section-add" title="Browse channels" onClick={openBrowse}>
</button>
</div>
{channels.length > 0 ? (
<ConversationList conversations={channels} selectedId={browsing ? null : selected} onSelect={pick} />
<ConversationList conversations={channels} selectedId={browsing || composing ? null : selected} onSelect={pick} />
) : (
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
)}
@@ -60,12 +74,17 @@ export function Messenger() {
) : null}
<div className="miu-section">
{channelsSupported ? (
{channelsSupported || directorySupported ? (
<div className="miu-section-head">
<span>Direct messages</span>
{directorySupported ? (
<button type="button" className="miu-section-add" title="New message" onClick={openCompose}>
</button>
) : null}
</div>
) : null}
<ConversationList conversations={dms} selectedId={browsing ? null : selected} onSelect={pick} />
<ConversationList conversations={dms} selectedId={browsing || composing ? null : selected} onSelect={pick} />
</div>
</aside>
@@ -77,12 +96,19 @@ export function Messenger() {
pick(threadId);
}}
/>
) : composing ? (
<NewConversation
onCreated={(threadId) => {
refetch();
pick(threadId);
}}
/>
) : (
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
)}
</section>
{!browsing && selected && activeRoot ? (
{!browsing && !composing && selected && activeRoot ? (
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
) : null}
</div>
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { Messenger } from './messenger';
function mount(adapter = new MockAdapter()) {
render(
<MessagingProvider adapter={adapter}>
<Messenger />
</MessagingProvider>,
);
return adapter;
}
describe('MockAdapter directory + openThread', () => {
it('directory lists people you can message', async () => {
const a = new MockAdapter();
const people = await a.directory();
expect(people.some((p) => p.name === 'Dan Whitaker')).toBe(true);
});
it('one participant opens a dm; two+ open a group with a subject', async () => {
const a = new MockAdapter();
const dm = await a.openThread({ participantIds: ['pp_dan'], membership: 'dm' });
const list = await a.listConversations();
expect(list.find((c) => c.threadId === dm.threadId)?.membership).toBe('dm');
const group = await a.openThread({ participantIds: ['pp_dan', 'pp_priya'], membership: 'group', subject: 'Roof crew' });
const g = (await a.listConversations()).find((c) => c.threadId === group.threadId);
expect(g?.membership).toBe('group');
expect(g?.title).toBe('Roof crew');
});
it('opening a dm with the same person reuses the existing thread', async () => {
const a = new MockAdapter();
const first = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
const second = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
expect(second.threadId).toBe(first.threadId);
});
});
describe('<Messenger /> new conversation flow', () => {
it('opens the picker from the Direct messages +, and starting a chat leaves the picker', async () => {
mount();
// Open the "New message" picker.
fireEvent.click(await screen.findByTitle('New message'));
expect(await screen.findByText('New message')).toBeTruthy();
// Pick a person and start a DM.
fireEvent.click(await screen.findByText('Dan Whitaker'));
fireEvent.click(screen.getByText('Start chat'));
// The picker closes (we're back in a thread view — the picker heading is gone).
await waitFor(() => expect(screen.queryByText('Start chat')).toBeNull());
});
it('selecting two people switches the action to group create', async () => {
mount();
fireEvent.click(await screen.findByTitle('New message'));
fireEvent.click(await screen.findByText('Dan Whitaker'));
fireEvent.click(await screen.findByText('Priya Nair'));
expect(screen.getByText(/Create group \(2\)/)).toBeTruthy();
expect(screen.getByLabelText('Group name')).toBeTruthy();
});
});
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Person } from '../types';
/**
* Start a direct message or a group — Slack-style. Pick people from the org directory: one selected
* opens a DM (deduped by the adapter), two or more create a group with an optional name. Shown in
* the main pane like the channel browser.
*/
export function NewConversation({ onCreated }: { onCreated?: (threadId: string) => void }) {
const adapter = useAdapter();
const [people, setPeople] = useState<Person[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [q, setQ] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!adapter.directory) {
setLoading(false);
return;
}
let alive = true;
adapter
.directory()
.then((p) => {
if (alive) {
setPeople(p);
setError(null);
}
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter]);
const filtered = people.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const isGroup = selected.length > 1;
const canStart = selected.length >= 1 && !busy;
function toggle(id: string): void {
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
}
async function start(): Promise<void> {
if (!canStart) return;
setBusy(true);
setError(null);
try {
const res = await adapter.openThread({
participantIds: selected,
membership: isGroup ? 'group' : 'dm',
...(isGroup && name.trim() ? { subject: name.trim() } : {}),
});
onCreated?.(res.threadId);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<div className="miu-browser">
<div className="miu-browser-head">New message</div>
<div className="miu-newconv">
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
{isGroup ? (
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Group name (optional)" aria-label="Group name" />
) : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
<div className="miu-browser-list">
{loading && people.length === 0 ? <div className="miu-empty">Loading</div> : null}
{!loading && filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
{filtered.map((p) => (
<label key={p.id} className={`miu-person-row${selected.includes(p.id) ? ' is-active' : ''}`}>
<input type="checkbox" checked={selected.includes(p.id)} onChange={() => toggle(p.id)} />
<span className="miu-browser-name">{p.name}</span>
<span className="miu-pill">{p.kind}</span>
</label>
))}
</div>
<button type="button" className="miu-send" onClick={() => void start()} disabled={!canStart}>
{busy ? 'Starting…' : isGroup ? `Create group (${selected.length})` : 'Start chat'}
</button>
</div>
</div>
);
}
+26
View File
@@ -500,6 +500,32 @@
gap: 5px;
cursor: pointer;
}
/* new conversation (DM / group) picker */
.miu-newconv {
display: flex;
flex-direction: column;
gap: 10px;
}
.miu-person-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--miu-border);
border-radius: 10px;
background: var(--miu-panel);
cursor: pointer;
}
.miu-person-row:hover {
border-color: var(--miu-accent);
}
.miu-person-row.is-active {
border-color: var(--miu-accent);
background: color-mix(in srgb, var(--miu-accent) 10%, var(--miu-panel));
}
.miu-person-row .miu-browser-name {
flex: 1 1 auto;
}
.miu-browser-list {
display: flex;
flex-direction: column;