import type { Channel, MessageResponse, QueryChannelAPIResponse, SendMessageAPIResponse, } from 'stream-chat' import { StreamChat } from 'stream-chat' import { createMockStreamChatClient, type MockMessagingUser, } from './createMockStreamChatClient' /** * A seed message, authored from the viewer's perspective. `from: 'me'` is the * connected user (the viewer); `from: 'them'` is the other participant. */ export interface MockMessage { id?: string text?: string from: 'me' | 'them' /** Stream attachment payloads, rendered by the toolkit's attachment gate. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any attachments?: any[] /** e.g. `{ custom_type: 'MESSAGE_WELCOME' }`. */ metadata?: Record } export interface CreateMockMessagingClientOptions { /** The connected/viewing user. */ currentUser: MockMessagingUser /** The other party in the direct conversation (e.g. the creator/linker). */ participant: MockMessagingUser /** Seed conversation, oldest-first. */ messages?: MockMessage[] /** Channel id; defaults to `mock-dm`. */ channelId?: string /** * Optional canned reply. Called after the viewer sends a message; return the * reply text (or a partial message) to have `participant` echo a response, or * a falsy value for no reply. Drives the "send echo" in `dev:mock-messaging`. */ onSend?: ( text: string ) => string | { text?: string; attachments?: unknown[] } | null | undefined } export interface MockMessagingClient { /** Pass to ``. */ client: StreamChat /** The seeded direct-conversation channel. */ channel: Channel /** Pass to ``. */ participantFilterId: string } const REPLY_DELAY_MS = 600 /** * Builds an offline Stream client seeded with a single direct conversation, so * consumers can render the **real** messaging UI (MessagingProvider → * MessagingShell → ChannelView) without a Stream backend — for `dev:mock-*` * surfaces and Storybook. It is a real `StreamChat` whose network calls * (`connectUser`, `queryChannels`, `channel.watch`, `channel.sendMessage`) are * replaced with in-memory behaviour driven by Stream's own channel-state * machinery, so rendering fidelity matches production. * * Ships from `@linktr.ee/messaging-react/testing` and must never be imported by * production code. */ export function createMockMessagingClient({ currentUser, participant, messages = [], channelId = 'mock-dm', onSend, }: CreateMockMessagingClientOptions): MockMessagingClient { // Offline client marked connected without a WebSocket. `stubConnection` also // no-ops connectUser/disconnectUser/userMuteStatus: `MessagingProvider` // renders `` directly so it never calls connectUser, // and handling the `message.new` events dispatched on send + echo below runs // Channel._countMessageAsUnread → client.userMuteStatus, which throws offline. const client = createMockStreamChatClient(currentUser, { stubConnection: true, }) const cid = `messaging:${channelId}` const toStreamMessage = ( message: MockMessage, index: number ): MessageResponse => { const author = message.from === 'me' ? currentUser : participant // Stagger timestamps oldest-first so the list orders naturally. Must be a // Date, not an ISO string: we seed `channel.state.messages` directly // (bypassing Stream's wire→Date parsing), and stream-chat-react calls // `.getTime()` on `created_at` (e.g. useCooldownTimer / addToMessageList). const createdAt = new Date(Date.now() - (messages.length - index) * 60_000) return { id: message.id ?? `mock-msg-${index}`, text: message.text ?? '', type: 'regular', html: message.text ? `

${message.text}

` : '', user: author, attachments: message.attachments ?? [], latest_reactions: [], own_reactions: [], reaction_counts: {}, reaction_scores: {}, reply_count: 0, status: 'received', cid, created_at: createdAt, updated_at: createdAt, mentioned_users: [], ...(message.metadata ? { metadata: message.metadata } : {}), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any } const seededMessages = messages.map(toStreamMessage) const memberState = { [currentUser.id]: { user: { ...currentUser, is_account: false }, user_id: currentUser.id, role: 'owner', is_account: false, }, [participant.id]: { user: { ...participant, is_account: true }, user_id: participant.id, role: 'member', is_account: true, }, } const channel = client.channel('messaging', channelId, { // eslint-disable-next-line @typescript-eslint/no-explicit-any members: [currentUser.id, participant.id] as any, }) // Read/unread bookkeeping consults channel mute status; without a live // connection the real method throws, so report "not muted". channel.muteStatus = () => ({ muted: false, createdAt: null, expiresAt: null, }) // Neutralise the rest of the connection-dependent channel API that // stream-chat-react calls during the chat lifecycle. Each of these POSTs to // Stream or checks for a live WebSocket and would otherwise throw // "Make sure to await connectUser() first." offline. They are fire-and-forget // (read receipts, typing) or read-only (pagination, unread) so no-ops are safe. /* eslint-disable @typescript-eslint/no-explicit-any */ channel.markRead = (async () => ({}) as any) as any channel.keystroke = (async () => undefined) as any channel.stopTyping = (async () => undefined) as any channel.sendReaction = (async () => ({}) as any) as any channel.deleteReaction = (async () => ({}) as any) as any channel.countUnread = (() => 0) as any // Pagination: report no older pages so "load more" resolves to a no-op. channel.query = (async () => ({ messages: [] }) as any) as any // Leave/Delete Conversation calls channel.hide(); the real method POSTs to // Stream, so no-op it to keep the action working offline. channel.hide = (async () => ({}) as any) as any /* eslint-enable @typescript-eslint/no-explicit-any */ // Seed state locally instead of fetching. Mirrors the proven ChannelView // story mock: override `watch` to populate `state` and return a canned // QueryChannelAPIResponse. `stream-chat-react` may call `watch()` again on // remount/navigation, so seed only once — otherwise a re-watch would wipe // messages added via `sendMessage` + the send-echo reply, resetting the chat. let hasSeeded = false channel.watch = async () => { if (!hasSeeded) { // eslint-disable-next-line @typescript-eslint/no-explicit-any channel.state.messages = seededMessages as unknown as any[] hasSeeded = true } // eslint-disable-next-line @typescript-eslint/no-explicit-any channel.state.members = memberState as unknown as any return { channel: { members: [currentUser.id, participant.id] }, members: [], messages: channel.state.messages, watchers: [], pinned_messages: [], duration: '0ms', } as unknown as QueryChannelAPIResponse } const appendMessage = (message: MessageResponse) => { // addMessageSorted is Stream's own state mutation (dedupes by id), so the // toolkit re-renders through the real channel-state path. // eslint-disable-next-line @typescript-eslint/no-explicit-any channel.state.addMessageSorted(message as any) client.dispatchEvent({ type: 'message.new', cid, message, user: message.user ?? undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) } let sentCount = 0 let replyCount = 0 channel.sendMessage = async (message) => { const text = typeof message === 'string' ? message : ((message as { text?: string })?.text ?? '') const sent = { ...toStreamMessage( { text, from: 'me', id: `mock-sent-${sentCount++}` }, seededMessages.length ), // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(typeof message === 'object' ? (message as any) : {}), user: currentUser, cid, } as MessageResponse // stream-chat-react adds the outgoing message optimistically; addMessageSorted // dedupes by id, so confirming here is safe and idempotent. appendMessage(sent) const reply = onSend?.(text) if (reply) { const replyText = typeof reply === 'string' ? reply : reply.text const replyAttachments = typeof reply === 'string' ? undefined : reply.attachments // Capture a unique id before scheduling: two sends within REPLY_DELAY_MS // would otherwise both read the same later `sentCount` at fire time and // emit colliding reply ids, so Stream's id-based dedupe drops one echo. const replyId = `mock-reply-${replyCount++}` setTimeout(() => { appendMessage( toStreamMessage( { text: replyText, from: 'them', // eslint-disable-next-line @typescript-eslint/no-explicit-any attachments: replyAttachments as any, id: replyId, }, seededMessages.length ) ) }, REPLY_DELAY_MS) } return { message: sent } as unknown as SendMessageAPIResponse } // MessagingShell finds the direct conversation via queryChannels; always // return the one seeded channel regardless of the filter. // eslint-disable-next-line @typescript-eslint/no-explicit-any client.queryChannels = async () => [channel] as any // Initialise state up-front so first render already has the conversation. void channel.watch() return { client, channel, participantFilterId: participant.id } }