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