@linktr.ee/messaging-react 3.7.0-rc-1783333932 → 3.7.0-rc-1783334820

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/{Card-C8fHW9JN.js → Card-B00W_V2V.js} +2 -2
  2. package/dist/{Card-C8fHW9JN.js.map → Card-B00W_V2V.js.map} +1 -1
  3. package/dist/{Card-DPysc-d8.js → Card-B22ztgFZ.js} +3 -3
  4. package/dist/{Card-DPysc-d8.js.map → Card-B22ztgFZ.js.map} +1 -1
  5. package/dist/{Card-BApYAXry.js → Card-B6M_Z_2p.js} +2 -2
  6. package/dist/{Card-BApYAXry.js.map → Card-B6M_Z_2p.js.map} +1 -1
  7. package/dist/{Card-CZ6WAwfm.cjs → Card-B9wzbhJO.cjs} +2 -2
  8. package/dist/{Card-CZ6WAwfm.cjs.map → Card-B9wzbhJO.cjs.map} +1 -1
  9. package/dist/{Card-BcTN2501.cjs → Card-Bh7-DiTk.cjs} +2 -2
  10. package/dist/{Card-BcTN2501.cjs.map → Card-Bh7-DiTk.cjs.map} +1 -1
  11. package/dist/{Card-DU_zHX8a.cjs → Card-Qudta4p2.cjs} +2 -2
  12. package/dist/{Card-DU_zHX8a.cjs.map → Card-Qudta4p2.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-CbK80G-d.js → LockedThumbnail-CrYroEV1.js} +2 -2
  14. package/dist/{LockedThumbnail-CbK80G-d.js.map → LockedThumbnail-CrYroEV1.js.map} +1 -1
  15. package/dist/{LockedThumbnail-Ca-qH-ZE.cjs → LockedThumbnail-DPIsI8LL.cjs} +2 -2
  16. package/dist/{LockedThumbnail-Ca-qH-ZE.cjs.map → LockedThumbnail-DPIsI8LL.cjs.map} +1 -1
  17. package/dist/index-B-1G-O9u.cjs +2 -0
  18. package/dist/index-B-1G-O9u.cjs.map +1 -0
  19. package/dist/{index-CNoa6hhY.js → index-XS-fkenc.js} +6 -4
  20. package/dist/index-XS-fkenc.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +31 -16
  23. package/dist/index.js +1 -1
  24. package/dist/testing.cjs +1 -1
  25. package/dist/testing.cjs.map +1 -1
  26. package/dist/testing.js +28 -21
  27. package/dist/testing.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/providers/MessagingProvider.test.tsx +43 -0
  30. package/src/providers/MessagingProvider.tsx +8 -1
  31. package/src/testing/createMockMessagingClient.ts +18 -3
  32. package/src/types.ts +35 -18
  33. package/dist/index-C1p97mNU.cjs +0 -2
  34. package/dist/index-C1p97mNU.cjs.map +0 -1
  35. package/dist/index-CNoa6hhY.js.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-C1p97mNU.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-B-1G-O9u.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -1232,29 +1232,44 @@ declare interface MessagingContextValue {
1232
1232
  export declare const MessagingProvider: default_2.FC<MessagingProviderProps>;
1233
1233
 
1234
1234
  /**
1235
- * MessagingProvider component props
1235
+ * apiKey mode: the provider builds its own Stream client from `apiKey` and owns
1236
+ * the connect/disconnect lifecycle. Both fields are required so a non-injected
1237
+ * consumer cannot compile a provider that never connects.
1236
1238
  */
1237
- export declare interface MessagingProviderProps {
1239
+ declare interface MessagingProviderApiKeyProps extends MessagingProviderBaseProps {
1240
+ client?: undefined;
1241
+ apiKey: string;
1242
+ serviceConfig: Omit<StreamChatServiceConfig, 'apiKey'>;
1243
+ }
1244
+
1245
+ declare interface MessagingProviderBaseProps {
1238
1246
  children: React.ReactNode;
1239
1247
  user: MessagingUser | null;
1240
- /**
1241
- * An already-constructed Stream client. When provided it takes precedence
1242
- * over `apiKey`: the provider renders `<Chat>` against this exact instance
1243
- * and does NOT create a StreamChatService or call `connectUser` — the caller
1244
- * owns the client's connection lifecycle. This is the seam for offline/dev
1245
- * (`createMockMessagingClient` from `@linktr.ee/messaging-react/testing`) and
1246
- * for tests, so consumers render the real UI instead of forking it.
1247
- * `apiKey`/`serviceConfig` are ignored while `client` is set.
1248
- */
1249
- client?: StreamChat;
1250
- /** Required unless `client` is provided. */
1251
- serviceConfig?: Omit<StreamChatServiceConfig, 'apiKey'>;
1252
- /** Required unless `client` is provided. */
1253
- apiKey?: string;
1254
1248
  capabilities?: MessagingCapabilities;
1255
1249
  debug?: boolean;
1256
1250
  }
1257
1251
 
1252
+ /**
1253
+ * Injected-client mode: the provider renders `<Chat>` against an
1254
+ * already-constructed Stream client and does NOT create a StreamChatService or
1255
+ * call `connectUser` — the caller owns the client's connection lifecycle. This
1256
+ * is the seam for offline/dev (`createMockMessagingClient` from
1257
+ * `@linktr.ee/messaging-react/testing`) and for tests, so consumers render the
1258
+ * real UI instead of forking it. `apiKey`/`serviceConfig` are not used here.
1259
+ */
1260
+ declare interface MessagingProviderInjectedClientProps extends MessagingProviderBaseProps {
1261
+ client: StreamChat;
1262
+ apiKey?: never;
1263
+ serviceConfig?: never;
1264
+ }
1265
+
1266
+ /**
1267
+ * MessagingProvider component props. Either an injected `client` (caller owns
1268
+ * the lifecycle) or an `apiKey` + `serviceConfig` (provider owns it) — never
1269
+ * neither.
1270
+ */
1271
+ export declare type MessagingProviderProps = MessagingProviderInjectedClientProps | MessagingProviderApiKeyProps;
1272
+
1258
1273
  /**
1259
1274
  * Direct-conversation surface for one specific participant.
1260
1275
  *
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as d, m as p, n as v, o as C, p as A, q as k, s as F, t as P, u as b, v as f, w as x, x as y, y as S, z as q, B as z, D as B, E as D, G as E } from "./index-CNoa6hhY.js";
1
+ import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as c, i as u, j as L, k as h, l as d, m as p, n as v, o as C, p as A, q as k, s as F, t as P, u as b, v as f, w as x, x as y, y as S, z as q, B as z, D as B, E as D, G as E } from "./index-XS-fkenc.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/dist/testing.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const k=require("stream-chat");function b(s,{stubConnection:n=!1}={}){const a=new k.StreamChat("mock-api-key",{allowServerSideConnect:!0});return a.userID=s.id,a.user=s,n&&(a.connectUser=async()=>({me:s}),a.disconnectUser=async()=>{},a.userMuteStatus=(()=>!1)),a}const v=600;function w({currentUser:s,participant:n,messages:a=[],channelId:y="mock-dm",onSend:d}){const r=b(s,{stubConnection:!0}),u=`messaging:${y}`,l=(t,c)=>{const i=t.from==="me"?s:n,o=new Date(Date.now()-(a.length-c)*6e4);return{id:t.id??`mock-msg-${c}`,text:t.text??"",type:"regular",html:t.text?`<p>${t.text}</p>`:"",user:i,attachments:t.attachments??[],latest_reactions:[],own_reactions:[],reaction_counts:{},reaction_scores:{},reply_count:0,status:"received",cid:u,created_at:o,updated_at:o,mentioned_users:[],...t.metadata?{metadata:t.metadata}:{}}},m=a.map(l),p={[s.id]:{user:{...s,is_account:!1},user_id:s.id,role:"owner",is_account:!1},[n.id]:{user:{...n,is_account:!0},user_id:n.id,role:"member",is_account:!0}},e=r.channel("messaging",y,{members:[s.id,n.id]});e.muteStatus=(()=>({muted:!1,createdAt:null,expiresAt:null})),e.markRead=(async()=>({})),e.keystroke=(async()=>{}),e.stopTyping=(async()=>{}),e.sendReaction=(async()=>({})),e.deleteReaction=(async()=>({})),e.countUnread=(()=>0),e.query=(async()=>({messages:[]}));let h=!1;e.watch=async()=>(h||(e.state.messages=m,h=!0),e.state.members=p,{channel:{members:[s.id,n.id]},members:[],messages:e.state.messages,watchers:[],pinned_messages:[],duration:"0ms"});const f=t=>{e.state.addMessageSorted(t),r.dispatchEvent({type:"message.new",cid:u,message:t,user:t.user??void 0})};let _=0;return e.sendMessage=async t=>{const c=typeof t=="string"?t:(t==null?void 0:t.text)??"",i={...l({text:c,from:"me",id:`mock-sent-${_++}`},m.length),...typeof t=="object"?t:{},user:s,cid:u};f(i);const o=d==null?void 0:d(c);if(o){const g=typeof o=="string"?o:o.text,M=typeof o=="string"?void 0:o.attachments;setTimeout(()=>{f(l({text:g,from:"them",attachments:M,id:`mock-reply-${_}`},m.length))},v)}return{message:i}},r.queryChannels=async()=>[e],e.watch(),{client:r,channel:e,participantFilterId:n.id}}exports.createMockMessagingClient=w;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const b=require("stream-chat");function v(s,{stubConnection:n=!1}={}){const a=new b.StreamChat("mock-api-key",{allowServerSideConnect:!0});return a.userID=s.id,a.user=s,n&&(a.connectUser=async()=>({me:s}),a.disconnectUser=async()=>{},a.userMuteStatus=(()=>!1)),a}const w=600;function x({currentUser:s,participant:n,messages:a=[],channelId:y="mock-dm",onSend:d}){const r=v(s,{stubConnection:!0}),u=`messaging:${y}`,l=(t,c)=>{const i=t.from==="me"?s:n,o=new Date(Date.now()-(a.length-c)*6e4);return{id:t.id??`mock-msg-${c}`,text:t.text??"",type:"regular",html:t.text?`<p>${t.text}</p>`:"",user:i,attachments:t.attachments??[],latest_reactions:[],own_reactions:[],reaction_counts:{},reaction_scores:{},reply_count:0,status:"received",cid:u,created_at:o,updated_at:o,mentioned_users:[],...t.metadata?{metadata:t.metadata}:{}}},m=a.map(l),_={[s.id]:{user:{...s,is_account:!1},user_id:s.id,role:"owner",is_account:!1},[n.id]:{user:{...n,is_account:!0},user_id:n.id,role:"member",is_account:!0}},e=r.channel("messaging",y,{members:[s.id,n.id]});e.muteStatus=(()=>({muted:!1,createdAt:null,expiresAt:null})),e.markRead=(async()=>({})),e.keystroke=(async()=>{}),e.stopTyping=(async()=>{}),e.sendReaction=(async()=>({})),e.deleteReaction=(async()=>({})),e.countUnread=(()=>0),e.query=(async()=>({messages:[]})),e.hide=(async()=>({}));let h=!1;e.watch=async()=>(h||(e.state.messages=m,h=!0),e.state.members=_,{channel:{members:[s.id,n.id]},members:[],messages:e.state.messages,watchers:[],pinned_messages:[],duration:"0ms"});const f=t=>{e.state.addMessageSorted(t),r.dispatchEvent({type:"message.new",cid:u,message:t,user:t.user??void 0})};let p=0,g=0;return e.sendMessage=async t=>{const c=typeof t=="string"?t:(t==null?void 0:t.text)??"",i={...l({text:c,from:"me",id:`mock-sent-${p++}`},m.length),...typeof t=="object"?t:{},user:s,cid:u};f(i);const o=d==null?void 0:d(c);if(o){const M=typeof o=="string"?o:o.text,k=typeof o=="string"?void 0:o.attachments,C=`mock-reply-${g++}`;setTimeout(()=>{f(l({text:M,from:"them",attachments:k,id:C},m.length))},w)}return{message:i}},r.queryChannels=async()=>[e],e.watch(),{client:r,channel:e,participantFilterId:n.id}}exports.createMockMessagingClient=x;
2
2
  //# sourceMappingURL=testing.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.cjs","sources":["../src/testing/createMockStreamChatClient.ts","../src/testing/createMockMessagingClient.ts"],"sourcesContent":["import { StreamChat } from 'stream-chat'\n\nimport type { MockMessagingUser } from './createMockMessagingClient'\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nexport interface CreateMockStreamChatClientOptions {\n /**\n * Also replace the connection-dependent client methods (`connectUser`,\n * `disconnectUser`, `userMuteStatus`) with offline no-ops. Needed when the\n * client drives a flow that would otherwise open a WebSocket or throw\n * \"Make sure to await connectUser() first.\" offline. Direct `<Chat client>`\n * rendering (e.g. component stories) does not need this.\n */\n stubConnection?: boolean\n}\n\n/**\n * Builds an offline `StreamChat` for mocks and stories: a real client on a\n * throwaway api key with `userID`/`user` set so `<Chat>` treats it as\n * connected without a backend. The single place that knows how to construct a\n * connectionless Stream client, so a stream-chat version bump only needs\n * updating here rather than in each mock surface.\n */\nexport function createMockStreamChatClient(\n user: MockMessagingUser,\n { stubConnection = false }: CreateMockStreamChatClientOptions = {}\n): StreamChat {\n const client = new StreamChat('mock-api-key', {\n allowServerSideConnect: true,\n })\n client.userID = user.id\n client.user = user as any\n\n if (stubConnection) {\n client.connectUser = async () => ({ me: user }) as any\n client.disconnectUser = async () => undefined\n client.userMuteStatus = (() => false) as any\n }\n\n return client\n}\n\n/* eslint-enable @typescript-eslint/no-explicit-any */\n","import type {\n Channel,\n MessageResponse,\n QueryChannelAPIResponse,\n SendMessageAPIResponse,\n} from 'stream-chat'\nimport { StreamChat } from 'stream-chat'\n\nimport { createMockStreamChatClient } from './createMockStreamChatClient'\n\n/** A participant in the mock conversation. */\nexport interface MockMessagingUser {\n id: string\n name?: string\n image?: string\n}\n\n/**\n * A seed message, authored from the viewer's perspective. `from: 'me'` is the\n * connected user (the viewer); `from: 'them'` is the other participant.\n */\nexport interface MockMessage {\n id?: string\n text?: string\n from: 'me' | 'them'\n /** Stream attachment payloads, rendered by the toolkit's attachment gate. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments?: any[]\n /** e.g. `{ custom_type: 'MESSAGE_WELCOME' }`. */\n metadata?: Record<string, unknown>\n}\n\nexport interface CreateMockMessagingClientOptions {\n /** The connected/viewing user. */\n currentUser: MockMessagingUser\n /** The other party in the direct conversation (e.g. the creator/linker). */\n participant: MockMessagingUser\n /** Seed conversation, oldest-first. */\n messages?: MockMessage[]\n /** Channel id; defaults to `mock-dm`. */\n channelId?: string\n /**\n * Optional canned reply. Called after the viewer sends a message; return the\n * reply text (or a partial message) to have `participant` echo a response, or\n * a falsy value for no reply. Drives the \"send echo\" in `dev:mock-messaging`.\n */\n onSend?: (\n text: string\n ) => string | { text?: string; attachments?: unknown[] } | null | undefined\n}\n\nexport interface MockMessagingClient {\n /** Pass to `<MessagingProvider client={...}>`. */\n client: StreamChat\n /** The seeded direct-conversation channel. */\n channel: Channel\n /** Pass to `<MessagingShell initialParticipantFilter={...}>`. */\n participantFilterId: string\n}\n\nconst REPLY_DELAY_MS = 600\n\n/**\n * Builds an offline Stream client seeded with a single direct conversation, so\n * consumers can render the **real** messaging UI (MessagingProvider →\n * MessagingShell → ChannelView) without a Stream backend — for `dev:mock-*`\n * surfaces and Storybook. It is a real `StreamChat` whose network calls\n * (`connectUser`, `queryChannels`, `channel.watch`, `channel.sendMessage`) are\n * replaced with in-memory behaviour driven by Stream's own channel-state\n * machinery, so rendering fidelity matches production.\n *\n * Ships from `@linktr.ee/messaging-react/testing` and must never be imported by\n * production code.\n */\nexport function createMockMessagingClient({\n currentUser,\n participant,\n messages = [],\n channelId = 'mock-dm',\n onSend,\n}: CreateMockMessagingClientOptions): MockMessagingClient {\n // Offline client marked connected without a WebSocket. `stubConnection` also\n // no-ops connectUser/disconnectUser/userMuteStatus: `MessagingProvider`\n // renders `<Chat client={client}>` directly so it never calls connectUser,\n // and handling the `message.new` events dispatched on send + echo below runs\n // Channel._countMessageAsUnread → client.userMuteStatus, which throws offline.\n const client = createMockStreamChatClient(currentUser, {\n stubConnection: true,\n })\n\n const cid = `messaging:${channelId}`\n\n const toStreamMessage = (\n message: MockMessage,\n index: number\n ): MessageResponse => {\n const author = message.from === 'me' ? currentUser : participant\n // Stagger timestamps oldest-first so the list orders naturally. Must be a\n // Date, not an ISO string: we seed `channel.state.messages` directly\n // (bypassing Stream's wire→Date parsing), and stream-chat-react calls\n // `.getTime()` on `created_at` (e.g. useCooldownTimer / addToMessageList).\n const createdAt = new Date(Date.now() - (messages.length - index) * 60_000)\n return {\n id: message.id ?? `mock-msg-${index}`,\n text: message.text ?? '',\n type: 'regular',\n html: message.text ? `<p>${message.text}</p>` : '',\n user: author,\n attachments: message.attachments ?? [],\n latest_reactions: [],\n own_reactions: [],\n reaction_counts: {},\n reaction_scores: {},\n reply_count: 0,\n status: 'received',\n cid,\n created_at: createdAt,\n updated_at: createdAt,\n mentioned_users: [],\n ...(message.metadata ? { metadata: message.metadata } : {}),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any\n }\n\n const seededMessages = messages.map(toStreamMessage)\n\n const memberState = {\n [currentUser.id]: {\n user: { ...currentUser, is_account: false },\n user_id: currentUser.id,\n role: 'owner',\n is_account: false,\n },\n [participant.id]: {\n user: { ...participant, is_account: true },\n user_id: participant.id,\n role: 'member',\n is_account: true,\n },\n }\n\n const channel = client.channel('messaging', channelId, {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n members: [currentUser.id, participant.id] as any,\n })\n // Read/unread bookkeeping consults channel mute status; without a live\n // connection the real method throws, so report \"not muted\".\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.muteStatus = (() => ({ muted: false, createdAt: null, expiresAt: null })) as any\n\n // Neutralise the rest of the connection-dependent channel API that\n // stream-chat-react calls during the chat lifecycle. Each of these POSTs to\n // Stream or checks for a live WebSocket and would otherwise throw\n // \"Make sure to await connectUser() first.\" offline. They are fire-and-forget\n // (read receipts, typing) or read-only (pagination, unread) so no-ops are safe.\n /* eslint-disable @typescript-eslint/no-explicit-any */\n channel.markRead = (async () => ({}) as any) as any\n channel.keystroke = (async () => undefined) as any\n channel.stopTyping = (async () => undefined) as any\n channel.sendReaction = (async () => ({}) as any) as any\n channel.deleteReaction = (async () => ({}) as any) as any\n channel.countUnread = (() => 0) as any\n // Pagination: report no older pages so \"load more\" resolves to a no-op.\n channel.query = (async () => ({ messages: [] }) as any) as any\n /* eslint-enable @typescript-eslint/no-explicit-any */\n\n // Seed state locally instead of fetching. Mirrors the proven ChannelView\n // story mock: override `watch` to populate `state` and return a canned\n // QueryChannelAPIResponse. `stream-chat-react` may call `watch()` again on\n // remount/navigation, so seed only once — otherwise a re-watch would wipe\n // messages added via `sendMessage` + the send-echo reply, resetting the chat.\n let hasSeeded = false\n channel.watch = async () => {\n if (!hasSeeded) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.messages = seededMessages as unknown as any[]\n hasSeeded = true\n }\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.members = memberState as unknown as any\n return {\n channel: { members: [currentUser.id, participant.id] },\n members: [],\n messages: channel.state.messages,\n watchers: [],\n pinned_messages: [],\n duration: '0ms',\n } as unknown as QueryChannelAPIResponse\n }\n\n const appendMessage = (message: MessageResponse) => {\n // addMessageSorted is Stream's own state mutation (dedupes by id), so the\n // toolkit re-renders through the real channel-state path.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.addMessageSorted(message as any)\n client.dispatchEvent({\n type: 'message.new',\n cid,\n message,\n user: message.user ?? undefined,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any)\n }\n\n let sentCount = 0\n channel.sendMessage = async (message) => {\n const text =\n typeof message === 'string'\n ? message\n : ((message as { text?: string })?.text ?? '')\n const sent = {\n ...toStreamMessage({ text, from: 'me', id: `mock-sent-${sentCount++}` }, seededMessages.length),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ...(typeof message === 'object' ? (message as any) : {}),\n user: currentUser,\n cid,\n } as MessageResponse\n\n // stream-chat-react adds the outgoing message optimistically; addMessageSorted\n // dedupes by id, so confirming here is safe and idempotent.\n appendMessage(sent)\n\n const reply = onSend?.(text)\n if (reply) {\n const replyText = typeof reply === 'string' ? reply : reply.text\n const replyAttachments =\n typeof reply === 'string' ? undefined : reply.attachments\n setTimeout(() => {\n appendMessage(\n toStreamMessage(\n {\n text: replyText,\n from: 'them',\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments: replyAttachments as any,\n id: `mock-reply-${sentCount}`,\n },\n seededMessages.length\n )\n )\n }, REPLY_DELAY_MS)\n }\n\n return { message: sent } as unknown as SendMessageAPIResponse\n }\n\n // MessagingShell finds the direct conversation via queryChannels; always\n // return the one seeded channel regardless of the filter.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n client.queryChannels = async () => [channel] as any\n\n // Initialise state up-front so first render already has the conversation.\n void channel.watch()\n\n return { client, channel, participantFilterId: participant.id }\n}\n"],"names":["createMockStreamChatClient","user","stubConnection","client","StreamChat","REPLY_DELAY_MS","createMockMessagingClient","currentUser","participant","messages","channelId","onSend","cid","toStreamMessage","message","index","author","createdAt","seededMessages","memberState","channel","hasSeeded","appendMessage","sentCount","text","sent","reply","replyText","replyAttachments"],"mappings":"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"}
1
+ {"version":3,"file":"testing.cjs","sources":["../src/testing/createMockStreamChatClient.ts","../src/testing/createMockMessagingClient.ts"],"sourcesContent":["import { StreamChat } from 'stream-chat'\n\nimport type { MockMessagingUser } from './createMockMessagingClient'\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nexport interface CreateMockStreamChatClientOptions {\n /**\n * Also replace the connection-dependent client methods (`connectUser`,\n * `disconnectUser`, `userMuteStatus`) with offline no-ops. Needed when the\n * client drives a flow that would otherwise open a WebSocket or throw\n * \"Make sure to await connectUser() first.\" offline. Direct `<Chat client>`\n * rendering (e.g. component stories) does not need this.\n */\n stubConnection?: boolean\n}\n\n/**\n * Builds an offline `StreamChat` for mocks and stories: a real client on a\n * throwaway api key with `userID`/`user` set so `<Chat>` treats it as\n * connected without a backend. The single place that knows how to construct a\n * connectionless Stream client, so a stream-chat version bump only needs\n * updating here rather than in each mock surface.\n */\nexport function createMockStreamChatClient(\n user: MockMessagingUser,\n { stubConnection = false }: CreateMockStreamChatClientOptions = {}\n): StreamChat {\n const client = new StreamChat('mock-api-key', {\n allowServerSideConnect: true,\n })\n client.userID = user.id\n client.user = user as any\n\n if (stubConnection) {\n client.connectUser = async () => ({ me: user }) as any\n client.disconnectUser = async () => undefined\n client.userMuteStatus = (() => false) as any\n }\n\n return client\n}\n\n/* eslint-enable @typescript-eslint/no-explicit-any */\n","import type {\n Channel,\n MessageResponse,\n QueryChannelAPIResponse,\n SendMessageAPIResponse,\n} from 'stream-chat'\nimport { StreamChat } from 'stream-chat'\n\nimport { createMockStreamChatClient } from './createMockStreamChatClient'\n\n/** A participant in the mock conversation. */\nexport interface MockMessagingUser {\n id: string\n name?: string\n image?: string\n}\n\n/**\n * A seed message, authored from the viewer's perspective. `from: 'me'` is the\n * connected user (the viewer); `from: 'them'` is the other participant.\n */\nexport interface MockMessage {\n id?: string\n text?: string\n from: 'me' | 'them'\n /** Stream attachment payloads, rendered by the toolkit's attachment gate. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments?: any[]\n /** e.g. `{ custom_type: 'MESSAGE_WELCOME' }`. */\n metadata?: Record<string, unknown>\n}\n\nexport interface CreateMockMessagingClientOptions {\n /** The connected/viewing user. */\n currentUser: MockMessagingUser\n /** The other party in the direct conversation (e.g. the creator/linker). */\n participant: MockMessagingUser\n /** Seed conversation, oldest-first. */\n messages?: MockMessage[]\n /** Channel id; defaults to `mock-dm`. */\n channelId?: string\n /**\n * Optional canned reply. Called after the viewer sends a message; return the\n * reply text (or a partial message) to have `participant` echo a response, or\n * a falsy value for no reply. Drives the \"send echo\" in `dev:mock-messaging`.\n */\n onSend?: (\n text: string\n ) => string | { text?: string; attachments?: unknown[] } | null | undefined\n}\n\nexport interface MockMessagingClient {\n /** Pass to `<MessagingProvider client={...}>`. */\n client: StreamChat\n /** The seeded direct-conversation channel. */\n channel: Channel\n /** Pass to `<MessagingShell initialParticipantFilter={...}>`. */\n participantFilterId: string\n}\n\nconst REPLY_DELAY_MS = 600\n\n/**\n * Builds an offline Stream client seeded with a single direct conversation, so\n * consumers can render the **real** messaging UI (MessagingProvider →\n * MessagingShell → ChannelView) without a Stream backend — for `dev:mock-*`\n * surfaces and Storybook. It is a real `StreamChat` whose network calls\n * (`connectUser`, `queryChannels`, `channel.watch`, `channel.sendMessage`) are\n * replaced with in-memory behaviour driven by Stream's own channel-state\n * machinery, so rendering fidelity matches production.\n *\n * Ships from `@linktr.ee/messaging-react/testing` and must never be imported by\n * production code.\n */\nexport function createMockMessagingClient({\n currentUser,\n participant,\n messages = [],\n channelId = 'mock-dm',\n onSend,\n}: CreateMockMessagingClientOptions): MockMessagingClient {\n // Offline client marked connected without a WebSocket. `stubConnection` also\n // no-ops connectUser/disconnectUser/userMuteStatus: `MessagingProvider`\n // renders `<Chat client={client}>` directly so it never calls connectUser,\n // and handling the `message.new` events dispatched on send + echo below runs\n // Channel._countMessageAsUnread → client.userMuteStatus, which throws offline.\n const client = createMockStreamChatClient(currentUser, {\n stubConnection: true,\n })\n\n const cid = `messaging:${channelId}`\n\n const toStreamMessage = (\n message: MockMessage,\n index: number\n ): MessageResponse => {\n const author = message.from === 'me' ? currentUser : participant\n // Stagger timestamps oldest-first so the list orders naturally. Must be a\n // Date, not an ISO string: we seed `channel.state.messages` directly\n // (bypassing Stream's wire→Date parsing), and stream-chat-react calls\n // `.getTime()` on `created_at` (e.g. useCooldownTimer / addToMessageList).\n const createdAt = new Date(Date.now() - (messages.length - index) * 60_000)\n return {\n id: message.id ?? `mock-msg-${index}`,\n text: message.text ?? '',\n type: 'regular',\n html: message.text ? `<p>${message.text}</p>` : '',\n user: author,\n attachments: message.attachments ?? [],\n latest_reactions: [],\n own_reactions: [],\n reaction_counts: {},\n reaction_scores: {},\n reply_count: 0,\n status: 'received',\n cid,\n created_at: createdAt,\n updated_at: createdAt,\n mentioned_users: [],\n ...(message.metadata ? { metadata: message.metadata } : {}),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any\n }\n\n const seededMessages = messages.map(toStreamMessage)\n\n const memberState = {\n [currentUser.id]: {\n user: { ...currentUser, is_account: false },\n user_id: currentUser.id,\n role: 'owner',\n is_account: false,\n },\n [participant.id]: {\n user: { ...participant, is_account: true },\n user_id: participant.id,\n role: 'member',\n is_account: true,\n },\n }\n\n const channel = client.channel('messaging', channelId, {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n members: [currentUser.id, participant.id] as any,\n })\n // Read/unread bookkeeping consults channel mute status; without a live\n // connection the real method throws, so report \"not muted\".\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.muteStatus = (() => ({\n muted: false,\n createdAt: null,\n expiresAt: null,\n })) as any\n\n // Neutralise the rest of the connection-dependent channel API that\n // stream-chat-react calls during the chat lifecycle. Each of these POSTs to\n // Stream or checks for a live WebSocket and would otherwise throw\n // \"Make sure to await connectUser() first.\" offline. They are fire-and-forget\n // (read receipts, typing) or read-only (pagination, unread) so no-ops are safe.\n /* eslint-disable @typescript-eslint/no-explicit-any */\n channel.markRead = (async () => ({}) as any) as any\n channel.keystroke = (async () => undefined) as any\n channel.stopTyping = (async () => undefined) as any\n channel.sendReaction = (async () => ({}) as any) as any\n channel.deleteReaction = (async () => ({}) as any) as any\n channel.countUnread = (() => 0) as any\n // Pagination: report no older pages so \"load more\" resolves to a no-op.\n channel.query = (async () => ({ messages: [] }) as any) as any\n // Leave/Delete Conversation calls channel.hide(); the real method POSTs to\n // Stream, so no-op it to keep the action working offline.\n channel.hide = (async () => ({}) as any) as any\n /* eslint-enable @typescript-eslint/no-explicit-any */\n\n // Seed state locally instead of fetching. Mirrors the proven ChannelView\n // story mock: override `watch` to populate `state` and return a canned\n // QueryChannelAPIResponse. `stream-chat-react` may call `watch()` again on\n // remount/navigation, so seed only once — otherwise a re-watch would wipe\n // messages added via `sendMessage` + the send-echo reply, resetting the chat.\n let hasSeeded = false\n channel.watch = async () => {\n if (!hasSeeded) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.messages = seededMessages as unknown as any[]\n hasSeeded = true\n }\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.members = memberState as unknown as any\n return {\n channel: { members: [currentUser.id, participant.id] },\n members: [],\n messages: channel.state.messages,\n watchers: [],\n pinned_messages: [],\n duration: '0ms',\n } as unknown as QueryChannelAPIResponse\n }\n\n const appendMessage = (message: MessageResponse) => {\n // addMessageSorted is Stream's own state mutation (dedupes by id), so the\n // toolkit re-renders through the real channel-state path.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.addMessageSorted(message as any)\n client.dispatchEvent({\n type: 'message.new',\n cid,\n message,\n user: message.user ?? undefined,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any)\n }\n\n let sentCount = 0\n let replyCount = 0\n channel.sendMessage = async (message) => {\n const text =\n typeof message === 'string'\n ? message\n : ((message as { text?: string })?.text ?? '')\n const sent = {\n ...toStreamMessage(\n { text, from: 'me', id: `mock-sent-${sentCount++}` },\n seededMessages.length\n ),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ...(typeof message === 'object' ? (message as any) : {}),\n user: currentUser,\n cid,\n } as MessageResponse\n\n // stream-chat-react adds the outgoing message optimistically; addMessageSorted\n // dedupes by id, so confirming here is safe and idempotent.\n appendMessage(sent)\n\n const reply = onSend?.(text)\n if (reply) {\n const replyText = typeof reply === 'string' ? reply : reply.text\n const replyAttachments =\n typeof reply === 'string' ? undefined : reply.attachments\n // Capture a unique id before scheduling: two sends within REPLY_DELAY_MS\n // would otherwise both read the same later `sentCount` at fire time and\n // emit colliding reply ids, so Stream's id-based dedupe drops one echo.\n const replyId = `mock-reply-${replyCount++}`\n setTimeout(() => {\n appendMessage(\n toStreamMessage(\n {\n text: replyText,\n from: 'them',\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments: replyAttachments as any,\n id: replyId,\n },\n seededMessages.length\n )\n )\n }, REPLY_DELAY_MS)\n }\n\n return { message: sent } as unknown as SendMessageAPIResponse\n }\n\n // MessagingShell finds the direct conversation via queryChannels; always\n // return the one seeded channel regardless of the filter.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n client.queryChannels = async () => [channel] as any\n\n // Initialise state up-front so first render already has the conversation.\n void channel.watch()\n\n return { client, channel, participantFilterId: participant.id }\n}\n"],"names":["createMockStreamChatClient","user","stubConnection","client","StreamChat","REPLY_DELAY_MS","createMockMessagingClient","currentUser","participant","messages","channelId","onSend","cid","toStreamMessage","message","index","author","createdAt","seededMessages","memberState","channel","hasSeeded","appendMessage","sentCount","replyCount","text","sent","reply","replyText","replyAttachments","replyId"],"mappings":"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"}
package/dist/testing.js CHANGED
@@ -1,22 +1,22 @@
1
- import { StreamChat as x } from "stream-chat";
2
- function M(s, { stubConnection: n = !1 } = {}) {
3
- const a = new x("mock-api-key", {
1
+ import { StreamChat as g } from "stream-chat";
2
+ function v(s, { stubConnection: n = !1 } = {}) {
3
+ const a = new g("mock-api-key", {
4
4
  allowServerSideConnect: !0
5
5
  });
6
6
  return a.userID = s.id, a.user = s, n && (a.connectUser = async () => ({ me: s }), a.disconnectUser = async () => {
7
7
  }, a.userMuteStatus = (() => !1)), a;
8
8
  }
9
- const g = 600;
10
- function b({
9
+ const C = 600;
10
+ function S({
11
11
  currentUser: s,
12
12
  participant: n,
13
13
  messages: a = [],
14
14
  channelId: y = "mock-dm",
15
15
  onSend: d
16
16
  }) {
17
- const r = M(s, {
17
+ const r = v(s, {
18
18
  stubConnection: !0
19
- }), u = `messaging:${y}`, m = (t, c) => {
19
+ }), u = `messaging:${y}`, l = (t, c) => {
20
20
  const i = t.from === "me" ? s : n, o = new Date(Date.now() - (a.length - c) * 6e4);
21
21
  return {
22
22
  id: t.id ?? `mock-msg-${c}`,
@@ -38,7 +38,7 @@ function b({
38
38
  ...t.metadata ? { metadata: t.metadata } : {}
39
39
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
40
  };
41
- }, l = a.map(m), p = {
41
+ }, m = a.map(l), p = {
42
42
  [s.id]: {
43
43
  user: { ...s, is_account: !1 },
44
44
  user_id: s.id,
@@ -55,11 +55,15 @@ function b({
55
55
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
56
  members: [s.id, n.id]
57
57
  });
58
- e.muteStatus = (() => ({ muted: !1, createdAt: null, expiresAt: null })), e.markRead = (async () => ({})), e.keystroke = (async () => {
58
+ e.muteStatus = (() => ({
59
+ muted: !1,
60
+ createdAt: null,
61
+ expiresAt: null
62
+ })), e.markRead = (async () => ({})), e.keystroke = (async () => {
59
63
  }), e.stopTyping = (async () => {
60
- }), e.sendReaction = (async () => ({})), e.deleteReaction = (async () => ({})), e.countUnread = (() => 0), e.query = (async () => ({ messages: [] }));
64
+ }), e.sendReaction = (async () => ({})), e.deleteReaction = (async () => ({})), e.countUnread = (() => 0), e.query = (async () => ({ messages: [] })), e.hide = (async () => ({}));
61
65
  let h = !1;
62
- e.watch = async () => (h || (e.state.messages = l, h = !0), e.state.members = p, {
66
+ e.watch = async () => (h || (e.state.messages = m, h = !0), e.state.members = p, {
63
67
  channel: { members: [s.id, n.id] },
64
68
  members: [],
65
69
  messages: e.state.messages,
@@ -76,10 +80,13 @@ function b({
76
80
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
81
  });
78
82
  };
79
- let _ = 0;
83
+ let _ = 0, k = 0;
80
84
  return e.sendMessage = async (t) => {
81
85
  const c = typeof t == "string" ? t : (t == null ? void 0 : t.text) ?? "", i = {
82
- ...m({ text: c, from: "me", id: `mock-sent-${_++}` }, l.length),
86
+ ...l(
87
+ { text: c, from: "me", id: `mock-sent-${_++}` },
88
+ m.length
89
+ ),
83
90
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
91
  ...typeof t == "object" ? t : {},
85
92
  user: s,
@@ -88,26 +95,26 @@ function b({
88
95
  f(i);
89
96
  const o = d == null ? void 0 : d(c);
90
97
  if (o) {
91
- const k = typeof o == "string" ? o : o.text, w = typeof o == "string" ? void 0 : o.attachments;
98
+ const w = typeof o == "string" ? o : o.text, x = typeof o == "string" ? void 0 : o.attachments, M = `mock-reply-${k++}`;
92
99
  setTimeout(() => {
93
100
  f(
94
- m(
101
+ l(
95
102
  {
96
- text: k,
103
+ text: w,
97
104
  from: "them",
98
105
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
- attachments: w,
100
- id: `mock-reply-${_}`
106
+ attachments: x,
107
+ id: M
101
108
  },
102
- l.length
109
+ m.length
103
110
  )
104
111
  );
105
- }, g);
112
+ }, C);
106
113
  }
107
114
  return { message: i };
108
115
  }, r.queryChannels = async () => [e], e.watch(), { client: r, channel: e, participantFilterId: n.id };
109
116
  }
110
117
  export {
111
- b as createMockMessagingClient
118
+ S as createMockMessagingClient
112
119
  };
113
120
  //# sourceMappingURL=testing.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"testing.js","sources":["../src/testing/createMockStreamChatClient.ts","../src/testing/createMockMessagingClient.ts"],"sourcesContent":["import { StreamChat } from 'stream-chat'\n\nimport type { MockMessagingUser } from './createMockMessagingClient'\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nexport interface CreateMockStreamChatClientOptions {\n /**\n * Also replace the connection-dependent client methods (`connectUser`,\n * `disconnectUser`, `userMuteStatus`) with offline no-ops. Needed when the\n * client drives a flow that would otherwise open a WebSocket or throw\n * \"Make sure to await connectUser() first.\" offline. Direct `<Chat client>`\n * rendering (e.g. component stories) does not need this.\n */\n stubConnection?: boolean\n}\n\n/**\n * Builds an offline `StreamChat` for mocks and stories: a real client on a\n * throwaway api key with `userID`/`user` set so `<Chat>` treats it as\n * connected without a backend. The single place that knows how to construct a\n * connectionless Stream client, so a stream-chat version bump only needs\n * updating here rather than in each mock surface.\n */\nexport function createMockStreamChatClient(\n user: MockMessagingUser,\n { stubConnection = false }: CreateMockStreamChatClientOptions = {}\n): StreamChat {\n const client = new StreamChat('mock-api-key', {\n allowServerSideConnect: true,\n })\n client.userID = user.id\n client.user = user as any\n\n if (stubConnection) {\n client.connectUser = async () => ({ me: user }) as any\n client.disconnectUser = async () => undefined\n client.userMuteStatus = (() => false) as any\n }\n\n return client\n}\n\n/* eslint-enable @typescript-eslint/no-explicit-any */\n","import type {\n Channel,\n MessageResponse,\n QueryChannelAPIResponse,\n SendMessageAPIResponse,\n} from 'stream-chat'\nimport { StreamChat } from 'stream-chat'\n\nimport { createMockStreamChatClient } from './createMockStreamChatClient'\n\n/** A participant in the mock conversation. */\nexport interface MockMessagingUser {\n id: string\n name?: string\n image?: string\n}\n\n/**\n * A seed message, authored from the viewer's perspective. `from: 'me'` is the\n * connected user (the viewer); `from: 'them'` is the other participant.\n */\nexport interface MockMessage {\n id?: string\n text?: string\n from: 'me' | 'them'\n /** Stream attachment payloads, rendered by the toolkit's attachment gate. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments?: any[]\n /** e.g. `{ custom_type: 'MESSAGE_WELCOME' }`. */\n metadata?: Record<string, unknown>\n}\n\nexport interface CreateMockMessagingClientOptions {\n /** The connected/viewing user. */\n currentUser: MockMessagingUser\n /** The other party in the direct conversation (e.g. the creator/linker). */\n participant: MockMessagingUser\n /** Seed conversation, oldest-first. */\n messages?: MockMessage[]\n /** Channel id; defaults to `mock-dm`. */\n channelId?: string\n /**\n * Optional canned reply. Called after the viewer sends a message; return the\n * reply text (or a partial message) to have `participant` echo a response, or\n * a falsy value for no reply. Drives the \"send echo\" in `dev:mock-messaging`.\n */\n onSend?: (\n text: string\n ) => string | { text?: string; attachments?: unknown[] } | null | undefined\n}\n\nexport interface MockMessagingClient {\n /** Pass to `<MessagingProvider client={...}>`. */\n client: StreamChat\n /** The seeded direct-conversation channel. */\n channel: Channel\n /** Pass to `<MessagingShell initialParticipantFilter={...}>`. */\n participantFilterId: string\n}\n\nconst REPLY_DELAY_MS = 600\n\n/**\n * Builds an offline Stream client seeded with a single direct conversation, so\n * consumers can render the **real** messaging UI (MessagingProvider →\n * MessagingShell → ChannelView) without a Stream backend — for `dev:mock-*`\n * surfaces and Storybook. It is a real `StreamChat` whose network calls\n * (`connectUser`, `queryChannels`, `channel.watch`, `channel.sendMessage`) are\n * replaced with in-memory behaviour driven by Stream's own channel-state\n * machinery, so rendering fidelity matches production.\n *\n * Ships from `@linktr.ee/messaging-react/testing` and must never be imported by\n * production code.\n */\nexport function createMockMessagingClient({\n currentUser,\n participant,\n messages = [],\n channelId = 'mock-dm',\n onSend,\n}: CreateMockMessagingClientOptions): MockMessagingClient {\n // Offline client marked connected without a WebSocket. `stubConnection` also\n // no-ops connectUser/disconnectUser/userMuteStatus: `MessagingProvider`\n // renders `<Chat client={client}>` directly so it never calls connectUser,\n // and handling the `message.new` events dispatched on send + echo below runs\n // Channel._countMessageAsUnread → client.userMuteStatus, which throws offline.\n const client = createMockStreamChatClient(currentUser, {\n stubConnection: true,\n })\n\n const cid = `messaging:${channelId}`\n\n const toStreamMessage = (\n message: MockMessage,\n index: number\n ): MessageResponse => {\n const author = message.from === 'me' ? currentUser : participant\n // Stagger timestamps oldest-first so the list orders naturally. Must be a\n // Date, not an ISO string: we seed `channel.state.messages` directly\n // (bypassing Stream's wire→Date parsing), and stream-chat-react calls\n // `.getTime()` on `created_at` (e.g. useCooldownTimer / addToMessageList).\n const createdAt = new Date(Date.now() - (messages.length - index) * 60_000)\n return {\n id: message.id ?? `mock-msg-${index}`,\n text: message.text ?? '',\n type: 'regular',\n html: message.text ? `<p>${message.text}</p>` : '',\n user: author,\n attachments: message.attachments ?? [],\n latest_reactions: [],\n own_reactions: [],\n reaction_counts: {},\n reaction_scores: {},\n reply_count: 0,\n status: 'received',\n cid,\n created_at: createdAt,\n updated_at: createdAt,\n mentioned_users: [],\n ...(message.metadata ? { metadata: message.metadata } : {}),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any\n }\n\n const seededMessages = messages.map(toStreamMessage)\n\n const memberState = {\n [currentUser.id]: {\n user: { ...currentUser, is_account: false },\n user_id: currentUser.id,\n role: 'owner',\n is_account: false,\n },\n [participant.id]: {\n user: { ...participant, is_account: true },\n user_id: participant.id,\n role: 'member',\n is_account: true,\n },\n }\n\n const channel = client.channel('messaging', channelId, {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n members: [currentUser.id, participant.id] as any,\n })\n // Read/unread bookkeeping consults channel mute status; without a live\n // connection the real method throws, so report \"not muted\".\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.muteStatus = (() => ({ muted: false, createdAt: null, expiresAt: null })) as any\n\n // Neutralise the rest of the connection-dependent channel API that\n // stream-chat-react calls during the chat lifecycle. Each of these POSTs to\n // Stream or checks for a live WebSocket and would otherwise throw\n // \"Make sure to await connectUser() first.\" offline. They are fire-and-forget\n // (read receipts, typing) or read-only (pagination, unread) so no-ops are safe.\n /* eslint-disable @typescript-eslint/no-explicit-any */\n channel.markRead = (async () => ({}) as any) as any\n channel.keystroke = (async () => undefined) as any\n channel.stopTyping = (async () => undefined) as any\n channel.sendReaction = (async () => ({}) as any) as any\n channel.deleteReaction = (async () => ({}) as any) as any\n channel.countUnread = (() => 0) as any\n // Pagination: report no older pages so \"load more\" resolves to a no-op.\n channel.query = (async () => ({ messages: [] }) as any) as any\n /* eslint-enable @typescript-eslint/no-explicit-any */\n\n // Seed state locally instead of fetching. Mirrors the proven ChannelView\n // story mock: override `watch` to populate `state` and return a canned\n // QueryChannelAPIResponse. `stream-chat-react` may call `watch()` again on\n // remount/navigation, so seed only once — otherwise a re-watch would wipe\n // messages added via `sendMessage` + the send-echo reply, resetting the chat.\n let hasSeeded = false\n channel.watch = async () => {\n if (!hasSeeded) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.messages = seededMessages as unknown as any[]\n hasSeeded = true\n }\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.members = memberState as unknown as any\n return {\n channel: { members: [currentUser.id, participant.id] },\n members: [],\n messages: channel.state.messages,\n watchers: [],\n pinned_messages: [],\n duration: '0ms',\n } as unknown as QueryChannelAPIResponse\n }\n\n const appendMessage = (message: MessageResponse) => {\n // addMessageSorted is Stream's own state mutation (dedupes by id), so the\n // toolkit re-renders through the real channel-state path.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.addMessageSorted(message as any)\n client.dispatchEvent({\n type: 'message.new',\n cid,\n message,\n user: message.user ?? undefined,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any)\n }\n\n let sentCount = 0\n channel.sendMessage = async (message) => {\n const text =\n typeof message === 'string'\n ? message\n : ((message as { text?: string })?.text ?? '')\n const sent = {\n ...toStreamMessage({ text, from: 'me', id: `mock-sent-${sentCount++}` }, seededMessages.length),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ...(typeof message === 'object' ? (message as any) : {}),\n user: currentUser,\n cid,\n } as MessageResponse\n\n // stream-chat-react adds the outgoing message optimistically; addMessageSorted\n // dedupes by id, so confirming here is safe and idempotent.\n appendMessage(sent)\n\n const reply = onSend?.(text)\n if (reply) {\n const replyText = typeof reply === 'string' ? reply : reply.text\n const replyAttachments =\n typeof reply === 'string' ? undefined : reply.attachments\n setTimeout(() => {\n appendMessage(\n toStreamMessage(\n {\n text: replyText,\n from: 'them',\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments: replyAttachments as any,\n id: `mock-reply-${sentCount}`,\n },\n seededMessages.length\n )\n )\n }, REPLY_DELAY_MS)\n }\n\n return { message: sent } as unknown as SendMessageAPIResponse\n }\n\n // MessagingShell finds the direct conversation via queryChannels; always\n // return the one seeded channel regardless of the filter.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n client.queryChannels = async () => [channel] as any\n\n // Initialise state up-front so first render already has the conversation.\n void channel.watch()\n\n return { client, channel, participantFilterId: participant.id }\n}\n"],"names":["createMockStreamChatClient","user","stubConnection","client","StreamChat","REPLY_DELAY_MS","createMockMessagingClient","currentUser","participant","messages","channelId","onSend","cid","toStreamMessage","message","index","author","createdAt","seededMessages","memberState","channel","hasSeeded","appendMessage","sentCount","text","sent","reply","replyText","replyAttachments"],"mappings":";AAwBO,SAASA,EACdC,GACA,EAAE,gBAAAC,IAAiB,GAAA,IAA6C,CAAA,GACpD;AACZ,QAAMC,IAAS,IAAIC,EAAW,gBAAgB;AAAA,IAC5C,wBAAwB;AAAA,EAAA,CACzB;AACD,SAAAD,EAAO,SAASF,EAAK,IACrBE,EAAO,OAAOF,GAEVC,MACFC,EAAO,cAAc,aAAa,EAAE,IAAIF,EAAA,IACxCE,EAAO,iBAAiB;KACxBA,EAAO,kBAAkB,MAAM,MAG1BA;AACT;ACmBA,MAAME,IAAiB;AAchB,SAASC,EAA0B;AAAA,EACxC,aAAAC;AAAA,EACA,aAAAC;AAAA,EACA,UAAAC,IAAW,CAAA;AAAA,EACX,WAAAC,IAAY;AAAA,EACZ,QAAAC;AACF,GAA0D;AAMxD,QAAMR,IAASH,EAA2BO,GAAa;AAAA,IACrD,gBAAgB;AAAA,EAAA,CACjB,GAEKK,IAAM,aAAaF,CAAS,IAE5BG,IAAkB,CACtBC,GACAC,MACoB;AACpB,UAAMC,IAASF,EAAQ,SAAS,OAAOP,IAAcC,GAK/CS,IAAY,IAAI,KAAK,KAAK,SAASR,EAAS,SAASM,KAAS,GAAM;AAC1E,WAAO;AAAA,MACL,IAAID,EAAQ,MAAM,YAAYC,CAAK;AAAA,MACnC,MAAMD,EAAQ,QAAQ;AAAA,MACtB,MAAM;AAAA,MACN,MAAMA,EAAQ,OAAO,MAAMA,EAAQ,IAAI,SAAS;AAAA,MAChD,MAAME;AAAA,MACN,aAAaF,EAAQ,eAAe,CAAA;AAAA,MACpC,kBAAkB,CAAA;AAAA,MAClB,eAAe,CAAA;AAAA,MACf,iBAAiB,CAAA;AAAA,MACjB,iBAAiB,CAAA;AAAA,MACjB,aAAa;AAAA,MACb,QAAQ;AAAA,MACR,KAAAF;AAAA,MACA,YAAYK;AAAA,MACZ,YAAYA;AAAA,MACZ,iBAAiB,CAAA;AAAA,MACjB,GAAIH,EAAQ,WAAW,EAAE,UAAUA,EAAQ,SAAA,IAAa,CAAA;AAAA;AAAA,IAAC;AAAA,EAG7D,GAEMI,IAAiBT,EAAS,IAAII,CAAe,GAE7CM,IAAc;AAAA,IAClB,CAACZ,EAAY,EAAE,GAAG;AAAA,MAChB,MAAM,EAAE,GAAGA,GAAa,YAAY,GAAA;AAAA,MACpC,SAASA,EAAY;AAAA,MACrB,MAAM;AAAA,MACN,YAAY;AAAA,IAAA;AAAA,IAEd,CAACC,EAAY,EAAE,GAAG;AAAA,MAChB,MAAM,EAAE,GAAGA,GAAa,YAAY,GAAA;AAAA,MACpC,SAASA,EAAY;AAAA,MACrB,MAAM;AAAA,MACN,YAAY;AAAA,IAAA;AAAA,EACd,GAGIY,IAAUjB,EAAO,QAAQ,aAAaO,GAAW;AAAA;AAAA,IAErD,SAAS,CAACH,EAAY,IAAIC,EAAY,EAAE;AAAA,EAAA,CACzC;AAID,EAAAY,EAAQ,cAAc,OAAO,EAAE,OAAO,IAAO,WAAW,MAAM,WAAW,KAAA,KAQzEA,EAAQ,YAAY,aAAa,CAAA,KACjCA,EAAQ,aAAa,YAAA;AAAA,MACrBA,EAAQ,cAAc,YAAA;AAAA,MACtBA,EAAQ,gBAAgB,aAAa,CAAA,KACrCA,EAAQ,kBAAkB,aAAa,CAAA,KACvCA,EAAQ,eAAe,MAAM,IAE7BA,EAAQ,SAAS,aAAa,EAAE,UAAU,CAAA,EAAC;AAQ3C,MAAIC,IAAY;AAChB,EAAAD,EAAQ,QAAQ,aACTC,MAEHD,EAAQ,MAAM,WAAWF,GACzBG,IAAY,KAGdD,EAAQ,MAAM,UAAUD,GACjB;AAAA,IACL,SAAS,EAAE,SAAS,CAACZ,EAAY,IAAIC,EAAY,EAAE,EAAA;AAAA,IACnD,SAAS,CAAA;AAAA,IACT,UAAUY,EAAQ,MAAM;AAAA,IACxB,UAAU,CAAA;AAAA,IACV,iBAAiB,CAAA;AAAA,IACjB,UAAU;AAAA,EAAA;AAId,QAAME,IAAgB,CAACR,MAA6B;AAIlD,IAAAM,EAAQ,MAAM,iBAAiBN,CAAc,GAC7CX,EAAO,cAAc;AAAA,MACnB,MAAM;AAAA,MACN,KAAAS;AAAA,MACA,SAAAE;AAAA,MACA,MAAMA,EAAQ,QAAQ;AAAA;AAAA,IAAA,CAEhB;AAAA,EACV;AAEA,MAAIS,IAAY;AAChB,SAAAH,EAAQ,cAAc,OAAON,MAAY;AACvC,UAAMU,IACJ,OAAOV,KAAY,WACfA,KACEA,KAAA,gBAAAA,EAA+B,SAAQ,IACzCW,IAAO;AAAA,MACX,GAAGZ,EAAgB,EAAE,MAAAW,GAAM,MAAM,MAAM,IAAI,aAAaD,GAAW,MAAML,EAAe,MAAM;AAAA;AAAA,MAE9F,GAAI,OAAOJ,KAAY,WAAYA,IAAkB,CAAA;AAAA,MACrD,MAAMP;AAAA,MACN,KAAAK;AAAA,IAAA;AAKF,IAAAU,EAAcG,CAAI;AAElB,UAAMC,IAAQf,KAAA,gBAAAA,EAASa;AACvB,QAAIE,GAAO;AACT,YAAMC,IAAY,OAAOD,KAAU,WAAWA,IAAQA,EAAM,MACtDE,IACJ,OAAOF,KAAU,WAAW,SAAYA,EAAM;AAChD,iBAAW,MAAM;AACf,QAAAJ;AAAA,UACET;AAAA,YACE;AAAA,cACE,MAAMc;AAAA,cACN,MAAM;AAAA;AAAA,cAEN,aAAaC;AAAA,cACb,IAAI,cAAcL,CAAS;AAAA,YAAA;AAAA,YAE7BL,EAAe;AAAA,UAAA;AAAA,QACjB;AAAA,MAEJ,GAAGb,CAAc;AAAA,IACnB;AAEA,WAAO,EAAE,SAASoB,EAAA;AAAA,EACpB,GAKAtB,EAAO,gBAAgB,YAAY,CAACiB,CAAO,GAGtCA,EAAQ,MAAA,GAEN,EAAE,QAAAjB,GAAQ,SAAAiB,GAAS,qBAAqBZ,EAAY,GAAA;AAC7D;"}
1
+ {"version":3,"file":"testing.js","sources":["../src/testing/createMockStreamChatClient.ts","../src/testing/createMockMessagingClient.ts"],"sourcesContent":["import { StreamChat } from 'stream-chat'\n\nimport type { MockMessagingUser } from './createMockMessagingClient'\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nexport interface CreateMockStreamChatClientOptions {\n /**\n * Also replace the connection-dependent client methods (`connectUser`,\n * `disconnectUser`, `userMuteStatus`) with offline no-ops. Needed when the\n * client drives a flow that would otherwise open a WebSocket or throw\n * \"Make sure to await connectUser() first.\" offline. Direct `<Chat client>`\n * rendering (e.g. component stories) does not need this.\n */\n stubConnection?: boolean\n}\n\n/**\n * Builds an offline `StreamChat` for mocks and stories: a real client on a\n * throwaway api key with `userID`/`user` set so `<Chat>` treats it as\n * connected without a backend. The single place that knows how to construct a\n * connectionless Stream client, so a stream-chat version bump only needs\n * updating here rather than in each mock surface.\n */\nexport function createMockStreamChatClient(\n user: MockMessagingUser,\n { stubConnection = false }: CreateMockStreamChatClientOptions = {}\n): StreamChat {\n const client = new StreamChat('mock-api-key', {\n allowServerSideConnect: true,\n })\n client.userID = user.id\n client.user = user as any\n\n if (stubConnection) {\n client.connectUser = async () => ({ me: user }) as any\n client.disconnectUser = async () => undefined\n client.userMuteStatus = (() => false) as any\n }\n\n return client\n}\n\n/* eslint-enable @typescript-eslint/no-explicit-any */\n","import type {\n Channel,\n MessageResponse,\n QueryChannelAPIResponse,\n SendMessageAPIResponse,\n} from 'stream-chat'\nimport { StreamChat } from 'stream-chat'\n\nimport { createMockStreamChatClient } from './createMockStreamChatClient'\n\n/** A participant in the mock conversation. */\nexport interface MockMessagingUser {\n id: string\n name?: string\n image?: string\n}\n\n/**\n * A seed message, authored from the viewer's perspective. `from: 'me'` is the\n * connected user (the viewer); `from: 'them'` is the other participant.\n */\nexport interface MockMessage {\n id?: string\n text?: string\n from: 'me' | 'them'\n /** Stream attachment payloads, rendered by the toolkit's attachment gate. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments?: any[]\n /** e.g. `{ custom_type: 'MESSAGE_WELCOME' }`. */\n metadata?: Record<string, unknown>\n}\n\nexport interface CreateMockMessagingClientOptions {\n /** The connected/viewing user. */\n currentUser: MockMessagingUser\n /** The other party in the direct conversation (e.g. the creator/linker). */\n participant: MockMessagingUser\n /** Seed conversation, oldest-first. */\n messages?: MockMessage[]\n /** Channel id; defaults to `mock-dm`. */\n channelId?: string\n /**\n * Optional canned reply. Called after the viewer sends a message; return the\n * reply text (or a partial message) to have `participant` echo a response, or\n * a falsy value for no reply. Drives the \"send echo\" in `dev:mock-messaging`.\n */\n onSend?: (\n text: string\n ) => string | { text?: string; attachments?: unknown[] } | null | undefined\n}\n\nexport interface MockMessagingClient {\n /** Pass to `<MessagingProvider client={...}>`. */\n client: StreamChat\n /** The seeded direct-conversation channel. */\n channel: Channel\n /** Pass to `<MessagingShell initialParticipantFilter={...}>`. */\n participantFilterId: string\n}\n\nconst REPLY_DELAY_MS = 600\n\n/**\n * Builds an offline Stream client seeded with a single direct conversation, so\n * consumers can render the **real** messaging UI (MessagingProvider →\n * MessagingShell → ChannelView) without a Stream backend — for `dev:mock-*`\n * surfaces and Storybook. It is a real `StreamChat` whose network calls\n * (`connectUser`, `queryChannels`, `channel.watch`, `channel.sendMessage`) are\n * replaced with in-memory behaviour driven by Stream's own channel-state\n * machinery, so rendering fidelity matches production.\n *\n * Ships from `@linktr.ee/messaging-react/testing` and must never be imported by\n * production code.\n */\nexport function createMockMessagingClient({\n currentUser,\n participant,\n messages = [],\n channelId = 'mock-dm',\n onSend,\n}: CreateMockMessagingClientOptions): MockMessagingClient {\n // Offline client marked connected without a WebSocket. `stubConnection` also\n // no-ops connectUser/disconnectUser/userMuteStatus: `MessagingProvider`\n // renders `<Chat client={client}>` directly so it never calls connectUser,\n // and handling the `message.new` events dispatched on send + echo below runs\n // Channel._countMessageAsUnread → client.userMuteStatus, which throws offline.\n const client = createMockStreamChatClient(currentUser, {\n stubConnection: true,\n })\n\n const cid = `messaging:${channelId}`\n\n const toStreamMessage = (\n message: MockMessage,\n index: number\n ): MessageResponse => {\n const author = message.from === 'me' ? currentUser : participant\n // Stagger timestamps oldest-first so the list orders naturally. Must be a\n // Date, not an ISO string: we seed `channel.state.messages` directly\n // (bypassing Stream's wire→Date parsing), and stream-chat-react calls\n // `.getTime()` on `created_at` (e.g. useCooldownTimer / addToMessageList).\n const createdAt = new Date(Date.now() - (messages.length - index) * 60_000)\n return {\n id: message.id ?? `mock-msg-${index}`,\n text: message.text ?? '',\n type: 'regular',\n html: message.text ? `<p>${message.text}</p>` : '',\n user: author,\n attachments: message.attachments ?? [],\n latest_reactions: [],\n own_reactions: [],\n reaction_counts: {},\n reaction_scores: {},\n reply_count: 0,\n status: 'received',\n cid,\n created_at: createdAt,\n updated_at: createdAt,\n mentioned_users: [],\n ...(message.metadata ? { metadata: message.metadata } : {}),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any\n }\n\n const seededMessages = messages.map(toStreamMessage)\n\n const memberState = {\n [currentUser.id]: {\n user: { ...currentUser, is_account: false },\n user_id: currentUser.id,\n role: 'owner',\n is_account: false,\n },\n [participant.id]: {\n user: { ...participant, is_account: true },\n user_id: participant.id,\n role: 'member',\n is_account: true,\n },\n }\n\n const channel = client.channel('messaging', channelId, {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n members: [currentUser.id, participant.id] as any,\n })\n // Read/unread bookkeeping consults channel mute status; without a live\n // connection the real method throws, so report \"not muted\".\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.muteStatus = (() => ({\n muted: false,\n createdAt: null,\n expiresAt: null,\n })) as any\n\n // Neutralise the rest of the connection-dependent channel API that\n // stream-chat-react calls during the chat lifecycle. Each of these POSTs to\n // Stream or checks for a live WebSocket and would otherwise throw\n // \"Make sure to await connectUser() first.\" offline. They are fire-and-forget\n // (read receipts, typing) or read-only (pagination, unread) so no-ops are safe.\n /* eslint-disable @typescript-eslint/no-explicit-any */\n channel.markRead = (async () => ({}) as any) as any\n channel.keystroke = (async () => undefined) as any\n channel.stopTyping = (async () => undefined) as any\n channel.sendReaction = (async () => ({}) as any) as any\n channel.deleteReaction = (async () => ({}) as any) as any\n channel.countUnread = (() => 0) as any\n // Pagination: report no older pages so \"load more\" resolves to a no-op.\n channel.query = (async () => ({ messages: [] }) as any) as any\n // Leave/Delete Conversation calls channel.hide(); the real method POSTs to\n // Stream, so no-op it to keep the action working offline.\n channel.hide = (async () => ({}) as any) as any\n /* eslint-enable @typescript-eslint/no-explicit-any */\n\n // Seed state locally instead of fetching. Mirrors the proven ChannelView\n // story mock: override `watch` to populate `state` and return a canned\n // QueryChannelAPIResponse. `stream-chat-react` may call `watch()` again on\n // remount/navigation, so seed only once — otherwise a re-watch would wipe\n // messages added via `sendMessage` + the send-echo reply, resetting the chat.\n let hasSeeded = false\n channel.watch = async () => {\n if (!hasSeeded) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.messages = seededMessages as unknown as any[]\n hasSeeded = true\n }\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.members = memberState as unknown as any\n return {\n channel: { members: [currentUser.id, participant.id] },\n members: [],\n messages: channel.state.messages,\n watchers: [],\n pinned_messages: [],\n duration: '0ms',\n } as unknown as QueryChannelAPIResponse\n }\n\n const appendMessage = (message: MessageResponse) => {\n // addMessageSorted is Stream's own state mutation (dedupes by id), so the\n // toolkit re-renders through the real channel-state path.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n channel.state.addMessageSorted(message as any)\n client.dispatchEvent({\n type: 'message.new',\n cid,\n message,\n user: message.user ?? undefined,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any)\n }\n\n let sentCount = 0\n let replyCount = 0\n channel.sendMessage = async (message) => {\n const text =\n typeof message === 'string'\n ? message\n : ((message as { text?: string })?.text ?? '')\n const sent = {\n ...toStreamMessage(\n { text, from: 'me', id: `mock-sent-${sentCount++}` },\n seededMessages.length\n ),\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n ...(typeof message === 'object' ? (message as any) : {}),\n user: currentUser,\n cid,\n } as MessageResponse\n\n // stream-chat-react adds the outgoing message optimistically; addMessageSorted\n // dedupes by id, so confirming here is safe and idempotent.\n appendMessage(sent)\n\n const reply = onSend?.(text)\n if (reply) {\n const replyText = typeof reply === 'string' ? reply : reply.text\n const replyAttachments =\n typeof reply === 'string' ? undefined : reply.attachments\n // Capture a unique id before scheduling: two sends within REPLY_DELAY_MS\n // would otherwise both read the same later `sentCount` at fire time and\n // emit colliding reply ids, so Stream's id-based dedupe drops one echo.\n const replyId = `mock-reply-${replyCount++}`\n setTimeout(() => {\n appendMessage(\n toStreamMessage(\n {\n text: replyText,\n from: 'them',\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n attachments: replyAttachments as any,\n id: replyId,\n },\n seededMessages.length\n )\n )\n }, REPLY_DELAY_MS)\n }\n\n return { message: sent } as unknown as SendMessageAPIResponse\n }\n\n // MessagingShell finds the direct conversation via queryChannels; always\n // return the one seeded channel regardless of the filter.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n client.queryChannels = async () => [channel] as any\n\n // Initialise state up-front so first render already has the conversation.\n void channel.watch()\n\n return { client, channel, participantFilterId: participant.id }\n}\n"],"names":["createMockStreamChatClient","user","stubConnection","client","StreamChat","REPLY_DELAY_MS","createMockMessagingClient","currentUser","participant","messages","channelId","onSend","cid","toStreamMessage","message","index","author","createdAt","seededMessages","memberState","channel","hasSeeded","appendMessage","sentCount","replyCount","text","sent","reply","replyText","replyAttachments","replyId"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.7.0-rc-1783333932",
3
+ "version": "3.7.0-rc-1783334820",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@linktr.ee/component-library": "11.8.6",
53
- "@linktr.ee/messaging-core": "2.3.0-rc-1783333932",
53
+ "@linktr.ee/messaging-core": "2.3.0-rc-1783334820",
54
54
  "@phosphor-icons/react": "^2.1.10"
55
55
  },
56
56
  "devDependencies": {
@@ -322,4 +322,47 @@ describe('MessagingProvider', () => {
322
322
  expect(connectUser).not.toHaveBeenCalled()
323
323
  expect(disconnectUser).not.toHaveBeenCalled()
324
324
  })
325
+
326
+ it('does not reconnect a stale service after switching to an injected client', async () => {
327
+ const { connectUser } = setupServiceMock()
328
+
329
+ const { rerender } = render(
330
+ <MessagingProvider
331
+ apiKey="mock-api-key"
332
+ user={{ id: 'user-1', name: 'User One' } as MessagingUser}
333
+ serviceConfig={{
334
+ fetchToken: async () => 'token',
335
+ createChannel: async () => ({ channelId: 'ch-1' }),
336
+ }}
337
+ >
338
+ <Probe onState={() => {}} />
339
+ </MessagingProvider>
340
+ )
341
+
342
+ // apiKey path connects user-1 through its service.
343
+ await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1))
344
+
345
+ const injectedClient = {
346
+ userID: 'injected-user',
347
+ } as unknown as StreamChat
348
+
349
+ // Switch the same provider to an injected client and change the user. The
350
+ // user-connection effect must not drive the new user onto the stale
351
+ // apiKey-path service.
352
+ rerender(
353
+ <MessagingProvider
354
+ client={injectedClient}
355
+ user={{ id: 'user-2', name: 'User Two' } as MessagingUser}
356
+ >
357
+ <Probe onState={() => {}} />
358
+ </MessagingProvider>
359
+ )
360
+
361
+ await act(async () => {
362
+ await Promise.resolve()
363
+ })
364
+
365
+ // Still only the single user-1 connect — user-2 never reached the service.
366
+ expect(connectUser).toHaveBeenCalledTimes(1)
367
+ })
325
368
  })
@@ -216,6 +216,13 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
216
216
  dependencies: { service: !!service, userId: user?.id },
217
217
  })
218
218
 
219
+ // The injected client owns its own connection — never connect a service for
220
+ // it. Without this guard a `user` change in injected-client mode would run
221
+ // service.connectUser on a stale apiKey-path service (the toggle case).
222
+ if (injectedClient) {
223
+ return
224
+ }
225
+
219
226
  if (!service || !user) {
220
227
  debugLog('⚠️ USER CONNECTION SKIPPED', 'Missing service or user')
221
228
  return
@@ -273,7 +280,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
273
280
  }
274
281
 
275
282
  connectUser()
276
- }, [service, user, debugLog, isConnected])
283
+ }, [service, user, debugLog, isConnected, injectedClient])
277
284
 
278
285
  // Disconnect tied to the client's lifetime: only when `chatClient` changes
279
286
  // (i.e. `apiKey` changes) or the provider unmounts — never on a mere
@@ -146,7 +146,11 @@ export function createMockMessagingClient({
146
146
  // Read/unread bookkeeping consults channel mute status; without a live
147
147
  // connection the real method throws, so report "not muted".
148
148
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
- channel.muteStatus = (() => ({ muted: false, createdAt: null, expiresAt: null })) as any
149
+ channel.muteStatus = (() => ({
150
+ muted: false,
151
+ createdAt: null,
152
+ expiresAt: null,
153
+ })) as any
150
154
 
151
155
  // Neutralise the rest of the connection-dependent channel API that
152
156
  // stream-chat-react calls during the chat lifecycle. Each of these POSTs to
@@ -162,6 +166,9 @@ export function createMockMessagingClient({
162
166
  channel.countUnread = (() => 0) as any
163
167
  // Pagination: report no older pages so "load more" resolves to a no-op.
164
168
  channel.query = (async () => ({ messages: [] }) as any) as any
169
+ // Leave/Delete Conversation calls channel.hide(); the real method POSTs to
170
+ // Stream, so no-op it to keep the action working offline.
171
+ channel.hide = (async () => ({}) as any) as any
165
172
  /* eslint-enable @typescript-eslint/no-explicit-any */
166
173
 
167
174
  // Seed state locally instead of fetching. Mirrors the proven ChannelView
@@ -203,13 +210,17 @@ export function createMockMessagingClient({
203
210
  }
204
211
 
205
212
  let sentCount = 0
213
+ let replyCount = 0
206
214
  channel.sendMessage = async (message) => {
207
215
  const text =
208
216
  typeof message === 'string'
209
217
  ? message
210
218
  : ((message as { text?: string })?.text ?? '')
211
219
  const sent = {
212
- ...toStreamMessage({ text, from: 'me', id: `mock-sent-${sentCount++}` }, seededMessages.length),
220
+ ...toStreamMessage(
221
+ { text, from: 'me', id: `mock-sent-${sentCount++}` },
222
+ seededMessages.length
223
+ ),
213
224
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
214
225
  ...(typeof message === 'object' ? (message as any) : {}),
215
226
  user: currentUser,
@@ -225,6 +236,10 @@ export function createMockMessagingClient({
225
236
  const replyText = typeof reply === 'string' ? reply : reply.text
226
237
  const replyAttachments =
227
238
  typeof reply === 'string' ? undefined : reply.attachments
239
+ // Capture a unique id before scheduling: two sends within REPLY_DELAY_MS
240
+ // would otherwise both read the same later `sentCount` at fire time and
241
+ // emit colliding reply ids, so Stream's id-based dedupe drops one echo.
242
+ const replyId = `mock-reply-${replyCount++}`
228
243
  setTimeout(() => {
229
244
  appendMessage(
230
245
  toStreamMessage(
@@ -233,7 +248,7 @@ export function createMockMessagingClient({
233
248
  from: 'them',
234
249
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
235
250
  attachments: replyAttachments as any,
236
- id: `mock-reply-${sentCount}`,
251
+ id: replyId,
237
252
  },
238
253
  seededMessages.length
239
254
  )
package/src/types.ts CHANGED
@@ -443,26 +443,43 @@ export interface MessagingShellProps extends ChannelViewPassthroughProps {
443
443
  initialParticipantData?: Participant
444
444
  }
445
445
 
446
- /**
447
- * MessagingProvider component props
448
- */
449
- export interface MessagingProviderProps {
446
+ interface MessagingProviderBaseProps {
450
447
  children: React.ReactNode
451
448
  user: MessagingUser | null
452
- /**
453
- * An already-constructed Stream client. When provided it takes precedence
454
- * over `apiKey`: the provider renders `<Chat>` against this exact instance
455
- * and does NOT create a StreamChatService or call `connectUser` — the caller
456
- * owns the client's connection lifecycle. This is the seam for offline/dev
457
- * (`createMockMessagingClient` from `@linktr.ee/messaging-react/testing`) and
458
- * for tests, so consumers render the real UI instead of forking it.
459
- * `apiKey`/`serviceConfig` are ignored while `client` is set.
460
- */
461
- client?: StreamChat
462
- /** Required unless `client` is provided. */
463
- serviceConfig?: Omit<StreamChatServiceConfig, 'apiKey'>
464
- /** Required unless `client` is provided. */
465
- apiKey?: string
466
449
  capabilities?: MessagingCapabilities
467
450
  debug?: boolean
468
451
  }
452
+
453
+ /**
454
+ * Injected-client mode: the provider renders `<Chat>` against an
455
+ * already-constructed Stream client and does NOT create a StreamChatService or
456
+ * call `connectUser` — the caller owns the client's connection lifecycle. This
457
+ * is the seam for offline/dev (`createMockMessagingClient` from
458
+ * `@linktr.ee/messaging-react/testing`) and for tests, so consumers render the
459
+ * real UI instead of forking it. `apiKey`/`serviceConfig` are not used here.
460
+ */
461
+ interface MessagingProviderInjectedClientProps extends MessagingProviderBaseProps {
462
+ client: StreamChat
463
+ apiKey?: never
464
+ serviceConfig?: never
465
+ }
466
+
467
+ /**
468
+ * apiKey mode: the provider builds its own Stream client from `apiKey` and owns
469
+ * the connect/disconnect lifecycle. Both fields are required so a non-injected
470
+ * consumer cannot compile a provider that never connects.
471
+ */
472
+ interface MessagingProviderApiKeyProps extends MessagingProviderBaseProps {
473
+ client?: undefined
474
+ apiKey: string
475
+ serviceConfig: Omit<StreamChatServiceConfig, 'apiKey'>
476
+ }
477
+
478
+ /**
479
+ * MessagingProvider component props. Either an injected `client` (caller owns
480
+ * the lifecycle) or an `apiKey` + `serviceConfig` (provider owns it) — never
481
+ * neither.
482
+ */
483
+ export type MessagingProviderProps =
484
+ | MessagingProviderInjectedClientProps
485
+ | MessagingProviderApiKeyProps