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
@@ -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>