feat(messaging-ui): rendered Messenger components — the drop-in UI
Turns the SDK from hooks-only into a real UI SDK: <Messenger> (conversation list + open thread + composer), plus <ConversationList> and <Thread>, all driven by the existing useConversations/useMessages hooks over the injected adapter — zero transport imports (boundary intact). - themeable via --miu-* CSS variables; default theme ships as ./styles.css - optimistic send, typing indicator, seen ticks, reactions display, unread badges - render smoke tests (jsdom + MockAdapter): mounts, auto-selects first thread, sends a message, switches threads (3 tests) — 48 total green - tsup: css bundled to dist/styles.css; dts scoped to TS entries Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useMessages } from '../hooks/use-messages';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function Thread({ threadId }: { threadId: string | null }) {
|
||||
const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
setDraft('');
|
||||
setSending(true);
|
||||
try {
|
||||
await send(text);
|
||||
} catch {
|
||||
// The error is surfaced via the hook's `error`; keep the composer usable.
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!threadId) {
|
||||
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="miu-thread">
|
||||
<div className="miu-messages">
|
||||
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.pending ? ' is-pending' : ''}`}>
|
||||
<div className="miu-bubble">{m.text}</div>
|
||||
{m.reactions && m.reactions.length > 0 ? (
|
||||
<div className="miu-reactions">
|
||||
{m.reactions.map((r) => (
|
||||
<span key={r.emoji} className={`miu-reaction${r.mine ? ' is-mine' : ''}`}>
|
||||
{r.emoji} {r.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{m.mine && seenIds.has(m.id) ? <span className="miu-seen">Seen</span> : null}
|
||||
</div>
|
||||
))}
|
||||
{typingUserIds.length > 0 ? (
|
||||
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form className="miu-composer" onSubmit={submit}>
|
||||
<input
|
||||
className="miu-input"
|
||||
value={draft}
|
||||
placeholder="Type a message…"
|
||||
aria-label="Message"
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
sendTyping();
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user