00a2cc474a
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>
231 lines
7.7 KiB
TypeScript
231 lines
7.7 KiB
TypeScript
import type { MessagingAdapter } from '../adapter';
|
|
import type {
|
|
Attachment,
|
|
ChannelSummary,
|
|
ChannelVisibility,
|
|
Conversation,
|
|
CreateChannelInput,
|
|
Membership,
|
|
Message,
|
|
MessageEvent,
|
|
Person,
|
|
SendOpts,
|
|
Unsubscribe,
|
|
} from '../types';
|
|
|
|
const ME = 'me';
|
|
|
|
export const MOCK_PEOPLE: Person[] = [
|
|
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
|
|
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
|
|
{ id: 'pp_priya', name: 'Priya Nair', kind: 'staff' },
|
|
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
|
|
{ id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' },
|
|
];
|
|
|
|
interface MockThread {
|
|
threadId: string;
|
|
membership: Membership;
|
|
subject: string | null;
|
|
participants: string[];
|
|
messages: Message[];
|
|
topic?: string | null;
|
|
visibility?: ChannelVisibility;
|
|
}
|
|
|
|
const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name]));
|
|
|
|
/**
|
|
* In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version
|
|
* used a module-level Map, which leaks between tests. Each `new MockAdapter()` is
|
|
* fully isolated.
|
|
*/
|
|
export class MockAdapter implements MessagingAdapter {
|
|
private seq = 100;
|
|
private threads = new Map<string, MockThread>();
|
|
private listeners = new Map<string, Set<(e: MessageEvent) => void>>();
|
|
|
|
constructor(private readonly now: () => string = () => new Date().toISOString()) {
|
|
const seededAt = this.now();
|
|
this.threads.set('th_mock_1', {
|
|
threadId: 'th_mock_1',
|
|
membership: 'dm',
|
|
subject: null,
|
|
participants: [ME, 'pp_sofia'],
|
|
messages: [
|
|
{
|
|
id: 'm1',
|
|
actorId: 'pp_sofia',
|
|
text: 'Can you review the Henderson estimate?',
|
|
at: seededAt,
|
|
reactions: [],
|
|
},
|
|
],
|
|
});
|
|
this.threads.set('th_mock_2', {
|
|
threadId: 'th_mock_2',
|
|
membership: 'group',
|
|
subject: 'Storm response — East side',
|
|
participants: [ME, 'pp_dan', 'pp_priya'],
|
|
messages: [
|
|
{ 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 {
|
|
return ME;
|
|
}
|
|
|
|
async listConversations(): Promise<Conversation[]> {
|
|
// 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,
|
|
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 }> {
|
|
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
|
|
const threadId = `th_mock_${this.seq++}`;
|
|
this.threads.set(threadId, {
|
|
threadId,
|
|
membership,
|
|
subject: p.subject ?? null,
|
|
participants: [ME, ...p.participantIds],
|
|
messages: [],
|
|
});
|
|
return { threadId };
|
|
}
|
|
|
|
async history(threadId: string): Promise<Message[]> {
|
|
return [...(this.threads.get(threadId)?.messages ?? [])];
|
|
}
|
|
|
|
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
|
const t = this.threads.get(threadId);
|
|
if (!t) throw new Error(`Unknown thread: ${threadId}`);
|
|
const message: Message = {
|
|
id: `m_${this.seq++}`,
|
|
actorId: ME,
|
|
text: content,
|
|
at: this.now(),
|
|
reactions: [],
|
|
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
|
...(opts?.attachment ? { attachment: opts.attachment } : {}),
|
|
};
|
|
t.messages = [...t.messages, message];
|
|
this.emit(threadId, { kind: 'message', message });
|
|
return message;
|
|
}
|
|
|
|
async react(threadId: string, messageId: string, emoji: string): Promise<void> {
|
|
const t = this.threads.get(threadId);
|
|
if (!t) return;
|
|
let next: Message | undefined;
|
|
t.messages = t.messages.map((m) => {
|
|
if (m.id !== messageId) return m;
|
|
const existing = (m.reactions ?? []).find((r) => r.emoji === emoji);
|
|
const reactions = existing
|
|
? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
|
|
: [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
|
|
next = { ...m, reactions };
|
|
return next;
|
|
});
|
|
if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] });
|
|
}
|
|
|
|
async upload(file: File): Promise<Attachment> {
|
|
return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name };
|
|
}
|
|
|
|
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
|
|
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
|
|
this.listeners.get(threadId)!.add(cb);
|
|
return () => {
|
|
this.listeners.get(threadId)?.delete(cb);
|
|
};
|
|
}
|
|
|
|
sendTyping(): void {
|
|
// No-op: nobody is typing back in a mock.
|
|
}
|
|
|
|
async markRead(): Promise<void> {
|
|
// No-op: the mock has no second party to report a read.
|
|
}
|
|
|
|
isConnected(): boolean {
|
|
return true;
|
|
}
|
|
|
|
private emit(threadId: string, e: MessageEvent): void {
|
|
this.listeners.get(threadId)?.forEach((cb) => cb(e));
|
|
}
|
|
}
|