@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.
- package/dist/{Card-2l_y20FM.js → Card-CmlaSw7i.js} +2 -2
- package/dist/{Card-2l_y20FM.js.map → Card-CmlaSw7i.js.map} +1 -1
- package/dist/{Card-Gr4HDVbn.cjs → Card-EWN07XIl.cjs} +2 -2
- package/dist/{Card-Gr4HDVbn.cjs.map → Card-EWN07XIl.cjs.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/{index-B96WLmx6.js → index-CN3errpB.js} +3055 -3109
- package/dist/index-CN3errpB.js.map +1 -0
- package/dist/index-CcitzVoW.cjs +5 -0
- package/dist/index-CcitzVoW.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +56 -60
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/components/ChannelView.broadcast-name.integration.test.tsx +116 -0
- package/src/components/ChannelView.tsx +17 -3
- package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +15 -0
- package/src/components/CustomLinkPreviewList/index.tsx +24 -26
- package/src/components/CustomMessage/BroadcastNameContext.test.ts +43 -0
- package/src/components/CustomMessage/BroadcastNameContext.ts +95 -0
- package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -22
- package/src/components/CustomMessage/CustomMessage.test.tsx +48 -0
- package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +62 -0
- package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +8 -2
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -8
- package/src/components/CustomMessage/index.tsx +23 -3
- package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +50 -2
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +180 -2
- package/src/components/CustomMessageInput/index.tsx +143 -16
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +57 -166
- package/src/components/MessageAttachment/Audio/index.tsx +77 -71
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +75 -175
- package/src/components/MessageAttachment/index.tsx +9 -25
- package/src/components/MessageAttachment/types.ts +9 -19
- package/src/components/MessagingShell/index.tsx +2 -0
- package/src/index.ts +1 -0
- package/src/styles.css +5 -120
- package/src/types.ts +11 -0
- package/src/utils/formatRelativeTime.test.ts +63 -57
- package/src/utils/formatRelativeTime.ts +21 -20
- package/dist/index-B96WLmx6.js.map +0 -1
- package/dist/index-CCtHMrmO.cjs +0 -5
- package/dist/index-CCtHMrmO.cjs.map +0 -1
- 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
|
-
|
|
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
|
-
<
|
|
184
|
-
<
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
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
|
-
|
|
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
|
|
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: () => (
|