feat(messaging-ui): channels — browse, join, leave, create (contract + mock + UI)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 20:04:46 +05:30
parent 3f1f89dfbe
commit 00a2cc474a
14 changed files with 544 additions and 28 deletions
+18
View File
@@ -1,6 +1,8 @@
import type { import type {
Attachment, Attachment,
ChannelSummary,
Conversation, Conversation,
CreateChannelInput,
Membership, Membership,
Message, Message,
MessageEvent, MessageEvent,
@@ -53,4 +55,20 @@ export interface MessagingAdapter {
/** Absent => treated as always connected (e.g. a pure-REST adapter). */ /** Absent => treated as always connected (e.g. a pure-REST adapter). */
isConnected?(): boolean; 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<ChannelSummary[]>;
/** 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<void>;
/** Leave a channel by id. */
leaveChannel?(threadId: string): Promise<void>;
} }
@@ -188,7 +188,9 @@ export class KernelClientAdapter implements MessagingAdapter {
private toConversation(t: ThreadSummary): Conversation { private toConversation(t: ThreadSummary): Conversation {
const others = t.participants.filter((p) => p !== this.me); 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 { return {
threadId: t.threadId, threadId: t.threadId,
title: t.subject?.trim() || others.join(', ') || 'Conversation', title: t.subject?.trim() || others.join(', ') || 'Conversation',
@@ -196,6 +198,7 @@ export class KernelClientAdapter implements MessagingAdapter {
membership, membership,
participants: [...t.participants], participants: [...t.participants],
unread: t.unread, unread: t.unread,
...(topic != null ? { topic } : {}),
...(t.lastMessage ? { lastMessage: t.lastMessage } : {}), ...(t.lastMessage ? { lastMessage: t.lastMessage } : {}),
...(t.lastAt ? { lastAt: t.lastAt } : {}), ...(t.lastAt ? { lastAt: t.lastAt } : {}),
}; };
+73 -11
View File
@@ -1,7 +1,10 @@
import type { MessagingAdapter } from '../adapter'; import type { MessagingAdapter } from '../adapter';
import type { import type {
Attachment, Attachment,
ChannelSummary,
ChannelVisibility,
Conversation, Conversation,
CreateChannelInput,
Membership, Membership,
Message, Message,
MessageEvent, MessageEvent,
@@ -26,6 +29,8 @@ interface MockThread {
subject: string | null; subject: string | null;
participants: string[]; participants: string[];
messages: Message[]; messages: Message[];
topic?: string | null;
visibility?: ChannelVisibility;
} }
const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name])); 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: [] }, { 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 { currentActorId(): string {
@@ -73,19 +93,61 @@ export class MockAdapter implements MessagingAdapter {
} }
async listConversations(): Promise<Conversation[]> { async listConversations(): Promise<Conversation[]> {
return [...this.threads.values()].map((t) => { // Only threads I'm a member of — an un-joined public channel appears in browse, not here.
const last = t.messages[t.messages.length - 1]; return [...this.threads.values()]
const others = t.participants.filter((p) => p !== ME); .filter((t) => t.participants.includes(ME))
return { .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<ChannelSummary[]> {
// 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, threadId: t.threadId,
title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation', name: t.subject ?? 'channel',
subject: t.subject, topic: t.topic ?? null,
membership: t.membership, visibility: t.visibility ?? 'public',
participants: [...t.participants], memberCount: t.participants.length,
unread: 0, joined: t.participants.includes(ME),
...(last ? { lastMessage: last.text, lastAt: last.at } : {}), }));
}; }
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<void> {
const t = this.threads.get(threadId);
if (t && !t.participants.includes(ME)) t.participants = [...t.participants, ME];
}
async leaveChannel(threadId: string): Promise<void> {
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 }> { async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
@@ -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<ChannelVisibility>('public');
const [busy, setBusy] = useState(false);
async function submitCreate(e: FormEvent): Promise<void> {
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<void> {
await join(threadId);
onJoined?.(threadId);
}
return (
<div className="miu-browser">
<div className="miu-browser-head">Channels</div>
<form className="miu-channel-create" onSubmit={submitCreate}>
<input
className="miu-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New channel name"
aria-label="Channel name"
/>
<input
className="miu-input"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="Topic (optional)"
aria-label="Channel topic"
/>
<div className="miu-channel-vis">
<label>
<input type="radio" name="miu-vis" checked={visibility === 'public'} onChange={() => setVisibility('public')} /> Public
</label>
<label>
<input type="radio" name="miu-vis" checked={visibility === 'private'} onChange={() => setVisibility('private')} /> Private
</label>
</div>
<button type="submit" className="miu-send" disabled={!name.trim() || busy}>
Create
</button>
</form>
<div className="miu-browser-list">
{loading && browsable.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{!loading && browsable.length === 0 ? <div className="miu-empty">No channels yet create one above.</div> : null}
{browsable.map((c) => (
<div key={c.threadId} className="miu-browser-row">
<span className="miu-channel-glyph" aria-hidden="true">{c.visibility === 'private' ? '🔒' : '#'}</span>
<span className="miu-browser-main">
<span className="miu-browser-name">{c.name}</span>
{c.topic ? <span className="miu-browser-topic">{c.topic}</span> : null}
<span className="miu-browser-meta">
{c.memberCount} member{c.memberCount === 1 ? '' : 's'}
</span>
</span>
{c.joined ? (
<span className="miu-browser-joined">Joined</span>
) : (
<button type="button" className="miu-join" onClick={() => void doJoin(c.threadId)}>
Join
</button>
)}
</div>
))}
</div>
</div>
);
}
@@ -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(
<MessagingProvider adapter={new MockAdapter()}>
<Messenger />
</MessagingProvider>,
);
}
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());
});
});
@@ -33,7 +33,7 @@ export function ConversationList({
onClick={() => onSelect(c.threadId)} onClick={() => onSelect(c.threadId)}
> >
<span className="miu-avatar" aria-hidden="true"> <span className="miu-avatar" aria-hidden="true">
{initials(c.title)} {c.membership === 'channel' ? '#' : initials(c.title)}
</span> </span>
<span className="miu-convrow-main"> <span className="miu-convrow-main">
<span className="miu-convrow-title">{c.title}</span> <span className="miu-convrow-title">{c.title}</span>
@@ -23,9 +23,8 @@ describe('<Messenger /> (rendered UI over an adapter)', () => {
it('sends a message through the adapter and shows it in the thread', async () => { it('sends a message through the adapter and shows it in the thread', async () => {
mount(); mount();
await screen.findByText('Crew is rolling out at 7.'); // Wait for the auto-selected thread's composer (avoids a race with the auto-select effect).
const input = (await screen.findByLabelText('Message')) as HTMLInputElement;
const input = screen.getByLabelText('Message') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'on our way' } }); fireEvent.change(input, { target: { value: 'on our way' } });
fireEvent.click(screen.getByText('Send')); fireEvent.click(screen.getByText('Send'));
@@ -1,31 +1,80 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useConversations } from '../hooks/use-conversations'; import { useConversations } from '../hooks/use-conversations';
import { useAdapter } from '../provider';
import { ConversationList } from './conversation-list'; import { ConversationList } from './conversation-list';
import { ChannelBrowser } from './channel-browser';
import { Thread } from './thread'; import { Thread } from './thread';
/** /**
* The drop-in messenger: conversation list + open thread. Owns only selection state; * The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
* all data flows through the injected adapter via the hooks. Auto-selects the first * with a channel browser when the adapter supports channels. Owns only selection + browse state;
* conversation so the pane is never empty on load. * all data flows through the injected adapter via the hooks.
*/ */
export function Messenger() { 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<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [browsing, setBrowsing] = useState(false);
useEffect(() => { useEffect(() => {
if (browsing) return;
if (selected && conversations.some((c) => c.threadId === selected)) return; if (selected && conversations.some((c) => c.threadId === selected)) return;
setSelected(conversations[0]?.threadId ?? null); 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 ( return (
<div className="miu-messenger"> <div className="miu-messenger">
<aside className="miu-sidebar"> <aside className="miu-sidebar">
{loading && conversations.length === 0 ? <div className="miu-empty">Loading</div> : null} {loading && conversations.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null} {error ? <div className="miu-empty miu-error">{error}</div> : null}
<ConversationList conversations={conversations} selectedId={selected} onSelect={setSelected} />
{channelsSupported ? (
<div className="miu-section">
<div className="miu-section-head">
<span>Channels</span>
<button type="button" className="miu-section-add" title="Browse channels" onClick={() => setBrowsing(true)}>
</button>
</div>
{channels.length > 0 ? (
<ConversationList conversations={channels} selectedId={browsing ? null : selected} onSelect={pick} />
) : (
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
)}
</div>
) : null}
<div className="miu-section">
{channelsSupported ? (
<div className="miu-section-head">
<span>Direct messages</span>
</div>
) : null}
<ConversationList conversations={dms} selectedId={browsing ? null : selected} onSelect={pick} />
</div>
</aside> </aside>
<section className="miu-main"> <section className="miu-main">
<Thread threadId={selected} /> {browsing ? (
<ChannelBrowser
onJoined={(threadId) => {
refetch();
pick(threadId);
}}
/>
) : (
<Thread threadId={selected} />
)}
</section> </section>
</div> </div>
); );
@@ -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<string>;
join: (threadId: string) => Promise<void>;
leave: (threadId: string) => Promise<void>;
}
export function useChannels(): ChannelsState {
const adapter = useAdapter();
const supported = typeof adapter.browseChannels === 'function';
const [browsable, setBrowsable] = useState<ChannelSummary[]>([]);
const [loading, setLoading] = useState(supported);
const [error, setError] = useState<string | null>(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 };
}
@@ -17,7 +17,8 @@ describe('useConversations', () => {
expect(result.current.loading).toBe(true); expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false)); 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.conversations[0]!.threadId).toBe('th_mock_1');
expect(result.current.error).toBeNull(); expect(result.current.error).toBeNull();
}); });
@@ -35,7 +36,7 @@ describe('useConversations', () => {
it('refetch picks up newly opened threads', async () => { it('refetch picks up newly opened threads', async () => {
const adapter = new MockAdapter(); const adapter = new MockAdapter();
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) }); 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 act(async () => {
await adapter.openThread({ participantIds: ['pp_dan'] }); await adapter.openThread({ participantIds: ['pp_dan'] });
@@ -44,6 +45,6 @@ describe('useConversations', () => {
result.current.refetch(); result.current.refetch();
}); });
await waitFor(() => expect(result.current.conversations).toHaveLength(3)); await waitFor(() => expect(result.current.conversations).toHaveLength(5));
}); });
}); });
+6
View File
@@ -6,19 +6,25 @@
export { MessagingProvider, useAdapter } from './provider'; export { MessagingProvider, useAdapter } from './provider';
export { useConversations } from './hooks/use-conversations'; export { useConversations } from './hooks/use-conversations';
export { useMessages } from './hooks/use-messages'; export { useMessages } from './hooks/use-messages';
export { useChannels } from './hooks/use-channels';
export { isOwnMessage } from './types'; export { isOwnMessage } from './types';
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens). // Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
export { Messenger } from './components/messenger'; export { Messenger } from './components/messenger';
export { ConversationList } from './components/conversation-list'; export { ConversationList } from './components/conversation-list';
export { ChannelBrowser } from './components/channel-browser';
export { Thread } from './components/thread'; 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';
export type { ChannelsState } from './hooks/use-channels';
export type { export type {
Attachment, Attachment,
ChannelSummary,
ChannelVisibility,
Conversation, Conversation,
CreateChannelInput,
Membership, Membership,
Message, Message,
MessageEvent, MessageEvent,
+126
View File
@@ -209,6 +209,132 @@
cursor: not-allowed; 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 */ /* misc */
.miu-empty { .miu-empty {
padding: 24px; padding: 24px;
+22 -1
View File
@@ -1,7 +1,9 @@
// Domain types for the messaging UI. Zero imports on purpose: this file must never // 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. // 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 { export interface Person {
id: string; id: string;
@@ -18,6 +20,25 @@ export interface Conversation {
unread: number; unread: number;
lastMessage?: string; lastMessage?: string;
lastAt?: 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 { export interface Reaction {