@linktr.ee/messaging-react 3.20.0 → 3.22.0-rc-1785413501

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.
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-DSZ8Rxnl.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-BnBvT5FW.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
@@ -82,6 +82,11 @@ export declare interface AvatarProps {
82
82
  dmAgentEnabled?: boolean;
83
83
  }
84
84
 
85
+ export declare type BroadcastClickHandler = (broadcast: {
86
+ id: string;
87
+ name?: string;
88
+ }) => void;
89
+
85
90
  /**
86
91
  * Build the meta line shown under the title in compact document / file
87
92
  * attachments — `EXT · SIZE` (`PDF · 379.5 KB`). Either part is dropped
@@ -205,7 +210,7 @@ export declare const ChannelView: default_2.NamedExoticComponent<ChannelViewProp
205
210
  * Props that MessagingShell passes through to ChannelView.
206
211
  * ChannelViewProps is the source of truth for these props.
207
212
  */
208
- declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customChannelActions' | 'renderChannelActions' | 'onParticipantNameClick' | 'renderMessage' | 'onMessageLinkClick' | 'showChannelInfo' | 'composerInput'>;
213
+ declare type ChannelViewPassthroughProps = Pick<ChannelViewProps, 'renderMessageInputActions' | 'renderConversationFooter' | 'CustomChannelEmptyState' | 'onBlockParticipantClick' | 'onReportParticipantClick' | 'dmAgentEnabled' | 'onMessageSent' | 'chatbotVotingEnabled' | 'viewerLanguage' | 'renderChannelBanner' | 'customChannelActions' | 'renderChannelActions' | 'onParticipantNameClick' | 'renderMessage' | 'onMessageLinkClick' | 'onBroadcastClick' | 'showChannelInfo' | 'composerInput'>;
209
214
 
210
215
  /**
211
216
  * ChannelView component props
@@ -391,6 +396,11 @@ export declare interface ChannelViewProps {
391
396
  * onMessageLinkClick={(url) => trackChannelLinkClicked(url)}
392
397
  */
393
398
  onMessageLinkClick?: MessageLinkClickHandler;
399
+ /**
400
+ * Fired when a broadcast label is clicked on an outgoing broadcast message.
401
+ * Hosts own navigation so this shared package never assumes an admin URL.
402
+ */
403
+ onBroadcastClick?: BroadcastClickHandler;
394
404
  /**
395
405
  * Passed to Stream `Channel` as `SendButton`. Required for hosts that replace
396
406
  * the send control: `Channel` merges this into `ComponentContext` and an
@@ -1373,6 +1383,8 @@ export declare interface MessageMetadata {
1373
1383
  * `custom_type` — it identifies the send context, not the message kind. (MES-1266)
1374
1384
  */
1375
1385
  sent_from?: 'stack_detail';
1386
+ broadcast_id?: string;
1387
+ broadcast_name?: string;
1376
1388
  }
1377
1389
 
1378
1390
  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-BuqmY6ae.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-DIKyFjPC.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/dist/testing.d.ts CHANGED
@@ -55,6 +55,8 @@ export declare interface MessageMetadata {
55
55
  * `custom_type` — it identifies the send context, not the message kind. (MES-1266)
56
56
  */
57
57
  sent_from?: 'stack_detail';
58
+ broadcast_id?: string;
59
+ broadcast_name?: string;
58
60
  }
59
61
 
60
62
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.20.0",
3
+ "version": "3.22.0-rc-1785413501",
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.2.0",
53
+ "@linktr.ee/messaging-core": "2.3.0-rc-1785413501",
54
54
  "@linktr.ee/messaging-taxonomy": "^0.3.0",
55
55
  "@phosphor-icons/react": "^2.1.10"
56
56
  },
@@ -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
  }
@@ -163,7 +166,12 @@ const TemplateInner: React.FC<{
163
166
 
164
167
  const MessageComponent = React.useMemo(() => {
165
168
  return function CustomMessageComponent(props: MessageUIComponentProps) {
166
- return <CustomMessage {...props} />
169
+ return (
170
+ <CustomMessage
171
+ {...props}
172
+ onBroadcastClick={() => undefined}
173
+ />
174
+ )
167
175
  }
168
176
  }, [])
169
177
 
@@ -257,6 +265,43 @@ DeliveredStatus.args = {
257
265
  ],
258
266
  }
259
267
 
