@linktr.ee/messaging-react 3.18.0 → 3.19.0-rc-1785389440

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 (46) hide show
  1. package/dist/{Card-owYJxu9h.js → Card-B2Wwdd_f.js} +2 -2
  2. package/dist/Card-B2Wwdd_f.js.map +1 -0
  3. package/dist/{Card-DdJcz7Np.cjs → Card-BASTLo6d.cjs} +2 -2
  4. package/dist/Card-BASTLo6d.cjs.map +1 -0
  5. package/dist/assets/index.css +1 -1
  6. package/dist/index-BOwprCNq.cjs +2 -0
  7. package/dist/index-BOwprCNq.cjs.map +1 -0
  8. package/dist/{index-CsPg1TYf.js → index-CXGGi2Cy.js} +1347 -1297
  9. package/dist/index-CXGGi2Cy.js.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +14 -2
  12. package/dist/index.js +1 -1
  13. package/dist/testing.cjs.map +1 -1
  14. package/dist/testing.d.ts +3 -1
  15. package/dist/testing.js.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/ChannelView.stories.tsx +1 -2
  18. package/src/components/ChannelView.tsx +6 -1
  19. package/src/components/CustomMessage/CustomMessage.stories.tsx +40 -0
  20. package/src/components/CustomMessage/CustomMessage.test.tsx +141 -0
  21. package/src/components/CustomMessage/LockedAttachment/components/_shared/useReceivedSource.ts +3 -0
  22. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +76 -4
  23. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +75 -7
  24. package/src/components/CustomMessage/index.tsx +5 -2
  25. package/src/components/LinkAttachment/components/_shared/useChinPalette.ts +3 -0
  26. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +0 -1
  27. package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +0 -1
  28. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +0 -1
  29. package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +0 -1
  30. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +0 -1
  31. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +0 -1
  32. package/src/components/MessageAttachment/_shared/useCarousel.ts +3 -0
  33. package/src/components/MessagingShell/index.tsx +5 -0
  34. package/src/hooks/useChannelModerationActions.ts +3 -0
  35. package/src/hooks/useChannelStar.ts +3 -0
  36. package/src/index.ts +1 -0
  37. package/src/providers/MessagingProvider.tsx +3 -0
  38. package/src/stream-custom-data.ts +3 -0
  39. package/src/styles.css +48 -5
  40. package/src/testing/createMockMessagingClient.ts +0 -1
  41. package/src/types.ts +12 -0
  42. package/dist/Card-DdJcz7Np.cjs.map +0 -1
  43. package/dist/Card-owYJxu9h.js.map +0 -1
  44. package/dist/index-BQ4mQDo2.cjs +0 -2
  45. package/dist/index-BQ4mQDo2.cjs.map +0 -1
  46. package/dist/index-CsPg1TYf.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-BQ4mQDo2.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.MessageBubble=e.MessageBubble;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.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;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-BOwprCNq.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.MessageBubble=e.MessageBubble;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.optimizeMessagingAttachmentUrl=e.optimizeMessagingAttachmentUrl;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
@@ -83,6 +83,11 @@ export declare interface AvatarProps {
83
83
  dmAgentEnabled?: boolean;
84
84
  }
85
85
 
86
+ export declare type BroadcastClickHandler = (broadcast: {
87
+ id: string;
88
+ name?: string;
89
+ }) => void;
90
+
86
91
  /**
87
92
  * Build the meta line shown under the title in compact document / file
88
93
  * attachments — `EXT · SIZE` (`PDF · 379.5 KB`). Either part is dropped
@@ -206,7 +211,7 @@ export declare const ChannelView: default_2.NamedExoticComponent<ChannelViewProp
206
211
  * Props that MessagingShell passes through to ChannelView.
207
212
  * ChannelViewProps is the source of truth for these props.
208
213
  */
209
- declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customChannelActions' | 'renderChannelActions' | 'onParticipantNameClick' | 'renderMessage' | 'onMessageLinkClick' | 'showChannelInfo' | 'composerInput'>;
214
+ declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customChannelActions' | 'renderChannelActions' | 'onParticipantNameClick' | 'renderMessage' | 'onMessageLinkClick' | 'onBroadcastClick' | 'showChannelInfo' | 'composerInput'>;
210
215
 
211
216
  /**
212
217
  * ChannelView component props
@@ -392,6 +397,11 @@ export declare interface ChannelViewProps {
392
397
  * onMessageLinkClick={(url) => trackChannelLinkClicked(url)}
393
398
  */
