From 00a2cc474a3eabb0e5fa12181a6d243a7e0bd3e6 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 20:04:46 +0530 Subject: [PATCH] =?UTF-8?q?feat(messaging-ui):=20channels=20=E2=80=94=20br?= =?UTF-8?q?owse,=20join,=20leave,=20create=20(contract=20+=20mock=20+=20UI?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third membership beyond dm/group: a discoverable, joinable room. - contract: Membership += 'channel'; Conversation.topic; ChannelSummary + CreateChannelInput; optional adapter methods browseChannels/createChannel/ joinChannel/leaveChannel (capability-degrading — absent hides the UI) - MockAdapter implements them (seeds a joined public, a joinable public, and a private channel); listConversations now returns only threads I'm in - useChannels hook (browse/create/join/leave + supported flag) - UI: sidebar sections (Channels vs Direct messages), a + that opens a ChannelBrowser (join + create), # / lock glyphs; themeable styles - KernelClientAdapter passes channel membership + topic through - 4 channel tests (mock browse/join/create + render browse→join); 52 total green Co-Authored-By: Claude Opus 4.8 --- .../insignia-iios-messaging-ui-0.1.0.tgz | Bin 0 -> 11689 bytes packages/iios-messaging-ui/src/adapter.ts | 18 +++ .../src/adapters/kernel-client.ts | 5 +- .../iios-messaging-ui/src/adapters/mock.ts | 84 ++++++++++-- .../src/components/channel-browser.tsx | 93 +++++++++++++ .../src/components/channels.test.tsx | 54 ++++++++ .../src/components/conversation-list.tsx | 2 +- .../src/components/messenger.test.tsx | 5 +- .../src/components/messenger.tsx | 65 +++++++-- .../src/hooks/use-channels.ts | 84 ++++++++++++ .../src/hooks/use-conversations.test.tsx | 7 +- packages/iios-messaging-ui/src/index.ts | 6 + packages/iios-messaging-ui/src/styles.css | 126 ++++++++++++++++++ packages/iios-messaging-ui/src/types.ts | 23 +++- 14 files changed, 544 insertions(+), 28 deletions(-) create mode 100644 packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz create mode 100644 packages/iios-messaging-ui/src/components/channel-browser.tsx create mode 100644 packages/iios-messaging-ui/src/components/channels.test.tsx create mode 100644 packages/iios-messaging-ui/src/hooks/use-channels.ts 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 0000000000000000000000000000000000000000..c2ff6802eb5b64fe7c678306fce2664df406fbc7 GIT binary patch literal 11689 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 literal 0 HcmV?d00001 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 {