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:
2026-07-21 17:47:30 +05:30
parent c5237a237a
commit 3f1f89dfbe
7 changed files with 433 additions and 2 deletions
@@ -0,0 +1,52 @@
import type { Conversation } from '../types';
/** Initials for the avatar chip — first letters of the first two words. */
function initials(title: string): string {
const parts = title.trim().split(/\s+/).filter(Boolean);
const chars = (parts[0]?.[0] ?? '') + (parts[1]?.[0] ?? '');
return (chars || '?').toUpperCase();
}
/**
* Presentational conversation list. Owns no data fetching — the parent passes the
* conversations (from useConversations) so a host can also drive it from its own store.
*/
export function ConversationList({
conversations,
selectedId,
onSelect,
}: {
conversations: Conversation[];
selectedId?: string | null;
onSelect: (threadId: string) => void;
}) {
if (conversations.length === 0) {
return <div className="miu-empty">No conversations yet.</div>;
}
return (
<ul className="miu-convlist" role="list">
{conversations.map((c) => (
<li key={c.threadId}>
<button
type="button"
className={`miu-convrow${c.threadId === selectedId ? ' is-active' : ''}`}
onClick={() => onSelect(c.threadId)}
>
<span className="miu-avatar" aria-hidden="true">
{initials(c.title)}
</span>
<span className="miu-convrow-main">
<span className="miu-convrow-title">{c.title}</span>
{c.lastMessage ? <span className="miu-convrow-preview">{c.lastMessage}</span> : null}
</span>
{c.unread > 0 ? (
<span className="miu-badge" aria-label={`${c.unread} unread`}>
{c.unread}
</span>
) : null}
</button>
</li>
))}
</ul>
);
}
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { Messenger } from './messenger';
import { MockAdapter } from '../adapters/mock';
function mount() {
return render(
<MessagingProvider adapter={new MockAdapter()}>
<Messenger />
</MessagingProvider>,
);
}
describe('<Messenger /> (rendered UI over an adapter)', () => {
it('renders the conversation list and auto-selects the first thread', async () => {
mount();
// Seeded group conversation title from the mock.
expect(await screen.findByText('Storm response — East side')).toBeTruthy();
// Auto-selected thread shows its seeded message.
expect(await screen.findByText('Crew is rolling out at 7.')).toBeTruthy();
});
it('sends a message through the adapter and shows it in the thread', async () => {
mount();
await screen.findByText('Crew is rolling out at 7.');
const input = screen.getByLabelText('Message') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'on our way' } });
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(screen.getByText('on our way')).toBeTruthy());
// Composer cleared after send.
expect(input.value).toBe('');
});
it('switches threads when another conversation is clicked', async () => {
mount();
// Click the DM (its title is the other participant's name from the mock directory).
fireEvent.click(await screen.findByText('Sofia Ramirez'));
expect(await screen.findByText('Can you review the Henderson estimate?')).toBeTruthy();
});
});
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import { useConversations } from '../hooks/use-conversations';
import { ConversationList } from './conversation-list';
import { Thread } from './thread';
/**
* The drop-in messenger: conversation list + open thread. Owns only selection state;
* all data flows through the injected adapter via the hooks. Auto-selects the first
* conversation so the pane is never empty on load.
*/
export function Messenger() {
const { conversations, loading, error } = useConversations();
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
if (selected && conversations.some((c) => c.threadId === selected)) return;
setSelected(conversations[0]?.threadId ?? null);
}, [conversations, selected]);
return (
<div className="miu-messenger">
<aside className="miu-sidebar">
{loading && conversations.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
<ConversationList conversations={conversations} selectedId={selected} onSelect={setSelected} />
</aside>
<section className="miu-main">
<Thread threadId={selected} />
</section>
</div>
);
}
@@ -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>
);
}
+5
View File
@@ -8,6 +8,11 @@ export { useConversations } from './hooks/use-conversations';
export { useMessages } from './hooks/use-messages'; export { useMessages } from './hooks/use-messages';
export { isOwnMessage } from './types'; export { isOwnMessage } from './types';
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
export { Messenger } from './components/messenger';
export { ConversationList } from './components/conversation-list';
export { Thread } from './components/thread';
export type { MessagingAdapter } from './adapter'; export type { MessagingAdapter } from './adapter';
export type { ConversationsState } from './hooks/use-conversations'; export type { ConversationsState } from './hooks/use-conversations';
export type { MessagesState, UiMessage } from './hooks/use-messages'; export type { MessagesState, UiMessage } from './hooks/use-messages';
+223
View File
@@ -0,0 +1,223 @@
/* Default theme for @insignia/iios-messaging-ui. Everything is driven by CSS variables
scoped to .miu-messenger, so a host restyles by overriding the tokens — no component edits. */
.miu-messenger {
--miu-bg: #0e0e13;
--miu-panel: #16161d;
--miu-panel-2: #1d1d26;
--miu-border: #262631;
--miu-text: #e9e9ef;
--miu-muted: #9a9aa7;
--miu-accent: #fda913;
--miu-accent-text: #1a1206;
--miu-radius: 12px;
display: flex;
height: 100%;
min-height: 0;
color: var(--miu-text);
background: var(--miu-bg);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 14px;
}
.miu-sidebar {
width: 300px;
flex-shrink: 0;
border-right: 1px solid var(--miu-border);
overflow-y: auto;
background: var(--miu-panel);
}
.miu-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* conversation list */
.miu-convlist {
list-style: none;
margin: 0;
padding: 0;
}
.miu-convrow {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 11px 14px;
border: none;
border-bottom: 1px solid var(--miu-border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.miu-convrow:hover {
background: var(--miu-panel-2);
}
.miu-convrow.is-active {
background: var(--miu-panel-2);
}
.miu-avatar {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 700;
color: var(--miu-accent-text);
background: var(--miu-accent);
}
.miu-convrow-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.miu-convrow-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.miu-convrow-preview {
color: var(--miu-muted);
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.miu-badge {
flex-shrink: 0;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
color: var(--miu-accent-text);
background: var(--miu-accent);
}
/* thread */
.miu-thread {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.miu-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.miu-msg {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 78%;
}
.miu-msg.is-mine {
align-self: flex-end;
align-items: flex-end;
}
.miu-bubble {
padding: 8px 12px;
border-radius: 14px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
white-space: pre-wrap;
word-break: break-word;
}
.miu-msg.is-mine .miu-bubble {
border: none;
color: var(--miu-accent-text);
background: var(--miu-accent);
}
.miu-msg.is-pending {
opacity: 0.6;
}
.miu-reactions {
display: flex;
gap: 4px;
margin-top: 3px;
}
.miu-reaction {
font-size: 12px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
}
.miu-reaction.is-mine {
border-color: var(--miu-accent);
}
.miu-seen {
font-size: 11px;
color: var(--miu-muted);
margin-top: 2px;
}
.miu-typing {
font-size: 12.5px;
color: var(--miu-muted);
font-style: italic;
}
/* composer */
.miu-composer {
display: flex;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--miu-border);
}
.miu-input {
flex: 1;
padding: 9px 12px;
border-radius: 10px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
color: var(--miu-text);
font: inherit;
outline: none;
}
.miu-input:focus {
border-color: var(--miu-accent);
}
.miu-send {
padding: 0 16px;
border-radius: 10px;
border: none;
font-weight: 700;
cursor: pointer;
color: var(--miu-accent-text);
background: var(--miu-accent);
}
.miu-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* misc */
.miu-empty {
padding: 24px;
color: var(--miu-muted);
text-align: center;
}
.miu-thread-empty {
margin: auto;
}
.miu-error {
color: #f0563f;
}
+3 -2
View File
@@ -6,9 +6,10 @@ export default defineConfig({
// entry, never reachable from `index`, because it imports vitest, and bundling // entry, never reachable from `index`, because it imports vitest, and bundling
// that into the main barrel would drag a test runner into every consumer's // that into the main barrel would drag a test runner into every consumer's
// production build. // production build.
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'], entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
format: ['esm'], format: ['esm'],
dts: true, // Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file).
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
clean: true, clean: true,
external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'], external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'],
}); });