394
399
  onMessageLinkClick?: MessageLinkClickHandler;
400
+ /**
401
+ * Fired when a broadcast label is clicked on an outgoing broadcast message.
402
+ * Hosts own navigation so this shared package never assumes an admin URL.
403
+ */
404
+ onBroadcastClick?: BroadcastClickHandler;
395
405
  /**
396
406
  * Passed to Stream `Channel` as `SendButton`. Required for hosts that replace
397
407
  * the send control: `Channel` merges this into `ComponentContext` and an
@@ -1349,7 +1359,7 @@ export declare interface MessageBubbleBaseProps {
1349
1359
  */
1350
1360
  export declare type MessageBubbleVariant = 'sent' | 'received';
1351
1361
 
1352
- declare type MessageCustomType = 'MESSAGE_TIP' | 'MESSAGE_PAID' | 'MESSAGE_CHATBOT' | 'MESSAGE_ATTACHMENT' | AgeSafetySystemType | EscalationSystemType | DmAgentSystemType;
1362
+ declare type MessageCustomType = 'MESSAGE_TIP' | 'MESSAGE_PAID' | 'MESSAGE_CHATBOT' | 'MESSAGE_OUTBOUND' | 'MESSAGE_ATTACHMENT' | AgeSafetySystemType | EscalationSystemType | DmAgentSystemType;
1353
1363
 
1354
1364
  /**
1355
1365
  * Callback invoked when a user clicks a link inside the channel view.
@@ -1380,6 +1390,8 @@ export declare interface MessageMetadata {
1380
1390
  * `custom_type` — it identifies the send context, not the message kind. (MES-1266)
1381
1391
  */
1382
1392
  sent_from?: 'stack_detail';
1393
+ broadcast_id?: string;
1394
+ broadcast_name?: string;
1383
1395
  }
1384
1396
 