268
+ export const BroadcastStatus: StoryFn<TemplateProps> = Template.bind({})
269
+ BroadcastStatus.args = {
270
+ messages: [
271
+ {
272
+ id: 'msg-1',
273
+ text: 'July class details',
274
+ user: storyUsers.creator,
275
+ metadata: {
276
+ custom_type: 'MESSAGE_OUTBOUND',
277
+ broadcast_id: 'broadcast-1',
278
+ broadcast_name: 'July 9th Class attendees',
279
+ },
280
+ },
281
+ ],
282
+ }
283
+
284
+ export const BroadcastStatusNonLatest: StoryFn<TemplateProps> = Template.bind({})
285
+ BroadcastStatusNonLatest.args = {
286
+ messages: [
287
+ {
288
+ id: 'msg-1',
289
+ text: 'July class details',
290
+ user: storyUsers.creator,
291
+ metadata: {
292
+ custom_type: 'MESSAGE_OUTBOUND',
293
+ broadcast_id: 'broadcast-1',
294
+ broadcast_name: 'July 9th Class attendees',
295
+ },
296
+ },
297
+ {
298
+ id: 'msg-2',
299
+ text: 'Let me know if you have any questions.',
300
+ user: storyUsers.creator,
301
+ },
302
+ ],
303
+ }
304
+
260
305
  export const WithTipTag: StoryFn<TemplateProps> = Template.bind({})
