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(); private listeners = new Map 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 { // 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, 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 listMembers(threadId: string): Promise { const t = this.threads.get(threadId); if (!t) return []; return t.participants.map((id) => { if (id === ME) return { id: ME, name: 'You', kind: 'staff' }; return MOCK_PEOPLE.find((p) => p.id === id) ?? { id, name: id, kind: 'staff' }; }); } async directory(): Promise { return MOCK_PEOPLE.map((p) => ({ ...p })); } async addMember(threadId: string, userId: string): Promise { const t = this.threads.get(threadId); if (t && !t.participants.includes(userId)) t.participants = [...t.participants, userId]; } async removeMember(threadId: string, userId: string): Promise { const t = this.threads.get(threadId); if (t) t.participants = t.participants.filter((p) => p !== userId); } async renameConversation(threadId: string, subject: string): Promise { const t = this.threads.get(threadId); if (t) t.subject = subject; } async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { // A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door). if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) { const target = p.participantIds[0]; for (const [id, t] of this.threads) { if (t.membership === 'dm' && t.participants.length === 2 && t.participants.includes(ME) && t.participants.includes(target)) { return { threadId: id }; } } } 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 { return [...(this.threads.get(threadId)?.messages ?? [])]; } async send(threadId: string, content: string, opts?: SendOpts): Promise { 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 { 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 { 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 { // 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)); } }