@linktr.ee/messaging-react 3.31.0-rc-1785485102 → 3.31.0

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 (44) hide show
  1. package/dist/{Card-2l_y20FM.js → Card-CmlaSw7i.js} +2 -2
  2. package/dist/{Card-2l_y20FM.js.map → Card-CmlaSw7i.js.map} +1 -1
  3. package/dist/{Card-Gr4HDVbn.cjs → Card-EWN07XIl.cjs} +2 -2
  4. package/dist/{Card-Gr4HDVbn.cjs.map → Card-EWN07XIl.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-B96WLmx6.js → index-CN3errpB.js} +3055 -3109
  7. package/dist/index-CN3errpB.js.map +1 -0
  8. package/dist/index-CcitzVoW.cjs +5 -0
  9. package/dist/index-CcitzVoW.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +56 -60
  12. package/dist/index.js +1 -1
  13. package/package.json +2 -2
  14. package/src/components/ChannelView.broadcast-name.integration.test.tsx +116 -0
  15. package/src/components/ChannelView.tsx +17 -3
  16. package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +15 -0
  17. package/src/components/CustomLinkPreviewList/index.tsx +24 -26
  18. package/src/components/CustomMessage/BroadcastNameContext.test.ts +43 -0
  19. package/src/components/CustomMessage/BroadcastNameContext.ts +95 -0
  20. package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -22
  21. package/src/components/CustomMessage/CustomMessage.test.tsx +48 -0
  22. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +62 -0
  23. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +8 -2
  24. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
  25. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -8
  26. package/src/components/CustomMessage/index.tsx +23 -3
  27. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +50 -2
  28. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +180 -2
  29. package/src/components/CustomMessageInput/index.tsx +143 -16
  30. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +57 -166
  31. package/src/components/MessageAttachment/Audio/index.tsx +77 -71
  32. package/src/components/MessageAttachment/MessageAttachment.test.tsx +75 -175
  33. package/src/components/MessageAttachment/index.tsx +9 -25
  34. package/src/components/MessageAttachment/types.ts +9 -19
  35. package/src/components/MessagingShell/index.tsx +2 -0
  36. package/src/index.ts +1 -0
  37. package/src/styles.css +5 -120
  38. package/src/types.ts +11 -0
  39. package/src/utils/formatRelativeTime.test.ts +63 -57
  40. package/src/utils/formatRelativeTime.ts +21 -20
  41. package/dist/index-B96WLmx6.js.map +0 -1
  42. package/dist/index-CCtHMrmO.cjs +0 -5
  43. package/dist/index-CCtHMrmO.cjs.map +0 -1
  44. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +0 -335