261
306
  WithTipTag.args = {
262
307
  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,8 +1,5 @@
1
1
  import React from 'react'
2
- import {
3
- useChannelStateContext,
4
- useMessageContext,
5
- } from 'stream-chat-react'
2
+ import { useChannelStateContext, useMessageContext } from 'stream-chat-react'
6
3
  import { beforeEach, describe, expect, it, vi } from 'vitest'
7
4
 
8
5
  import { renderWithProviders, screen, within } from '../../test/utils'
@@ -69,6 +66,7 @@ describe('SentMessageDeliveryStatus', () => {
69
66
  // avatars.
70
67
  mockedUseMessageContext.mockReturnValue({
71
68
  message: { id: 'own-2' },
69
+ isMyMessage: () => true,
72
70
  } as never)
73
71
 
74
72
  renderWithProviders(<SentMessageDeliveryStatus />)
@@ -83,6 +81,7 @@ describe('SentMessageDeliveryStatus', () => {
83
81
  it('renders nothing for an earlier own message once a later message exists', () => {
84
82
  mockedUseMessageContext.mockReturnValue({
85
83
  message: { id: 'own-1' },
84
+ isMyMessage: () => true,
86
85
  } as never)
87
86
 
88
87
  renderWithProviders(<SentMessageDeliveryStatus />)
@@ -104,6 +103,7 @@ describe('SentMessageDeliveryStatus', () => {
104
103
  } as never)
105
104
  mockedUseMessageContext.mockReturnValue({
106
105
  message: { id: 'own-2' },
106
+ isMyMessage: () => true,
107
107
  } as never)
108
108
 
109
109
  renderWithProviders(<SentMessageDeliveryStatus />)
@@ -124,6 +124,7 @@ describe('SentMessageDeliveryStatus', () => {
124
124
  } as never)
125
125
  mockedUseMessageContext.mockReturnValue({
126
126
  message: { id: 'own-2' },
127
+ isMyMessage: () => true,
127
128
  } as never)
128
129
 
129
130
  renderWithProviders(<SentMessageDeliveryStatus />)
@@ -138,6 +139,7 @@ describe('SentMessageDeliveryStatus', () => {
138
139
  it('renders "Sending" in the sending slot for the latest message', () => {
139
140
  mockedUseMessageContext.mockReturnValue({
140
141
  message: { id: 'own-2', status: 'sending' },
142
+ isMyMessage: () => true,
141
143
  threadList: false,
142
144
  } as never)
143
145
 
@@ -149,6 +151,7 @@ describe('SentMessageDeliveryStatus', () => {
149
151
  it('does not render "Sending" for an earlier message', () => {
150
152
  mockedUseMessageContext.mockReturnValue({
151
153
  message: { id: 'own-1', status: 'sending' },
154
+ isMyMessage: () => true,
152
155
  threadList: false,
153
156
  } as never)
154
157
 
@@ -162,6 +165,7 @@ describe('SentMessageDeliveryStatus', () => {
162
165
  // threadList is true; we suppress the stamp ourselves.
163
166
  mockedUseMessageContext.mockReturnValue({
164
167
  message: { id: 'own-2', status: 'sending' },
168
+ isMyMessage: () => true,
165
169
  threadList: true,
166
170
  } as never)
167
171
 
@@ -173,6 +177,120 @@ describe('SentMessageDeliveryStatus', () => {
173
177
  ).not.toBeInTheDocument()
174
178
  })
175
179
 
180
+ it('renders a broadcast label for every own broadcast message, including non-latest messages', () => {
181
+ mockedUseMessageContext.mockReturnValue({
182
+ message: {
183
+ id: 'own-1',
184
+ metadata: {
185
+ broadcast_id: 'broadcast-1',
186
+ broadcast_name: 'July 9th Class attendees',
187
+ },
188
+ },
189
+ isMyMessage: () => true,
190
+ threadList: false,
191
+ } as never)
192
+
193
+ renderWithProviders(<SentMessageDeliveryStatus />)
194
+
195
+ expect(
196
+ screen.getByTestId('sent-message-broadcast-status')
197
+ ).toHaveTextContent('July 9th Class attendees')
198
+ expect(
199
+ screen.queryByTestId('sent-message-delivery-status')
200
+ ).not.toBeInTheDocument()
201
+ expect(screen.queryByText('•')).not.toBeInTheDocument()
202
+ })
203
+
204
+ it('uses a generic label and invokes the broadcast callback when the name is missing', () => {
205
+ const onBroadcastClick = vi.fn()
206
+ mockedUseMessageContext.mockReturnValue({
207
+ message: { id: 'own-2', metadata: { broadcast_id: 'broadcast-1' } },
208
+ isMyMessage: () => true,
209
+ threadList: false,
210
+ } as never)
211
+
212
+ renderWithProviders(
213
+ <SentMessageDeliveryStatus onBroadcastClick={onBroadcastClick} />
214
+ )
215
+
216
+ expect(
217
+ screen.getByTestId('sent-message-broadcast-status')
218
+ ).toHaveTextContent('Broadcast')
219
+ expect(
220
+ screen.getByRole('button', { name: 'View broadcast Broadcast' })
221
+ ).toBeInTheDocument()
222
+ const button = screen.getByRole('button', {
223
+ name: 'View broadcast Broadcast',
224
+ })
225
+ expect(
226
+ button.querySelector('.sent-message-broadcast-badge')
227
+ ).toBeInTheDocument()
228
+ expect(
229
+ button.querySelector('.sent-message-broadcast-name')
230
+ ).toHaveTextContent('Broadcast')
231
+ expect(button).not.toHaveTextContent('•')
232
+ expect(screen.getAllByText('•')).toHaveLength(1)
233
+ button.click()
234
+ expect(onBroadcastClick).toHaveBeenCalledWith({ id: 'broadcast-1' })
235
+ })
236
+
237
+ it('keeps the latest separator outside the button and underlines only the name', () => {
238
+ const onBroadcastClick = vi.fn()
239
+ mockedUseMessageContext.mockReturnValue({
240
+ message: {
241
+ id: 'own-2',
242
+ metadata: {
243
+ broadcast_id: 'broadcast-1',
244
+ broadcast_name: 'July 9th Class attendees',
245
+ },
246
+ },
247
+ isMyMessage: () => true,
248
+ threadList: false,
249
+ } as never)
250
+
251
+ renderWithProviders(
252
+ <SentMessageDeliveryStatus onBroadcastClick={onBroadcastClick} />
253
+ )
254
+
255
+ const button = screen.getByRole('button', {
256
+ name: 'View broadcast July 9th Class attendees',
257
+ })
258
+ const name = within(button).getByTestId('sent-message-broadcast-status')
259
+
260
+ expect(screen.getAllByText('•')).toHaveLength(1)
261
+ expect(button).not.toHaveTextContent('•')
262
+ expect(name).toHaveClass('sent-message-broadcast-name')
263
+ expect(button.querySelector('.sent-message-broadcast-badge')).toHaveAttribute(
264
+ 'aria-hidden',
265
+ 'true'
266
+ )
267
+ expect(screen.getAllByTestId('sent-message-delivery-status')).toHaveLength(
268
+ 3
269
+ )
270
+ })
271
+
272
+ it('does not render a broadcast label for a visitor viewing a broadcast message', () => {
273
+ mockedUseMessageContext.mockReturnValue({
274
+ message: {
275
+ id: 'broadcast-1',
276
+ metadata: {
277
+ broadcast_id: 'broadcast-1',
278
+ broadcast_name: 'July 9th Class attendees',
279
+ },
280
+ },
281
+ isMyMessage: () => false,
282
+ threadList: false,
283
+ } as never)
284
+
285
+ renderWithProviders(
286
+ <SentMessageDeliveryStatus onBroadcastClick={vi.fn()} />
287
+ )
288
+
289
+ expect(
290
+ screen.queryByTestId('sent-message-broadcast-status')
291
+ ).not.toBeInTheDocument()
292
+ })
293
+
176
294
  it('renders "Failed to send" for every own failed message', () => {
177
295
  mockedUseMessageContext.mockReturnValue({
178
296
  message: { id: 'own-1', status: 'failed', type: 'regular' },
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ChecksIcon,
3
3
  CircleNotchIcon,
4
+ MegaphoneIcon,
4
5
  WarningCircleIcon,
5
6
  } from '@phosphor-icons/react'
6
7
  import {
@@ -93,6 +94,49 @@ const SendingStatus = () => {
93
94
  )
94
95
  }
95
96
 
97
+ const BroadcastStatus = ({
98
+ broadcastId,
99
+ broadcastName,
100
+ onBroadcastClick,
101
+ }: {
102
+ broadcastId: string
103
+ broadcastName?: string
104
+ onBroadcastClick?: (broadcast: { id: string; name?: string }) => void
105
+ }) => {
106
+ const name = broadcastName || 'Broadcast'
107
+ const content = (
108
+ <>
109
+ <span className="sent-message-broadcast-badge" aria-hidden="true">
110
+ <MegaphoneIcon weight="fill" />
111
+ </span>
112
+ <span
113
+ className="sent-message-broadcast-name"
114
+ data-testid="sent-message-broadcast-status"
115
+ >
116
+ {name}
117
+ </span>
118
+ </>
119
+ )
120
+
121
+ return onBroadcastClick ? (
122
+ <button
123
+ type="button"
124
+ className="sent-message-broadcast-link"
125
+ aria-label={`View broadcast ${name}`}
126
+ onClick={() =>
127
+ onBroadcastClick({
128
+ id: broadcastId,
129
+ ...(broadcastName ? { name: broadcastName } : {}),
130
+ })
131
+ }
132
+ >
133
+ {content}
134
+ </button>
135
+ ) : (
136
+ content
137
+ )
138
+ }
139
+
96
140
  const FailedStatus = () => (
97
141
  <span className="str-chat__message-status sent-message-status--failed">
98
142
  <WarningCircleIcon weight="bold" aria-hidden="true" />
@@ -114,10 +158,16 @@ const FailedStatus = () => (
114
158
  * because it has no failed slot and are shown on every own, non-thread
115
159
  * failed message.
116
160
  */
117
- export const SentMessageDeliveryStatus = () => {
161
+ export const SentMessageDeliveryStatus = ({
162
+ onBroadcastClick,
163
+ }: {
164
+ onBroadcastClick?: (broadcast: { id: string; name?: string }) => void
165
+ }) => {
118
166
  const { isMyMessage, message, threadList } = useMessageContext(
119
167
  'SentMessageDeliveryStatus'
120
168
  )
169
+ const isOwnMessage = isMyMessage()
170
+ const isLatestRealMessage = useIsLatestRealMessage()
121
171
 
122
172
  // Stream's MessageStatus still mounts MessageSendingStatus when
123
173
  // threadList is true; keep all of our stamps out of thread replies.
@@ -125,15 +175,28 @@ export const SentMessageDeliveryStatus = () => {
125
175
  return null
126
176
  }
127
177
 
128
- if (
129
- message.status === 'failed' &&
130
- isMyMessage() &&
131
- message.type !== 'error'
132
- ) {
178
+ if (message.status === 'failed' && isOwnMessage && message.type !== 'error') {
133
179
  return <FailedStatus />
134
180
  }
135
181
 
136
- return (
182
+ if (!isOwnMessage) {
183
+ return null
184
+ }
185
+
186
+ const broadcastId = message.metadata?.broadcast_id
187
+ const broadcastName = message.metadata?.broadcast_name
188
+ const broadcastStatus =
189
+ message.status !== 'sending' &&
190
+ message.status !== 'failed' &&
191
+ broadcastId ? (
192
+ <BroadcastStatus
193
+ broadcastId={broadcastId}
194
+ broadcastName={broadcastName}
195
+ onBroadcastClick={onBroadcastClick}
196
+ />
197
+ ) : null
198
+
199
+ const messageStatus = (
137
200
  <MessageStatus
138
201
  MessageSentStatus={DeliveredStatus}
139
202
  MessageDeliveredStatus={DeliveredStatus}
@@ -141,4 +204,23 @@ export const SentMessageDeliveryStatus = () => {
141
204
  MessageSendingStatus={SendingStatus}
142
205
  />
143
206
  )
207
+
208
+ if (!broadcastStatus) {
209
+ return messageStatus
210
+ }
211
+
212
+ return (
213
+ <span className="sent-message-status-row">
214
+ {messageStatus}
215
+ {isLatestRealMessage && (
216
+ <span
217
+ className="sent-message-broadcast-separator"
218
+ aria-hidden="true"
219
+ >
220
+
221
+ </span>
222
+ )}
223
+ {broadcastStatus}
224
+ </span>
225
+ )
144
226
  }