4faf05fee9
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>
98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|