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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user