@@ -0,0 +1,95 @@
1
+ import { createContext, useState } from 'react'
2
+
3
+ import type { BroadcastNameResolver } from '../../types'
4
+
5
+ /** Live campaign names keyed by broadcast id — not the resolver function. */
6
+ export type BroadcastNamesById = Readonly<Record<string, string>>
7
+
8
+ export const BroadcastNameContext = createContext<
9
+ BroadcastNamesById | undefined
10
+ >(undefined)
11
+
12
+ const broadcastIdFromMessage = (message: {
13
+ metadata?: { broadcast_id?: unknown }
14
+ }): string | undefined => {
15
+ const broadcastId = message.metadata?.broadcast_id
16
+ return typeof broadcastId === 'string' ? broadcastId : undefined
17
+ }
18
+
19
+ /**
20
+ * Resolve campaign names for the messages currently in view.
21
+ *
22
+ * Built outside memoized Stream rows so a new resolver reference can publish
23
+ * new campaign names without remounting the Stream message rows.
24
+ */
25
+ export const buildBroadcastNamesById = (
26
+ messages: readonly { metadata?: { broadcast_id?: unknown } }[] | undefined,
27
+ resolveBroadcastName: BroadcastNameResolver | undefined
28
+ ): BroadcastNamesById | undefined => {
29
+ if (!resolveBroadcastName || !messages?.length) {
30
+ return undefined
31
+ }
32
+
33
+ const seen = new Set<string>()
34
+ let names: Record<string, string> | undefined
35
+
36
+ for (const message of messages) {
37
+ const broadcastId = broadcastIdFromMessage(message)
38
+ if (!broadcastId || seen.has(broadcastId)) {
39
+ continue
40
+ }
41
+ seen.add(broadcastId)
42
+
43
+ const resolved = resolveBroadcastName(broadcastId)
44
+ if (!resolved) {
45
+ continue
46
+ }
47
+
48
+ names ??= {}
49
+ names[broadcastId] = resolved
50
+ }
51
+
52
+ return names
53
+ }
54
+
55
+ const areBroadcastNamesEqual = (
56
+ previous: BroadcastNamesById | undefined,
57
+ next: BroadcastNamesById | undefined
58
+ ): boolean => {
59
+ if (previous === next) {
60
+ return true
61
+ }
62
+ if (!previous || !next) {
63
+ return false
64
+ }
65
+
66
+ const previousIds = Object.keys(previous)
67
+ const nextIds = Object.keys(next)
68
+ if (previousIds.length !== nextIds.length) {
69
+ return false
70
+ }
71
+
72
+ return previousIds.every((id) => previous[id] === next[id])
73
+ }
74
+
75
+ /**
76
+ * Re-invokes the resolver on every render, but only returns a new object when
77
+ * the resolved strings actually change — so context consumers update for
78
+ * late-arriving campaign names without thrashing on unrelated renders.
79
+ */
80
+ export const useBroadcastNamesById = (
81
+ messages: readonly { metadata?: { broadcast_id?: unknown } }[] | undefined,
82
+ resolveBroadcastName: BroadcastNameResolver | undefined
83
+ ): BroadcastNamesById | undefined => {
84
+ const names = buildBroadcastNamesById(messages, resolveBroadcastName)
85
+ const [publishedNames, setPublishedNames] = useState<
86
+ BroadcastNamesById | undefined
87
+ >(names)
88
+
89
+ if (!areBroadcastNamesEqual(publishedNames, names)) {
90
+ setPublishedNames(names)
91
+ return names
92
+ }
93
+
94
+ return publishedNames
95
+ }
@@ -23,8 +23,10 @@ import {
23
23
  storyUsers,
24
24
  } from '../../stories/decorators/storyUser'
25
25
  import { createMockStreamChatClient } from '../../testing/createMockStreamChatClient'
26
+ import type { BroadcastNameResolver } from '../../types'
26
27
  import CustomTypingIndicator from '../CustomTypingIndicator'
27
28
 
29
+ import { BroadcastNameContext, buildBroadcastNamesById } from './BroadcastNameContext'
28
30
  import { CustomMessageActions } from './CustomMessageActions'
29
31
 
30
32
  import { CustomMessage } from './index'
@@ -49,7 +51,6 @@ const createMockChannel = async (
49
51
  type: msg.type ?? ('regular' as const),
50
52
  created_at: minutesAgo(messages.length - index),
51
53
  updated_at: minutesAgo(messages.length - index),
52
- html: `<p>${msg.text}</p>`,
53
54
  attachments: msg.attachments ?? [],
54
55
  latest_reactions: [],
55
56
  own_reactions: [],
@@ -58,7 +59,7 @@ const createMockChannel = async (
58
59
  reply_count: 0,
59
60
  status: 'received',
60
61
  cid: 'messaging:storybook-channel-1',
61
- mentioned_users: [],
62
+ mentioned_users: msg.mentioned_users ?? [],
62
63
  }))
63
64
 
