diff --git a/packages/iios-messaging-ui/src/components/conversation-list.tsx b/packages/iios-messaging-ui/src/components/conversation-list.tsx new file mode 100644 index 0000000..14e75fb --- /dev/null +++ b/packages/iios-messaging-ui/src/components/conversation-list.tsx @@ -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
No conversations yet.
; + } + return ( + + ); +} diff --git a/packages/iios-messaging-ui/src/components/messenger.test.tsx b/packages/iios-messaging-ui/src/components/messenger.test.tsx new file mode 100644 index 0000000..1a78968 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/messenger.test.tsx @@ -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( + + + , + ); +} + +describe(' (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(); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/messenger.tsx b/packages/iios-messaging-ui/src/components/messenger.tsx new file mode 100644 index 0000000..da4cc7e --- /dev/null +++ b/packages/iios-messaging-ui/src/components/messenger.tsx @@ -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(null); + + useEffect(() => { + if (selected && conversations.some((c) => c.threadId === selected)) return; + setSelected(conversations[0]?.threadId ?? null); + }, [conversations, selected]); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/thread.tsx b/packages/iios-messaging-ui/src/components/thread.tsx new file mode 100644 index 0000000..0f44569 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/thread.tsx @@ -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 { + 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
Select a conversation.
; + } + + return ( +
+
+ {loading && messages.length === 0 ?
Loading…
: null} + {error ?
{error}
: null} + {messages.map((m) => ( +
+
{m.text}
+ {m.reactions && m.reactions.length > 0 ? ( +
+ {m.reactions.map((r) => ( + + {r.emoji} {r.count} + + ))} +
+ ) : null} + {m.mine && seenIds.has(m.id) ? Seen : null} +
+ ))} + {typingUserIds.length > 0 ? ( +
{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}
+ ) : null} +
+ +
+ { + setDraft(e.target.value); + sendTyping(); + }} + /> + +
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/index.ts b/packages/iios-messaging-ui/src/index.ts index 802d60d..7558faa 100644 --- a/packages/iios-messaging-ui/src/index.ts +++ b/packages/iios-messaging-ui/src/index.ts @@ -8,6 +8,11 @@ export { useConversations } from './hooks/use-conversations'; export { useMessages } from './hooks/use-messages'; 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 { ConversationsState } from './hooks/use-conversations'; export type { MessagesState, UiMessage } from './hooks/use-messages'; diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css new file mode 100644 index 0000000..43efa1f --- /dev/null +++ b/packages/iios-messaging-ui/src/styles.css @@ -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; +} diff --git a/packages/iios-messaging-ui/tsup.config.ts b/packages/iios-messaging-ui/tsup.config.ts index aea0692..3920383 100644 --- a/packages/iios-messaging-ui/tsup.config.ts +++ b/packages/iios-messaging-ui/tsup.config.ts @@ -6,9 +6,10 @@ export default defineConfig({ // 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 // 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'], - 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, external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'], });