diff --git a/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz b/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz new file mode 100644 index 0000000..c2ff680 Binary files /dev/null and b/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz differ diff --git a/packages/iios-messaging-ui/src/adapter.ts b/packages/iios-messaging-ui/src/adapter.ts index 1a13ff4..6df76e5 100644 --- a/packages/iios-messaging-ui/src/adapter.ts +++ b/packages/iios-messaging-ui/src/adapter.ts @@ -1,6 +1,8 @@ import type { Attachment, + ChannelSummary, Conversation, + CreateChannelInput, Membership, Message, MessageEvent, @@ -53,4 +55,20 @@ export interface MessagingAdapter { /** Absent => treated as always connected (e.g. a pure-REST adapter). */ isConnected?(): boolean; + + // ── Channels (optional capability) ────────────────────────────── + // A channel is just a third membership beyond dm/group: a discoverable, joinable room. + // Implement all four to enable the channels UI; absent => the UI hides channels entirely. + + /** Discoverable channels in the caller's scope, each flagged `joined`. */ + browseChannels?(): Promise; + + /** Create a channel; the creator joins as admin. Returns the new thread id. */ + createChannel?(input: CreateChannelInput): Promise<{ threadId: string }>; + + /** Join a (public) channel by id. */ + joinChannel?(threadId: string): Promise; + + /** Leave a channel by id. */ + leaveChannel?(threadId: string): Promise; } diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.ts index 3f3541e..5c148bc 100644 --- a/packages/iios-messaging-ui/src/adapters/kernel-client.ts +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.ts @@ -188,7 +188,9 @@ export class KernelClientAdapter implements MessagingAdapter { private toConversation(t: ThreadSummary): Conversation { const others = t.participants.filter((p) => p !== this.me); - const membership: Membership | null = t.membership === 'dm' || t.membership === 'group' ? t.membership : null; + const membership: Membership | null = + t.membership === 'dm' || t.membership === 'group' || t.membership === 'channel' ? t.membership : null; + const topic = (t.metadata as { topic?: string } | null)?.topic; return { threadId: t.threadId, title: t.subject?.trim() || others.join(', ') || 'Conversation', @@ -196,6 +198,7 @@ export class KernelClientAdapter implements MessagingAdapter { membership, participants: [...t.participants], unread: t.unread, + ...(topic != null ? { topic } : {}), ...(t.lastMessage ? { lastMessage: t.lastMessage } : {}), ...(t.lastAt ? { lastAt: t.lastAt } : {}), }; diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts index fbd31b8..7d81433 100644 --- a/packages/iios-messaging-ui/src/adapters/mock.ts +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -1,7 +1,10 @@ import type { MessagingAdapter } from '../adapter'; import type { Attachment, + ChannelSummary, + ChannelVisibility, Conversation, + CreateChannelInput, Membership, Message, MessageEvent, @@ -26,6 +29,8 @@ interface MockThread { subject: string | null; participants: string[]; messages: Message[]; + topic?: string | null; + visibility?: ChannelVisibility; } const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name])); @@ -66,6 +71,21 @@ export class MockAdapter implements MessagingAdapter { { id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: seededAt, reactions: [] }, ], }); + // Channels: a joined public one, a joinable public one (I'm NOT in it → shows in browse only), + // and a private one I'm a member of. + this.threads.set('th_ch_general', { + threadId: 'th_ch_general', membership: 'channel', subject: 'general', topic: 'Company-wide chatter', + visibility: 'public', participants: [ME, 'pp_dan', 'pp_priya', 'pp_sofia'], + messages: [{ id: 'c1', actorId: 'pp_priya', text: 'Welcome to #general 👋', at: seededAt, reactions: [] }], + }); + this.threads.set('th_ch_random', { + threadId: 'th_ch_random', membership: 'channel', subject: 'random', topic: 'Non-work banter', + visibility: 'public', participants: ['pp_dan', 'pp_sofia'], messages: [], + }); + this.threads.set('th_ch_deals', { + threadId: 'th_ch_deals', membership: 'channel', subject: 'deals', topic: 'Big pipeline moves', + visibility: 'private', participants: [ME, 'pp_sofia'], messages: [], + }); } currentActorId(): string { @@ -73,19 +93,61 @@ export class MockAdapter implements MessagingAdapter { } async listConversations(): Promise { - return [...this.threads.values()].map((t) => { - const last = t.messages[t.messages.length - 1]; - const others = t.participants.filter((p) => p !== ME); - return { + // Only threads I'm a member of — an un-joined public channel appears in browse, not here. + return [...this.threads.values()] + .filter((t) => t.participants.includes(ME)) + .map((t) => { + const last = t.messages[t.messages.length - 1]; + const others = t.participants.filter((p) => p !== ME); + return { + threadId: t.threadId, + title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation', + subject: t.subject, + membership: t.membership, + participants: [...t.participants], + unread: 0, + ...(t.topic != null ? { topic: t.topic } : {}), + ...(last ? { lastMessage: last.text, lastAt: last.at } : {}), + }; + }); + } + + async browseChannels(): Promise { + // Public channels are discoverable; private ones only if I'm already a member. + return [...this.threads.values()] + .filter((t) => t.membership === 'channel' && (t.visibility === 'public' || t.participants.includes(ME))) + .map((t) => ({ threadId: t.threadId, - title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation', - subject: t.subject, - membership: t.membership, - participants: [...t.participants], - unread: 0, - ...(last ? { lastMessage: last.text, lastAt: last.at } : {}), - }; + name: t.subject ?? 'channel', + topic: t.topic ?? null, + visibility: t.visibility ?? 'public', + memberCount: t.participants.length, + joined: t.participants.includes(ME), + })); + } + + async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> { + const threadId = `th_ch_${this.seq++}`; + this.threads.set(threadId, { + threadId, + membership: 'channel', + subject: input.name, + topic: input.topic ?? null, + visibility: input.visibility, + participants: [ME], + messages: [], }); + return { threadId }; + } + + async joinChannel(threadId: string): Promise { + const t = this.threads.get(threadId); + if (t && !t.participants.includes(ME)) t.participants = [...t.participants, ME]; + } + + async leaveChannel(threadId: string): Promise { + const t = this.threads.get(threadId); + if (t) t.participants = t.participants.filter((p) => p !== ME); } async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { diff --git a/packages/iios-messaging-ui/src/components/channel-browser.tsx b/packages/iios-messaging-ui/src/components/channel-browser.tsx new file mode 100644 index 0000000..876eeba --- /dev/null +++ b/packages/iios-messaging-ui/src/components/channel-browser.tsx @@ -0,0 +1,93 @@ +import { useState, type FormEvent } from 'react'; +import { useChannels } from '../hooks/use-channels'; +import type { ChannelVisibility } from '../types'; + +/** Browse + join discoverable channels, and create a new one. Shown in the main pane. */ +export function ChannelBrowser({ onJoined }: { onJoined?: (threadId: string) => void }) { + const { browsable, loading, error, join, create } = useChannels(); + const [name, setName] = useState(''); + const [topic, setTopic] = useState(''); + const [visibility, setVisibility] = useState('public'); + const [busy, setBusy] = useState(false); + + async function submitCreate(e: FormEvent): Promise { + e.preventDefault(); + const n = name.trim(); + if (!n || busy) return; + setBusy(true); + try { + const threadId = await create({ name: n, ...(topic.trim() ? { topic: topic.trim() } : {}), visibility }); + setName(''); + setTopic(''); + onJoined?.(threadId); + } catch { + // surfaced via the hook's error + } finally { + setBusy(false); + } + } + + async function doJoin(threadId: string): Promise { + await join(threadId); + onJoined?.(threadId); + } + + return ( +
+
Channels
+ +
+ setName(e.target.value)} + placeholder="New channel name" + aria-label="Channel name" + /> + setTopic(e.target.value)} + placeholder="Topic (optional)" + aria-label="Channel topic" + /> +
+ + +
+ +
+ +
+ {loading && browsable.length === 0 ?
Loading…
: null} + {error ?
{error}
: null} + {!loading && browsable.length === 0 ?
No channels yet — create one above.
: null} + {browsable.map((c) => ( +
+ + + {c.name} + {c.topic ? {c.topic} : null} + + {c.memberCount} member{c.memberCount === 1 ? '' : 's'} + + + {c.joined ? ( + Joined + ) : ( + + )} +
+ ))} +
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/channels.test.tsx b/packages/iios-messaging-ui/src/components/channels.test.tsx new file mode 100644 index 0000000..05cfe61 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/channels.test.tsx @@ -0,0 +1,54 @@ +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('channels (mock adapter)', () => { + it('browse returns public channels + private ones I am in, with a joined flag', async () => { + const a = new MockAdapter(); + const list = await a.browseChannels(); + const byId = new Map(list.map((c) => [c.threadId, c])); + expect(byId.get('th_ch_general')?.joined).toBe(true); // public, I'm in + expect(byId.get('th_ch_random')?.joined).toBe(false); // public, I'm NOT in + expect(byId.get('th_ch_deals')?.joined).toBe(true); // private, I'm in + // A private channel I'm not in must never surface in browse — none seeded, so all private here are mine. + expect(list.every((c) => c.visibility === 'public' || c.joined)).toBe(true); + }); + + it('join adds me and the channel then appears in my conversation list', async () => { + const a = new MockAdapter(); + expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(false); + await a.joinChannel('th_ch_random'); + expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(true); + expect((await a.browseChannels()).find((c) => c.threadId === 'th_ch_random')?.joined).toBe(true); + }); + + it('create makes a channel I am a member of', async () => { + const a = new MockAdapter(); + const { threadId } = await a.createChannel({ name: 'design', topic: 'UI stuff', visibility: 'public' }); + const conv = (await a.listConversations()).find((c) => c.threadId === threadId); + expect(conv?.membership).toBe('channel'); + expect(conv?.topic).toBe('UI stuff'); + }); + + it('UI: browse, then join a channel — it moves into the Channels section', async () => { + mount(); + // Open the browser via the Channels section "+". + fireEvent.click(await screen.findByTitle('Browse channels')); + // #random is browse-only (not joined) → has a Join button. + expect(await screen.findByText('random')).toBeTruthy(); + const joinButtons = screen.getAllByText('Join'); + fireEvent.click(joinButtons[0]!); + // After joining, we jump to the thread and the browser closes → composer is shown. + await waitFor(() => expect(screen.getByLabelText('Message')).toBeTruthy()); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/conversation-list.tsx b/packages/iios-messaging-ui/src/components/conversation-list.tsx index 14e75fb..828a910 100644 --- a/packages/iios-messaging-ui/src/components/conversation-list.tsx +++ b/packages/iios-messaging-ui/src/components/conversation-list.tsx @@ -33,7 +33,7 @@ export function ConversationList({ onClick={() => onSelect(c.threadId)} > {c.title} diff --git a/packages/iios-messaging-ui/src/components/messenger.test.tsx b/packages/iios-messaging-ui/src/components/messenger.test.tsx index 1a78968..d58e0b1 100644 --- a/packages/iios-messaging-ui/src/components/messenger.test.tsx +++ b/packages/iios-messaging-ui/src/components/messenger.test.tsx @@ -23,9 +23,8 @@ describe(' (rendered UI over an adapter)', () => { 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; + // Wait for the auto-selected thread's composer (avoids a race with the auto-select effect). + const input = (await screen.findByLabelText('Message')) as HTMLInputElement; fireEvent.change(input, { target: { value: 'on our way' } }); fireEvent.click(screen.getByText('Send')); diff --git a/packages/iios-messaging-ui/src/components/messenger.tsx b/packages/iios-messaging-ui/src/components/messenger.tsx index da4cc7e..604df07 100644 --- a/packages/iios-messaging-ui/src/components/messenger.tsx +++ b/packages/iios-messaging-ui/src/components/messenger.tsx @@ -1,31 +1,80 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useConversations } from '../hooks/use-conversations'; +import { useAdapter } from '../provider'; import { ConversationList } from './conversation-list'; +import { ChannelBrowser } from './channel-browser'; 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. + * The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread, + * with a channel browser when the adapter supports channels. Owns only selection + browse state; + * all data flows through the injected adapter via the hooks. */ export function Messenger() { - const { conversations, loading, error } = useConversations(); + const { conversations, loading, error, refetch } = useConversations(); + const adapter = useAdapter(); + const channelsSupported = typeof adapter.browseChannels === 'function'; + const [selected, setSelected] = useState(null); + const [browsing, setBrowsing] = useState(false); useEffect(() => { + if (browsing) return; if (selected && conversations.some((c) => c.threadId === selected)) return; setSelected(conversations[0]?.threadId ?? null); - }, [conversations, selected]); + }, [conversations, selected, browsing]); + + const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]); + const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]); + + function pick(threadId: string): void { + setBrowsing(false); + setSelected(threadId); + } return (
+
- + {browsing ? ( + { + refetch(); + pick(threadId); + }} + /> + ) : ( + + )}
); diff --git a/packages/iios-messaging-ui/src/hooks/use-channels.ts b/packages/iios-messaging-ui/src/hooks/use-channels.ts new file mode 100644 index 0000000..de47e3d --- /dev/null +++ b/packages/iios-messaging-ui/src/hooks/use-channels.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAdapter } from '../provider'; +import type { ChannelSummary, CreateChannelInput } from '../types'; + +export interface ChannelsState { + /** Discoverable channels (public + private-I'm-in), each flagged `joined`. */ + browsable: ChannelSummary[]; + loading: boolean; + error: string | null; + /** Whether the adapter implements channels at all — drives showing/hiding the channels UI. */ + supported: boolean; + refetch: () => void; + create: (input: CreateChannelInput) => Promise; + join: (threadId: string) => Promise; + leave: (threadId: string) => Promise; +} + +export function useChannels(): ChannelsState { + const adapter = useAdapter(); + const supported = typeof adapter.browseChannels === 'function'; + const [browsable, setBrowsable] = useState([]); + const [loading, setLoading] = useState(supported); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + if (!adapter.browseChannels) { + setLoading(false); + return; + } + let alive = true; + setLoading(true); + adapter + .browseChannels() + .then((list) => { + if (!alive) return; + setBrowsable(list); + setError(null); + }) + .catch((e: unknown) => { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + setBrowsable([]); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [adapter, nonce]); + + const refetch = useCallback(() => setNonce((n) => n + 1), []); + + const create = useCallback( + async (input: CreateChannelInput) => { + if (!adapter.createChannel) throw new Error('channels not supported by this adapter'); + const { threadId } = await adapter.createChannel(input); + setNonce((n) => n + 1); + return threadId; + }, + [adapter], + ); + + const join = useCallback( + async (threadId: string) => { + if (!adapter.joinChannel) throw new Error('channels not supported by this adapter'); + await adapter.joinChannel(threadId); + setNonce((n) => n + 1); + }, + [adapter], + ); + + const leave = useCallback( + async (threadId: string) => { + if (!adapter.leaveChannel) throw new Error('channels not supported by this adapter'); + await adapter.leaveChannel(threadId); + setNonce((n) => n + 1); + }, + [adapter], + ); + + return { browsable, loading, error, supported, refetch, create, join, leave }; +} diff --git a/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx index 0970680..8413071 100644 --- a/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx +++ b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx @@ -17,7 +17,8 @@ describe('useConversations', () => { expect(result.current.loading).toBe(true); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.conversations).toHaveLength(2); + // 2 DMs/groups + 2 channels I'm a member of (the un-joined public channel is browse-only). + expect(result.current.conversations).toHaveLength(4); expect(result.current.conversations[0]!.threadId).toBe('th_mock_1'); expect(result.current.error).toBeNull(); }); @@ -35,7 +36,7 @@ describe('useConversations', () => { it('refetch picks up newly opened threads', async () => { const adapter = new MockAdapter(); const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) }); - await waitFor(() => expect(result.current.conversations).toHaveLength(2)); + await waitFor(() => expect(result.current.conversations).toHaveLength(4)); await act(async () => { await adapter.openThread({ participantIds: ['pp_dan'] }); @@ -44,6 +45,6 @@ describe('useConversations', () => { result.current.refetch(); }); - await waitFor(() => expect(result.current.conversations).toHaveLength(3)); + await waitFor(() => expect(result.current.conversations).toHaveLength(5)); }); }); diff --git a/packages/iios-messaging-ui/src/index.ts b/packages/iios-messaging-ui/src/index.ts index 7558faa..dd18ac0 100644 --- a/packages/iios-messaging-ui/src/index.ts +++ b/packages/iios-messaging-ui/src/index.ts @@ -6,19 +6,25 @@ export { MessagingProvider, useAdapter } from './provider'; export { useConversations } from './hooks/use-conversations'; export { useMessages } from './hooks/use-messages'; +export { useChannels } from './hooks/use-channels'; 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 { ChannelBrowser } from './components/channel-browser'; 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'; +export type { ChannelsState } from './hooks/use-channels'; export type { Attachment, + ChannelSummary, + ChannelVisibility, Conversation, + CreateChannelInput, Membership, Message, MessageEvent, diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 43efa1f..648761b 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -209,6 +209,132 @@ cursor: not-allowed; } +/* sidebar sections + channel browser */ +.miu-section { + border-bottom: 1px solid var(--miu-border); +} +.miu-section-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--miu-muted); +} +.miu-section-add { + border: none; + background: transparent; + color: var(--miu-muted); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0 4px; +} +.miu-section-add:hover { + color: var(--miu-text); +} +.miu-empty-sm { + padding: 8px 14px 12px; + text-align: left; + font-size: 12.5px; +} + +.miu-browser { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 16px; + gap: 14px; + overflow-y: auto; +} +.miu-browser-head { + font-size: 16px; + font-weight: 700; +} +.miu-channel-create { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--miu-border); + border-radius: var(--miu-radius); + background: var(--miu-panel); +} +.miu-channel-vis { + display: flex; + gap: 16px; + font-size: 13px; + color: var(--miu-muted); +} +.miu-channel-vis label { + display: inline-flex; + align-items: center; + gap: 5px; + cursor: pointer; +} +.miu-browser-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.miu-browser-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--miu-border); + border-radius: var(--miu-radius); + background: var(--miu-panel); +} +.miu-channel-glyph { + width: 30px; + height: 30px; + flex-shrink: 0; + border-radius: 8px; + display: grid; + place-items: center; + font-weight: 700; + color: var(--miu-muted); + background: var(--miu-panel-2); +} +.miu-browser-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +.miu-browser-name { + font-weight: 600; +} +.miu-browser-topic { + font-size: 12.5px; + color: var(--miu-muted); +} +.miu-browser-meta { + font-size: 11.5px; + color: var(--miu-muted); +} +.miu-join { + flex-shrink: 0; + padding: 5px 14px; + border-radius: 8px; + border: none; + font-weight: 700; + cursor: pointer; + color: var(--miu-accent-text); + background: var(--miu-accent); +} +.miu-browser-joined { + flex-shrink: 0; + font-size: 12px; + color: var(--miu-muted); +} + /* misc */ .miu-empty { padding: 24px; diff --git a/packages/iios-messaging-ui/src/types.ts b/packages/iios-messaging-ui/src/types.ts index ae99f9b..ff667da 100644 --- a/packages/iios-messaging-ui/src/types.ts +++ b/packages/iios-messaging-ui/src/types.ts @@ -1,7 +1,9 @@ // Domain types for the messaging UI. Zero imports on purpose: this file must never // reach for a transport package. Adapters map their own DTOs onto these. -export type Membership = 'dm' | 'group'; +export type Membership = 'dm' | 'group' | 'channel'; + +export type ChannelVisibility = 'public' | 'private'; export interface Person { id: string; @@ -18,6 +20,25 @@ export interface Conversation { unread: number; lastMessage?: string; lastAt?: string; + /** Channel description (channels only). */ + topic?: string | null; +} + +/** A discoverable channel (from browseChannels) — includes ones the caller has NOT joined. */ +export interface ChannelSummary { + threadId: string; + name: string; + topic: string | null; + visibility: ChannelVisibility; + memberCount: number; + /** True if the current user is already a member. */ + joined: boolean; +} + +export interface CreateChannelInput { + name: string; + topic?: string; + visibility: ChannelVisibility; } export interface Reaction {