64
65
  const channelData = {
@@ -108,10 +109,12 @@ const createMockChannel = async (
108
109
  interface TemplateProps {
109
110
  currentUser: StoryUser
110
111
  typingUser?: StoryUser
112
+ resolveBroadcastName?: BroadcastNameResolver
111
113
  messages: Array<{
112
114
  id: string
113
115
  text: string
114
116
  user: StoryUser
117
+ mentioned_users?: StoryUser[]
115
118
  type?: 'regular' | 'system'
116
119
  attachments?: Array<Record<string, unknown>>
117
120
  metadata?: {
@@ -138,7 +141,8 @@ const TemplateInner: React.FC<{
138
141
  currentUser: StoryUser
139
142
  typingUser?: StoryUser
140
143
  messages: TemplateProps['messages']
141
- }> = ({ currentUser, typingUser, messages }) => {
144
+ resolveBroadcastName?: BroadcastNameResolver
145
+ }> = ({ currentUser, typingUser, messages, resolveBroadcastName }) => {
142
146
  const [client] = React.useState(() => createMockStreamChatClient(currentUser))
143
147
 
144
148
  const [channel, setChannel] = React.useState<ChannelType | null>(null)
@@ -164,14 +168,14 @@ const TemplateInner: React.FC<{
164
168
  return () => clearTimeout(timer)
165
169
  }, [channel, client, typingUser])
166
170
 
171
+ const broadcastNamesById = React.useMemo(
172
+ () => buildBroadcastNamesById(messages, resolveBroadcastName),
173
+ [messages, resolveBroadcastName]
174
+ )
175
+
167
176
  const MessageComponent = React.useMemo(() => {
168
177
  return function CustomMessageComponent(props: MessageUIComponentProps) {
169
- return (
170
- <CustomMessage
171
- {...props}
172
- onBroadcastClick={() => undefined}
173
- />
174
- )
178
+ return <CustomMessage {...props} onBroadcastClick={() => undefined} />
175
179
  }
176
180
  }, [])
177
181
 
@@ -180,19 +184,21 @@ const TemplateInner: React.FC<{
180
184
  }
181
185
 
182
186
  return (
183
- <Chat client={client}>
184
- <div className="h-screen w-full bg-white">
185
- <Channel
186
- channel={channel}
187
- Message={MessageComponent}
188
- TypingIndicator={CustomTypingIndicator}
189
- >
190
- <Window>
191
- <MessageList />
192
- </Window>
193
- </Channel>
194
- </div>
195
- </Chat>
187
+ <BroadcastNameContext.Provider value={broadcastNamesById}>
188
+ <Chat client={client}>
189
+ <div className="h-screen w-full bg-white">
190
+ <Channel
191
+ channel={channel}
192
+ Message={MessageComponent}
193
+ TypingIndicator={CustomTypingIndicator}
194
+ >
195
+ <Window>
196
+ <MessageList />
197
+ </Window>
198
+ </Channel>
199
+ </div>
200
+ </Chat>
201
+ </BroadcastNameContext.Provider>
196
202
  )
197
203
  }
198
204
 
@@ -200,12 +206,14 @@ const Template: StoryFn<TemplateProps> = ({
200
206
  currentUser = storyUsers.creator,
201
207
  typingUser,
202
208
  messages,
209
+ resolveBroadcastName,
203
210
  }) => (
204
211
  <TemplateInner
205
212
  key={currentUser.id}
206
213
  currentUser={currentUser}
207
214
  typingUser={typingUser}
208
215
  messages={messages}
216
+ resolveBroadcastName={resolveBroadcastName}
209
217
  />
210
218
  )
211
219
 
@@ -230,6 +238,24 @@ Default.args = {
230
238
  ],
231
239
  }
232
240
 
241
+ export const MentionContrast: StoryFn<TemplateProps> = Template.bind({})
242
+ MentionContrast.args = {
243
+ messages: [
244
+ {
245
+ id: 'msg-1',
246
+ text: 'Hi @Creator, could you take a look at this?',
247
+ user: storyUsers.visitor,
248
+ mentioned_users: [storyUsers.creator],
249
+ },
250
+ {
251
+ id: 'msg-2',
252
+ text: 'Sure thing, @Visitor — I will check it now.',
253
+ user: storyUsers.creator,
254
+ mentioned_users: [storyUsers.visitor],
255
+ },
256
+ ],
257
+ }
258
+
233
259
  // MES-1036: "✓✓ Delivered" appears below only the most recent *sent*
234
260
  // message. Every mock message carries status: 'received', and stream's
235
261
  // MessageList derives `lastOwnMessage`, so the indicator lands on the last
@@ -302,6 +328,12 @@ BroadcastStatusNonLatest.args = {
302
328
  ],
303
329
  }
304
330
 
331
+ export const BroadcastStatusResolved: StoryFn<TemplateProps> = Template.bind({})
332
+ BroadcastStatusResolved.args = {
333
+ ...BroadcastStatus.args,
334
+ resolveBroadcastName: () => 'July 9th Class absentee',
335
+ }
336
+
305
337
  export const WithTipTag: StoryFn<TemplateProps> = Template.bind({})
306
338
  WithTipTag.args = {
307
339
  messages: [
@@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
4
 
5
5
  import { renderWithProviders, screen } from '../../test/utils'
6
6
 
7
+ import { BroadcastNameContext } from './BroadcastNameContext'
8
+
7
9
  import { CustomMessage } from './index'
8
10
 
9
11
  vi.mock('stream-chat-react', () => ({
@@ -138,4 +140,50 @@ describe('CustomMessage', () => {
138
140
  name: 'July 9th Class attendees',
139
141
  })
140
142
  })
143
+
144
+ it('prefers a resolved broadcast name over the stamped name', () => {
145
+ renderWithProviders(
146
+ <BroadcastNameContext.Provider
147
+ value={{ 'broadcast-1': 'July 9th Class absentee' }}
148
+ >
149
+ <CustomMessage />
150
+ </BroadcastNameContext.Provider>
151
+ )
152
+
153
+ expect(
154
+ screen.getByTestId('sent-message-broadcast-status')
155
+ ).toHaveTextContent('July 9th Class absentee')
156
+ })
157
+
158
+ it('falls back to the stamped broadcast name when resolution is unavailable', () => {
159
+ renderWithProviders(
160
+ <BroadcastNameContext.Provider value={{}}>
161
+ <CustomMessage />
162
+ </BroadcastNameContext.Provider>
163
+ )
164
+
165
+ expect(
166
+ screen.getByTestId('sent-message-broadcast-status')
167
+ ).toHaveTextContent('July 9th Class attendees')
168
+ })
169
+
170
+ it('renders a later resolved name when the live context value changes', () => {
171
+ const { rerender } = renderWithProviders(
172
+ <BroadcastNameContext.Provider value={{}}>
173
+ <CustomMessage />
174
+ </BroadcastNameContext.Provider>
175
+ )
176
+
177
+ rerender(
178
+ <BroadcastNameContext.Provider
179
+ value={{ 'broadcast-1': 'July 9th Class absentee' }}
180
+ >
181
+ <CustomMessage />
182
+ </BroadcastNameContext.Provider>
183
+ )
184
+
185
+ expect(
186
+ screen.getByTestId('sent-message-broadcast-status')
187
+ ).toHaveTextContent('July 9th Class absentee')
188
+ })
141
189
  })
@@ -269,6 +269,68 @@ describe('SentMessageDeliveryStatus', () => {
269
269
  )
270
270
  })
271
271
 
272
+ it('passes the resolved broadcast name to the click callback', () => {
273
+ const onBroadcastClick = vi.fn()
274
+ mockedUseMessageContext.mockReturnValue({
275
+ message: {
276
+ id: 'own-2',
277
+ metadata: {
278
+ broadcast_id: 'broadcast-1',
279
+ broadcast_name: 'July 9th Class attendees',
280
+ },
281
+ },
282
+ isMyMessage: () => true,
283
+ threadList: false,
284
+ } as never)
285
+
286
+ renderWithProviders(
287
+ <SentMessageDeliveryStatus
288
+ onBroadcastClick={onBroadcastClick}
289
+ resolvedBroadcastName="July 9th Class absentee"
290
+ />
291
+ )
292
+
293
+ screen.getByRole('button', {
294
+ name: 'View broadcast July 9th Class absentee',
295
+ }).click()
296
+
297
+ expect(onBroadcastClick).toHaveBeenCalledWith({
298
+ id: 'broadcast-1',
299
+ name: 'July 9th Class absentee',
300
+ })
301
+ })
302
+
303
+ it('reports a campaign named Broadcast on click', () => {
304
+ const onBroadcastClick = vi.fn()
305
+ mockedUseMessageContext.mockReturnValue({
306
+ message: {
307
+ id: 'own-3',
308
+ metadata: {
309
+ broadcast_id: 'broadcast-1',
310
+ broadcast_name: 'Old campaign name',
311
+ },
312
+ },
313
+ isMyMessage: () => true,
314
+ threadList: false,
315
+ } as never)
316
+
317
+ renderWithProviders(
318
+ <SentMessageDeliveryStatus
319
+ onBroadcastClick={onBroadcastClick}
320
+ resolvedBroadcastName="Broadcast"
321
+ />
322
+ )
323
+
324
+ screen.getByRole('button', {
325
+ name: 'View broadcast Broadcast',
326
+ }).click()
327
+
328
+ expect(onBroadcastClick).toHaveBeenCalledWith({
329
+ id: 'broadcast-1',
330
+ name: 'Broadcast',
331
+ })
332
+ })
333
+
272
334
  it('does not render a broadcast label for a visitor viewing a broadcast message', () => {
273
335
  mockedUseMessageContext.mockReturnValue({
274
336
  message: {
@@ -97,13 +97,16 @@ const SendingStatus = () => {
97
97
  const BroadcastStatus = ({
98
98
  broadcastId,
99
99
  broadcastName,
100
+ resolvedBroadcastName,
100
101
  onBroadcastClick,
101
102
  }: {
102
103
  broadcastId: string
103
104
  broadcastName?: string
105
+ resolvedBroadcastName?: string
104
106
  onBroadcastClick?: (broadcast: { id: string; name?: string }) => void
105
107
  }) => {
106
- const name = broadcastName || 'Broadcast'
108
+ const campaignName = resolvedBroadcastName || broadcastName
109
+ const name = campaignName || 'Broadcast'
107
110
  const content = (
108
111
  <>
109
112
  <span className="sent-message-broadcast-badge" aria-hidden="true">
@@ -126,7 +129,7 @@ const BroadcastStatus = ({
126
129
  onClick={() =>
127
130
  onBroadcastClick({
128
131
  id: broadcastId,
129
- ...(broadcastName ? { name: broadcastName } : {}),
132
+ ...(campaignName ? { name: campaignName } : {}),
130
133
  })
131
134
  }
132
135
  >
@@ -160,8 +163,10 @@ const FailedStatus = () => (
160
163
  */
161
164
  export const SentMessageDeliveryStatus = ({
162
165
  onBroadcastClick,
166
+ resolvedBroadcastName,
163
167
  }: {
164
168
  onBroadcastClick?: (broadcast: { id: string; name?: string }) => void
169
+ resolvedBroadcastName?: string
165
170
  }) => {
166
171
  const { isMyMessage, message, threadList } = useMessageContext(
167
172
  'SentMessageDeliveryStatus'
@@ -192,6 +197,7 @@ export const SentMessageDeliveryStatus = ({
192
197
  <BroadcastStatus
193
198
  broadcastId={broadcastId}
194
199
  broadcastName={broadcastName}
200
+ resolvedBroadcastName={resolvedBroadcastName}
195
201
  onBroadcastClick={onBroadcastClick}
196
202
  />
197
203
  ) : null
@@ -258,37 +258,4 @@ describe('StreamAttachmentMessage', () => {
258
258
  )
259
259
  ).toHaveTextContent('from the trip')
260
260
  })
261
-
262
- it('forwards a persisted duration and waveform to the audio card', () => {
263
- // Audio is sent as `type: "file"` with an `audio/*` mime type (for
264
- // mobile compatibility), and the composer persists `duration` +
265
- // `waveform_data` on the outgoing attachment. Without this
266
- // passthrough every sent and received card would fall back to
267
- // `--:--` and a flat placeholder even though the data is there.
268
- renderWithProviders(
269
- <StreamAttachmentMessage
270
- groupPosition="single"
271
- isMyMessage
272
- message={message([
273
- {
274
- type: 'file',
275
- asset_url: 'https://cdn.example.com/take-1.mp3',
276
- mime_type: 'audio/mpeg',
277
- title: 'take-1.mp3',
278
- duration: 12,
279
- waveform_data: [0.2, 0.9, 0.5, 0.7],
280
- },
281
- ])}
282
- />
283
- )
284
-
285
- expect(screen.getByText('00:12')).toBeInTheDocument()
286
- // The flat placeholder renders every bar at 0%; a real waveform is
287
- // observable as bars drawn above that floor.
288
- const barHeights = screen
289
- .getAllByTestId('amplitude-bar')
290
- .map((bar) => bar.style.getPropertyValue('--mes-audio-wave-bar'))
291
- expect(barHeights).toHaveLength(35)
292
- expect(barHeights.some((height) => height !== '0%')).toBe(true)
293
- })
294
261
  })
@@ -225,8 +225,6 @@ function buildMediaClusterRenderProps(
225
225
  src: trimToUndefined(attachment.asset_url) ?? '',
226
226
  mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
227
227
  filename: trimToUndefined((attachment as { title?: string }).title),
228
- durationSeconds: attachment.duration,
229
- waveformData: attachment.waveform_data,
230
228
  })),
231
229
  }
232
230
  case 'pdf':
@@ -273,12 +271,6 @@ function buildMediaClusterRenderProps(
273
271
  src: trimToUndefined(attachment.asset_url) ?? '',
274
272
  mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
275
273
  filename: trimToUndefined((attachment as { title?: string }).title),
276
- // Both are first-class `stream-chat` Attachment fields. The
277
- // composer writes them at send time so the waveform card paints
278
- // the real amplitudes and the correct duration with no remote
279
- // decode and no CDN CORS exposure.
280
- durationSeconds: attachment.duration,
281
- waveformData: attachment.waveform_data,
282
274
  }
283
275
  case 'pdf':
284
276
  return {
@@ -1,5 +1,5 @@
1
1
  import classNames from 'classnames'
2
- import React, { useMemo, useState } from 'react'
2
+ import React, { useContext, useMemo, useState } from 'react'
3
3
  import {
4
4
  Attachment as DefaultAttachment,
5
5
  EditMessageModal as DefaultEditMessageModal,
@@ -35,6 +35,7 @@ import { Avatar } from '../Avatar'
35
35
  import { isLinkAttachment } from '../MediaMessage'
36
36
  import { bubbleGroupPositionFromStream as messageAttachmentGroupPositionFromStream } from '../MessageAttachment'
37
37
 
38
+ import { BroadcastNameContext } from './BroadcastNameContext'
38
39
  import { useCustomMessage } from './context'
39
40
  import LockedAttachment from './LockedAttachment'
40
41
  import {
@@ -61,6 +62,7 @@ type CustomMessageWithContextProps = MessageContextValue & {
61
62
  chatbotVotingEnabled?: boolean
62
63
  viewerLanguage?: string
63
64
  onBroadcastClick?: (broadcast: { id: string; name?: string }) => void
65
+ resolvedBroadcastName?: string
64
66
  }
65
67
 
66
68
  const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
@@ -82,6 +84,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
82
84
  threadList,
83
85
  viewerLanguage,
84
86
  onBroadcastClick,
87
+ resolvedBroadcastName,
85
88
  } = props
86
89
 
87
90
  const { client } = useChatContext('CustomMessage')
@@ -426,7 +429,10 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
426
429
  )}
427
430
  </div>
428
431
  )}
429
- <SentMessageDeliveryStatus onBroadcastClick={onBroadcastClick} />
432
+ <SentMessageDeliveryStatus
433
+ onBroadcastClick={onBroadcastClick}
434
+ resolvedBroadcastName={resolvedBroadcastName}
435
+ />
430
436
  {showReplyCountButton && (
431
437
  <MessageRepliesCountButton
432
438
  onClick={handleOpenThread}
@@ -447,11 +453,25 @@ const MemoizedCustomMessage = React.memo(
447
453
  if (prev.chatbotVotingEnabled !== next.chatbotVotingEnabled) return false
448
454
  if (prev.viewerLanguage !== next.viewerLanguage) return false
449
455
  if (prev.onBroadcastClick !== next.onBroadcastClick) return false
456
+ if (prev.resolvedBroadcastName !== next.resolvedBroadcastName) return false
450
457
  return areMessageUIPropsEqual(prev, next)
451
458
  }
452
459
  ) as typeof CustomMessageWithContext
453
460
 
454
461
  export const CustomMessage = (props: CustomMessageUIComponentProps) => {
455
462
  const messageContext = useMessageContext('CustomMessage')
456
- return <MemoizedCustomMessage {...messageContext} {...props} />
463
+ const broadcastNamesById = useContext(BroadcastNameContext)
464
+ const broadcastId = messageContext.message.metadata?.broadcast_id
465
+ const resolvedBroadcastName =
466
+ typeof broadcastId === 'string'
467
+ ? broadcastNamesById?.[broadcastId]
468
+ : undefined
469
+
470
+ return (
471
+ <MemoizedCustomMessage
472
+ {...messageContext}
473
+ {...props}
474
+ resolvedBroadcastName={resolvedBroadcastName}
475
+ />
476
+ )
457
477
  }
@@ -6,6 +6,7 @@ import { Channel, Chat, type SendButtonProps } from 'stream-chat-react'
6
6
 
7
7
  import { createMockStreamChatClient } from '../../testing/createMockStreamChatClient'
8
8
  import LockedAttachment from '../CustomMessage/LockedAttachment'
9
+ import LinkAttachment from '../LinkAttachment'
9
10
 
10
11
  import { CustomMessageInput } from '.'
11
12
 
@@ -75,10 +76,16 @@ type WrapperProps = {
75
76
  sendButton?: React.ComponentType<SendButtonProps>
76
77
  attachmentPreviewList?: React.ComponentType
77
78
  renderFooter?: () => React.ReactNode
79
+ /**
80
+ * Stands in for a host that publishes `--messaging-surface-height` — the
81
+ * keyboard-shrunk viewport height the composer caps itself against. Set it to
82
+ * pin the cap to a known value instead of leaving it viewport-relative.
83
+ */
84
+ surfaceHeight?: number
78
85
  }
79
86
 
80
87
  const Wrapper: React.FC<WrapperProps> = (props) => {
81
- const { frozen, renderActions, sendButton, attachmentPreviewList, renderFooter } = props
88
+ const { frozen, renderActions, sendButton, attachmentPreviewList, renderFooter, surfaceHeight } = props
82
89
 
83
90
  const [client] = React.useState(() => createMockStreamChatClient(mockUser))
84
91
 
@@ -97,7 +104,23 @@ const Wrapper: React.FC<WrapperProps> = (props) => {
97
104
  {...(sendButton ? { SendButton: sendButton } : {})}
98
105
  {...(attachmentPreviewList ? { AttachmentPreviewList: attachmentPreviewList } : {})}
99
106
  >
100
- <div className="bg-white" style={{ minWidth: 360 }}>
107
+ <div
108
+ className="bg-white"
109
+ style={
110
+ {
111
+ minWidth: 360,
112
+ // Bottom-anchored inside a fixed-height surface, the way the host
113
+ // lays the composer out under a raised keyboard.
114
+ ...(surfaceHeight && {
115
+ height: surfaceHeight,
116
+ '--messaging-surface-height': `${surfaceHeight}px`,
117
+ display: 'flex',
118
+ flexDirection: 'column',
119
+ justifyContent: 'flex-end',
120
+ }),
121
+ } as React.CSSProperties
122
+ }
123
+ >
101
124
  <CustomMessageInput renderActions={renderActions} renderFooter={renderFooter} />
102
125
  </div>
103
126
  </Channel>
@@ -226,6 +249,31 @@ WithAttachments.parameters = {
226
249
  },
227
250
  }
228
251
 
252
+ export const WithStagedLinkPreviewUnderKeyboard: StoryFn<WrapperProps> = (args) => (
253
+ <Wrapper {...args} />
254
+ )
255
+ WithStagedLinkPreviewUnderKeyboard.args = {
256
+ // Roughly a 393x852 phone with the soft keyboard up, so the 250px staged card
257
+ // alone outgrows the composer's cap (half of this) and the card scrolls.
258
+ surfaceHeight: 516,
259
+ attachmentPreviewList: () => (
260
+ <LinkAttachment.Composer
261
+ title="How to run your first marathon"
262
+ url="https://example.com/marathon"
263
+ thumbnailUrl="https://picsum.photos/seed/marathon/580/300"
264
+ onDismiss={() => console.log('dismiss link preview')}
265
+ />
266
+ ),
267
+ }
268
+ WithStagedLinkPreviewUnderKeyboard.parameters = {
269
+ docs: {
270
+ description: {
271
+ story:
272
+ 'MES-1037: with a link preview staged the composer used to grow tall enough for the soft keyboard to cover the textarea and send button. The white card is now capped at half `--messaging-surface-height` (the keyboard-shrunk height its host publishes) and scrolls as ONE region — the preview keeps its full height instead of being cropped to make room, and the send button sticks to the bottom of the scroll so a long message cannot carry it out of view.',
273
+ },
274
+ },
275
+ }
276
+
229
277
  export const WithPaidAttachment: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
230
278
  WithPaidAttachment.args = {
231
279
  attachmentPreviewList: () => (