1385
1397
  export declare const MessageVoteButtons: default_2.FC<MessageVoteButtonsProps>;
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 p, m as d, n as v, p as C, q as A, s as k, t as F, u as b, v as P, w as f, x, o as y, y as z, z as B, B as S, D as q, E as D, G as E, H as V, I as w } from "./index-CsPg1TYf.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 p, m as d, n as v, p as C, q as A, s as k, t as F, u as b, v as P, w as f, x, o as y, y as z, z as B, B as S, D as q, E as D, G as E, H as V, I as w } from "./index-CXGGi2Cy.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
@@ -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 = (() => ({\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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"}
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 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.d.ts CHANGED
@@ -41,7 +41,7 @@ declare type DmAgentSystemType = 'SYSTEM_DM_AGENT_PAUSED' | 'SYSTEM_DM_AGENT_RES
41
41
 
42
42
  declare type EscalationSystemType = 'SYSTEM_ESCALATION';
43
43
 
44
- declare type MessageCustomType = 'MESSAGE_TIP' | 'MESSAGE_PAID' | 'MESSAGE_CHATBOT' | 'MESSAGE_ATTACHMENT' | AgeSafetySystemType | EscalationSystemType | DmAgentSystemType;
44
+ declare type MessageCustomType = 'MESSAGE_TIP' | 'MESSAGE_PAID' | 'MESSAGE_CHATBOT' | 'MESSAGE_OUTBOUND' | 'MESSAGE_ATTACHMENT' | AgeSafetySystemType | EscalationSystemType | DmAgentSystemType;
45
45
 
46
46
  export declare interface MessageMetadata {
47
47
  custom_type?: MessageCustomType;
@@ -62,6 +62,8 @@ export declare interface MessageMetadata {
62
62
  * `custom_type` — it identifies the send context, not the message kind. (MES-1266)
63
63
  */
64
64
  sent_from?: 'stack_detail';
65
+ broadcast_id?: string;
66
+ broadcast_name?: string;
65
67
  }
66
68
 
67
69
  /**
@@ -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 = (() => ({\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":";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;AAAA,IAC3B,OAAO;AAAA,IACP,WAAW;AAAA,IACX,WAAW;AAAA,EAAA,KASbA,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,KAG3CA,EAAQ,QAAQ,aAAa,CAAA;AAQ7B,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,GACZC,IAAa;AACjB,SAAAJ,EAAQ,cAAc,OAAON,MAAY;AACvC,UAAMW,IACJ,OAAOX,KAAY,WACfA,KACEA,KAAA,gBAAAA,EAA+B,SAAQ,IACzCY,IAAO;AAAA,MACX,GAAGb;AAAA,QACD,EAAE,MAAAY,GAAM,MAAM,MAAM,IAAI,aAAaF,GAAW,GAAA;AAAA,QAChDL,EAAe;AAAA,MAAA;AAAA;AAAA,MAGjB,GAAI,OAAOJ,KAAY,WAAYA,IAAkB,CAAA;AAAA,MACrD,MAAMP;AAAA,MACN,KAAAK;AAAA,IAAA;AAKF,IAAAU,EAAcI,CAAI;AAElB,UAAMC,IAAQhB,KAAA,gBAAAA,EAASc;AACvB,QAAIE,GAAO;AACT,YAAMC,IAAY,OAAOD,KAAU,WAAWA,IAAQA,EAAM,MACtDE,IACJ,OAAOF,KAAU,WAAW,SAAYA,EAAM,aAI1CG,IAAU,cAAcN,GAAY;AAC1C,iBAAW,MAAM;AACf,QAAAF;AAAA,UACET;AAAA,YACE;AAAA,cACE,MAAMe;AAAA,cACN,MAAM;AAAA;AAAA,cAEN,aAAaC;AAAA,cACb,IAAIC;AAAA,YAAA;AAAA,YAENZ,EAAe;AAAA,UAAA;AAAA,QACjB;AAAA,MAEJ,GAAGb,CAAc;AAAA,IACnB;AAEA,WAAO,EAAE,SAASqB,EAAA;AAAA,EACpB,GAKAvB,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 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.18.0",
3
+ "version": "3.19.0-rc-1785389440",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -40,8 +40,8 @@
40
40
  "test:ci": "vitest run",
41
41
  "test:ui": "vitest --ui",
42
42
  "test:coverage": "vitest --coverage",
43
- "lint": "npx --no-install eslint . --ext .ts,.tsx",
44
- "lint:fix": "npx --no-install eslint . --ext .ts,.tsx --fix",
43
+ "lint": "npx --no-install eslint .",
44
+ "lint:fix": "npx --no-install eslint . --fix",
45
45
  "verify": "yarn type-check && yarn lint && yarn test:ci && yarn build",
46
46
  "storybook": "storybook dev -p 6006",
47
47
  "storybook:build": "storybook build --stats-json",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@linktr.ee/component-library": "11.8.6",
53
- "@linktr.ee/messaging-core": "^2.2.0",
53
+ "@linktr.ee/messaging-core": "2.3.0-rc-1785389440",
54
54
  "@phosphor-icons/react": "^2.1.10"
55
55
  },
56
56
  "devDependencies": {
@@ -201,7 +201,7 @@ const createMockChannel = async (
201
201
  // Initialize the channel
202
202
  try {
203
203
  await channel.watch()
204
- } catch (e) {
204
+ } catch {
205
205
  // Ignore errors - we're in mock mode
206
206
  }
207
207
 
@@ -358,7 +358,6 @@ ParticipantIdentityTrigger.args = {
358
358
  channelCustomerTag: 'CUSTOMER_PAID',
359
359
  hasScrollableMessages: true,
360
360
  onParticipantNameClick: () =>
361
- // eslint-disable-next-line no-alert
362
361
  window.alert('Participant details clicked — open Thread content'),
363
362
  }
364
363
  ParticipantIdentityTrigger.parameters = {
@@ -56,6 +56,7 @@ const ChannelViewInner: React.FC<{
56
56
  viewerLanguage?: string
57
57
  showChannelInfo?: boolean
58
58
  onParticipantNameClick?: () => void
59
+ onBroadcastClick?: ChannelViewProps['onBroadcastClick']
59
60
  composerInput?: ChannelViewProps['composerInput']
60
61
  }> = ({
61
62
  onBack,
@@ -82,6 +83,7 @@ const ChannelViewInner: React.FC<{
82
83
  viewerLanguage,
83
84
  showChannelInfo = true,
84
85
  onParticipantNameClick,
86
+ onBroadcastClick,
85
87
  composerInput,
86
88
  }) => {
87
89
  const { channel } = useChannelStateContext()
@@ -125,6 +127,7 @@ const ChannelViewInner: React.FC<{
125
127
  {...props}
126
128
  chatbotVotingEnabled={chatbotVotingEnabled}
127
129
  viewerLanguage={viewerLanguage}
130
+ onBroadcastClick={onBroadcastClick}
128
131
  />
129
132
  )
130
133
 
@@ -134,7 +137,7 @@ const ChannelViewInner: React.FC<{
134
137
 
135
138
  return renderMessage(messageNode, message)
136
139
  },
137
- [chatbotVotingEnabled, renderMessage, viewerLanguage]
140
+ [chatbotVotingEnabled, onBroadcastClick, renderMessage, viewerLanguage]
138
141
  )
139
142
 
140
143
  return (
@@ -292,6 +295,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
292
295
  viewerLanguage,
293
296
  showChannelInfo = true,
294
297
  onParticipantNameClick,
298
+ onBroadcastClick,
295
299
  composerInput,
296
300
  }) => {
297
301
  // Custom send message handler that:
@@ -405,6 +409,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
405
409
  viewerLanguage={viewerLanguage}
406
410
  showChannelInfo={showChannelInfo}
407
411
  onParticipantNameClick={onParticipantNameClick}
412
+ onBroadcastClick={onBroadcastClick}
408
413
  composerInput={composerInput}
409
414
  />
410
415
  </Channel>
@@ -120,6 +120,7 @@ interface TemplateProps {
120
120
  | 'MESSAGE_PAID'
121
121
  | 'MESSAGE_CHATBOT'
122
122
  | 'MESSAGE_ATTACHMENT'
123
+ | 'MESSAGE_OUTBOUND'
123
124
  payment_status?: 'pending' | 'paid' | 'failed' | 'refunded'
124
125
  amount_text?: string
125
126
  attachment_title?: string
@@ -127,6 +128,8 @@ interface TemplateProps {
127
128
  attachment_thumbnail?: string
128
129
  attachment_detail?: string
129
130
  attachment_content_type?: 'text' | 'media'
131
+ broadcast_id?: string
132
+ broadcast_name?: string
130
133
  }
131
134
  }>
132
135
  }
@@ -257,6 +260,43 @@ DeliveredStatus.args = {
257
260
  ],
258
261
  }
259
262
 
263
+ export const BroadcastStatus: StoryFn<TemplateProps> = Template.bind({})
264
+ BroadcastStatus.args = {
265
+ messages: [
266
+ {
267
+ id: 'msg-1',
268
+ text: 'July class details',
269
+ user: storyUsers.creator,
270
+ metadata: {
271
+ custom_type: 'MESSAGE_OUTBOUND',
272
+ broadcast_id: 'broadcast-1',
273
+ broadcast_name: 'July 9th Class attendees',
274
+ },
275
+ },
276
+ ],
277
+ }
278
+
279
+ export const BroadcastStatusNonLatest: StoryFn<TemplateProps> = Template.bind({})
280
+ BroadcastStatusNonLatest.args = {
281
+ messages: [
282
+ {
283
+ id: 'msg-1',
284
+ text: 'July class details',
285
+ user: storyUsers.creator,
286
+ metadata: {
287
+ custom_type: 'MESSAGE_OUTBOUND',
288
+ broadcast_id: 'broadcast-1',
289
+ broadcast_name: 'July 9th Class attendees',
290
+ },
291
+ },
292
+ {
293
+ id: 'msg-2',
294
+ text: 'Let me know if you have any questions.',
295
+ user: storyUsers.creator,
296
+ },
297
+ ],
298
+ }
299
+
260
300
  export const WithTipTag: StoryFn<TemplateProps> = Template.bind({})
261
301
  WithTipTag.args = {
262
302
  messages: [
@@ -0,0 +1,141 @@
1
+ import React from 'react'
2
+ import { useChannelStateContext, useMessageContext } from 'stream-chat-react'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { renderWithProviders, screen } from '../../test/utils'
6
+
7
+ import { CustomMessage } from './index'
8
+
9
+ vi.mock('stream-chat-react', () => ({
10
+ Attachment: () => null,
11
+ EditMessageModal: () => null,
12
+ MessageBounceModal: () => null,
13
+ MessageBouncePrompt: () => null,
14
+ MessageBlocked: () => null,
15
+ MessageDeleted: () => null,
16
+ MessageErrorIcon: () => null,
17
+ MessageIsThreadReplyInChannelButtonIndicator: () => null,
18
+ MessageRepliesCountButton: () => null,
19
+ MessageStatus: ({
20
+ MessageSentStatus,
21
+ }: {
22
+ MessageSentStatus: React.ComponentType
23
+ }) => <MessageSentStatus />,
24
+ MessageText: () => <span>Message</span>,
25
+ Poll: () => null,
26
+ ReminderNotification: () => null,
27
+ StreamedMessageText: () => null,
28
+ areMessageUIPropsEqual: () => true,
29
+ isDateSeparatorMessage: () => false,
30
+ isMessageBlocked: () => false,
31
+ isMessageBounced: () => false,
32
+ messageHasAttachments: () => false,
33
+ messageHasReactions: () => false,
34
+ useChannelStateContext: vi.fn(),
35
+ useChatContext: () => ({ client: { polls: { fromState: vi.fn() } } }),
36
+ useComponentContext: () => ({
37
+ Attachment: () => null,
38
+ MessageRepliesCountButton: () => null,
39
+ }),
40
+ useMessageContext: vi.fn(),
41
+ useMessageReminder: () => undefined,
42
+ }))
43
+
44
+ vi.mock('../../hooks/useMessageVote', () => ({
45
+ useMessageVote: () => ({
46
+ selected: undefined,
47
+ voteUp: vi.fn(),
48
+ voteDown: vi.fn(),
49
+ }),
50
+ }))
51
+
52
+ vi.mock('./context', () => ({
53
+ useCustomMessage: () => ({
54
+ isUnlocking: () => false,
55
+ }),
56
+ }))
57
+
58
+ vi.mock('../Avatar', () => ({
59
+ Avatar: () => null,
60
+ }))
61
+
62
+ vi.mock('./MessageTag', () => ({
63
+ MessageTag: () => null,
64
+ isAttachmentMessage: () => false,
65
+ isChatbotMessage: () => false,
66
+ isMediaAttachmentMessage: () => false,
67
+ isTextAttachmentMessage: () => false,
68
+ isTipOnlyMessage: () => false,
69
+ }))
70
+
71
+ vi.mock('./MessageVoteButtons', () => ({
72
+ MessageVoteButtons: () => null,
73
+ }))
74
+
75
+ vi.mock('./LockedAttachment', () => ({
76
+ default: () => null,
77
+ }))
78
+
79
+ vi.mock('./StreamAttachmentMessage', () => ({
80
+ default: () => null,
81
+ buildOrderedAttachmentSegments: () => [],
82
+ }))
83
+
84
+ vi.mock('../MediaMessage', () => ({
85
+ isLinkAttachment: () => false,
86
+ }))
87
+
88
+ const mockedUseChannelStateContext = vi.mocked(useChannelStateContext)
89
+ const mockedUseMessageContext = vi.mocked(useMessageContext)
90
+
91
+ describe('CustomMessage', () => {
92
+ beforeEach(() => {
93
+ mockedUseChannelStateContext.mockReturnValue({
94
+ messages: [{ id: 'message-1', user: { id: 'me' } }],
95
+ } as never)
96
+ mockedUseMessageContext.mockReturnValue({
97
+ message: {
98
+ id: 'message-1',
99
+ text: 'Hello',
100
+ type: 'regular',
101
+ status: 'received',
102
+ user: { id: 'me', name: 'Me' },
103
+ metadata: {
104
+ broadcast_id: 'broadcast-1',
105
+ broadcast_name: 'July 9th Class attendees',
106
+ },
107
+ },
108
+ isMyMessage: () => true,
109
+ threadList: false,
110
+ editing: false,
111
+ endOfGroup: true,
112
+ firstOfGroup: true,
113
+ groupedByUser: true,
114
+ handleAction: vi.fn(),
115
+ handleOpenThread: vi.fn(),
116
+ handleRetry: vi.fn(),
117
+ highlighted: false,
118
+ renderText: undefined,
119
+ isMessageAIGenerated: undefined,
120
+ } as never)
121
+ })
122
+
123
+ it('uses the latest broadcast click handler after rerendering', () => {
124
+ const firstHandler = vi.fn()
125
+ const secondHandler = vi.fn()
126
+ const { rerender } = renderWithProviders(
127
+ <CustomMessage onBroadcastClick={firstHandler} />
128
+ )
129
+
130
+ rerender(<CustomMessage onBroadcastClick={secondHandler} />)
131
+ screen.getByRole('button', {
132
+ name: 'View broadcast July 9th Class attendees',
133
+ }).click()
134
+
135
+ expect(firstHandler).not.toHaveBeenCalled()
136
+ expect(secondHandler).toHaveBeenCalledWith({
137
+ id: 'broadcast-1',
138
+ name: 'July 9th Class attendees',
139
+ })
140
+ })
141
+ })
@@ -1,3 +1,6 @@
1
+ /* eslint-disable react-hooks/refs -- pre-existing pattern flagged by the React
2
+ Compiler rules added in eslint-plugin-react-hooks v7; refactoring it is
3
+ out of scope for the lint toolchain upgrade. */
1
4
  import { useCallback, useEffect, useRef, useState } from 'react'
2
5
 
3
6
  import type {