Feat/messaging ui foundation #6
Binary file not shown.
@@ -61,6 +61,10 @@ export interface MessagingAdapter {
|
|||||||
* Absent => the composer offers no autocomplete (you can still type @text). */
|
* Absent => the composer offers no autocomplete (you can still type @text). */
|
||||||
listMembers?(threadId: string): Promise<Person[]>;
|
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) ──────────────────────────────
|
// ── 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.
|
||||||
|
|||||||
@@ -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 }> {
|
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 membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
|
||||||
const threadId = `th_mock_${this.seq++}`;
|
const threadId = `th_mock_${this.seq++}`;
|
||||||
this.threads.set(threadId, {
|
this.threads.set(threadId, {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useConversations } from '../hooks/use-conversations';
|
|||||||
import { useAdapter } from '../provider';
|
import { useAdapter } from '../provider';
|
||||||
import { ConversationList } from './conversation-list';
|
import { ConversationList } from './conversation-list';
|
||||||
import { ChannelBrowser } from './channel-browser';
|
import { ChannelBrowser } from './channel-browser';
|
||||||
|
import { NewConversation } from './new-conversation';
|
||||||
import { Thread } from './thread';
|
import { Thread } from './thread';
|
||||||
import { ThreadPane } from './thread-pane';
|
import { ThreadPane } from './thread-pane';
|
||||||
|
|
||||||
@@ -15,28 +16,41 @@ export function Messenger() {
|
|||||||
const { conversations, loading, error, refetch } = useConversations();
|
const { conversations, loading, error, refetch } = useConversations();
|
||||||
const adapter = useAdapter();
|
const adapter = useAdapter();
|
||||||
const channelsSupported = typeof adapter.browseChannels === 'function';
|
const channelsSupported = typeof adapter.browseChannels === 'function';
|
||||||
|
const directorySupported = typeof adapter.directory === 'function';
|
||||||
|
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [browsing, setBrowsing] = useState(false);
|
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
|
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (browsing) return;
|
if (browsing || composing) return;
|
||||||
if (selected && conversations.some((c) => c.threadId === selected)) return;
|
if (selected && conversations.some((c) => c.threadId === selected)) return;
|
||||||
setSelected(conversations[0]?.threadId ?? null);
|
setSelected(conversations[0]?.threadId ?? null);
|
||||||
}, [conversations, selected, browsing]);
|
}, [conversations, selected, browsing, composing]);
|
||||||
|
|
||||||
// Switching conversations (or into browse) closes any open thread pane.
|
// Switching conversations (or into browse/compose) closes any open thread pane.
|
||||||
useEffect(() => setActiveRoot(null), [selected, browsing]);
|
useEffect(() => setActiveRoot(null), [selected, browsing, composing]);
|
||||||
|
|
||||||
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
|
||||||
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
|
||||||
|
|
||||||
function pick(threadId: string): void {
|
function pick(threadId: string): void {
|
||||||
setBrowsing(false);
|
setBrowsing(false);
|
||||||
|
setComposing(false);
|
||||||
setSelected(threadId);
|
setSelected(threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openBrowse(): void {
|
||||||
|
setComposing(false);
|
||||||
|
setBrowsing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCompose(): void {
|
||||||
|
setBrowsing(false);
|
||||||
|
setComposing(true);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="miu-messenger">
|
<div className="miu-messenger">
|
||||||
<aside className="miu-sidebar">
|
<aside className="miu-sidebar">
|
||||||
@@ -47,12 +61,12 @@ export function Messenger() {
|
|||||||
<div className="miu-section">
|
<div className="miu-section">
|
||||||
<div className="miu-section-head">
|
<div className="miu-section-head">
|
||||||
<span>Channels</span>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{channels.length > 0 ? (
|
{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>
|
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
|
||||||
)}
|
)}
|
||||||
@@ -60,12 +74,17 @@ export function Messenger() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="miu-section">
|
<div className="miu-section">
|
||||||
{channelsSupported ? (
|
{channelsSupported || directorySupported ? (
|
||||||
<div className="miu-section-head">
|
<div className="miu-section-head">
|
||||||
<span>Direct messages</span>
|
<span>Direct messages</span>
|
||||||
|
{directorySupported ? (
|
||||||
|
<button type="button" className="miu-section-add" title="New message" onClick={openCompose}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<ConversationList conversations={dms} selectedId={browsing ? null : selected} onSelect={pick} />
|
<ConversationList conversations={dms} selectedId={browsing || composing ? null : selected} onSelect={pick} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -77,12 +96,19 @@ export function Messenger() {
|
|||||||
pick(threadId);
|
pick(threadId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : composing ? (
|
||||||
|
<NewConversation
|
||||||
|
onCreated={(threadId) => {
|
||||||
|
refetch();
|
||||||
|
pick(threadId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
|
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{!browsing && selected && activeRoot ? (
|
{!browsing && !composing && selected && activeRoot ? (
|
||||||
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
|
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -500,6 +500,32 @@
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
cursor: pointer;
|
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 {
|
.miu-browser-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user