@linktr.ee/messaging-react 3.6.2-rc-1783304704 → 3.6.2

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 (30) hide show
  1. package/dist/{Card-6FOd4_j_.cjs → Card-BThi6-oD.cjs} +2 -2
  2. package/dist/{Card-6FOd4_j_.cjs.map → Card-BThi6-oD.cjs.map} +1 -1
  3. package/dist/{Card-DN8P8IeT.js → Card-Cz8tanga.js} +2 -2
  4. package/dist/{Card-DN8P8IeT.js.map → Card-Cz8tanga.js.map} +1 -1
  5. package/dist/{Card-CA63InbA.cjs → Card-DEk34Twe.cjs} +2 -2
  6. package/dist/{Card-CA63InbA.cjs.map → Card-DEk34Twe.cjs.map} +1 -1
  7. package/dist/{Card-C_TfROjB.js → Card-DSQclaiH.js} +3 -3
  8. package/dist/{Card-C_TfROjB.js.map → Card-DSQclaiH.js.map} +1 -1
  9. package/dist/{Card-BhIG7T7c.cjs → Card-Jc1n2Iqu.cjs} +2 -2
  10. package/dist/{Card-BhIG7T7c.cjs.map → Card-Jc1n2Iqu.cjs.map} +1 -1
  11. package/dist/{Card-DgWCIgWw.js → Card-NDZLmjxS.js} +2 -2
  12. package/dist/{Card-DgWCIgWw.js.map → Card-NDZLmjxS.js.map} +1 -1
  13. package/dist/{LockedThumbnail-DPlNLdpK.cjs → LockedThumbnail-CqcnnPXB.cjs} +2 -2
  14. package/dist/{LockedThumbnail-DPlNLdpK.cjs.map → LockedThumbnail-CqcnnPXB.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-DNy4CoCm.js → LockedThumbnail-t9fLaFmy.js} +2 -2
  16. package/dist/{LockedThumbnail-DNy4CoCm.js.map → LockedThumbnail-t9fLaFmy.js.map} +1 -1
  17. package/dist/index-CPCX_7gP.cjs +2 -0
  18. package/dist/index-CPCX_7gP.cjs.map +1 -0
  19. package/dist/{index-CxGOKFnx.js → index-bPlX3Oo5.js} +3566 -3284
  20. package/dist/index-bPlX3Oo5.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.js +1 -1
  23. package/package.json +2 -2
  24. package/src/components/CustomMessage/StreamAttachmentMessage.stories.tsx +178 -0
  25. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +186 -0
  26. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +417 -0
  27. package/src/components/CustomMessage/index.tsx +67 -0
  28. package/dist/index-ClREqfMm.cjs +0 -2
  29. package/dist/index-ClREqfMm.cjs.map +0 -1
  30. package/dist/index-CxGOKFnx.js.map +0 -1
@@ -0,0 +1,417 @@
1
+ import React from 'react'
2
+ import type { Attachment } from 'stream-chat'
3
+
4
+ import LinkAttachment from '../LinkAttachment'
5
+ import { isPlayableMedia } from '../LinkAttachment/components/_shared/CardThumbnail'
6
+ import MessageAttachment from '../MessageAttachment'
7
+ import type { BubbleGroupPosition } from '../MessageAttachment/types'
8
+
9
+ type StreamMediaKind = 'image' | 'video' | 'audio' | 'pdf' | 'file'
10
+
11
+ export type AttachmentSegment =
12
+ | { type: 'link'; attachments: Attachment[] }
13
+ | { type: 'media'; kind: StreamMediaKind; attachments: Attachment[] }
14
+
15
+ type StreamThreadMessage = {
16
+ attachments?: Attachment[]
17
+ text?: string
18
+ }
19
+
20
+ const SENT_CAPTION_CLASS =
21
+ 'mt-[2px] max-w-[280px] whitespace-pre-wrap break-words rounded-[18px] bg-[#121110] px-4 py-2 text-white'
22
+ const RECEIVED_CAPTION_CLASS =
23
+ 'mt-[2px] max-w-[280px] whitespace-pre-wrap break-words rounded-[18px] bg-[#e9eaed] px-4 py-2 text-[#080707]'
24
+
25
+ function trimToUndefined(value?: string | null): string | undefined {
26
+ const trimmed = value?.trim()
27
+ return trimmed ? trimmed : undefined
28
+ }
29
+
30
+ export function normalizeLinkUrl(url: string): string {
31
+ return url
32
+ .trim()
33
+ .replace(/^https?:\/\//i, '')
34
+ .replace(/\/+$/, '')
35
+ .toLowerCase()
36
+ }
37
+
38
+ export function linkCaptionDuplicatesAttachmentUrl(
39
+ caption: string | undefined,
40
+ attachmentUrl?: string
41
+ ): boolean {
42
+ if (!caption?.trim() || !attachmentUrl?.trim()) {
43
+ return false
44
+ }
45
+
46
+ const normalizedCaption = normalizeLinkUrl(caption)
47
+ const normalizedUrl = normalizeLinkUrl(attachmentUrl)
48
+
49
+ if (normalizedCaption === normalizedUrl) {
50
+ return true
51
+ }
52
+
53
+ if (!normalizedCaption.includes(normalizedUrl)) {
54
+ return false
55
+ }
56
+
57
+ const remainder = normalizedCaption.split(normalizedUrl).join('')
58
+ return !/[^\p{P}\p{Z}\p{C}]/u.test(remainder)
59
+ }
60
+
61
+ export function linkCardPropsFromStreamAttachment(a: Attachment) {
62
+ const url = trimToUndefined(a.og_scrape_url) ?? trimToUndefined(a.title_link)
63
+ const thumbnailUrl =
64
+ trimToUndefined((a as { image_url?: string }).image_url) ??
65
+ trimToUndefined((a as { thumb_url?: string }).thumb_url)
66
+ const sourceUrl = trimToUndefined(a.asset_url)
67
+ const mimeType = trimToUndefined(a.mime_type)
68
+
69
+ return {
70
+ title: trimToUndefined((a as { title?: string }).title),
71
+ description: trimToUndefined((a as { text?: string }).text),
72
+ url,
73
+ thumbnailUrl,
74
+ // Playable clip embedded in a link preview — lets LinkAttachment render its
75
+ // native video/audio player in the hero instead of a static thumbnail.
76
+ sourceUrl,
77
+ layout:
78
+ thumbnailUrl || isPlayableMedia(mimeType, sourceUrl)
79
+ ? 'featured'
80
+ : 'classic',
81
+ mimeType,
82
+ } as const
83
+ }
84
+
85
+ export function getAttachmentMediaKind(
86
+ attachment: Attachment
87
+ ): StreamMediaKind | null {
88
+ if (isLinkAttachment(attachment)) return null
89
+
90
+ if (attachment.type === 'image') {
91
+ const imageUrl = (attachment as { image_url?: string }).image_url
92
+ if (imageUrl || attachment.asset_url) return 'image'
93
+ return null
94
+ }
95
+
96
+ if (attachment.type === 'video' && attachment.asset_url) return 'video'
97
+ if (attachment.type === 'audio' && attachment.asset_url) return 'audio'
98
+
99
+ if (attachment.type === 'file' && attachment.asset_url) {
100
+ if (attachment.mime_type === 'application/pdf') return 'pdf'
101
+ if (attachment.mime_type?.startsWith('audio/')) return 'audio'
102
+ return 'file'
103
+ }
104
+
105
+ return null
106
+ }
107
+
108
+ export function isLinkAttachment(a: Attachment): boolean {
109
+ const ogScrapeUrl = trimToUndefined(a.og_scrape_url)
110
+ return a.type === 'link' || (!!ogScrapeUrl && !a.asset_url)
111
+ }
112
+
113
+ export function buildOrderedAttachmentSegments(
114
+ attachments: Attachment[] | undefined
115
+ ): AttachmentSegment[] {
116
+ if (!attachments?.length) return []
117
+
118
+ const segments: AttachmentSegment[] = []
119
+
120
+ const pushOrMergeMedia = (kind: StreamMediaKind, attachment: Attachment) => {
121
+ const last = segments[segments.length - 1]
122
+ if (last?.type === 'media' && last.kind === kind) {
123
+ last.attachments.push(attachment)
124
+ return
125
+ }
126
+
127
+ segments.push({ type: 'media', kind, attachments: [attachment] })
128
+ }
129
+
130
+ const pushOrMergeLink = (attachment: Attachment) => {
131
+ const last = segments[segments.length - 1]
132
+ if (last?.type === 'link') {
133
+ last.attachments.push(attachment)
134
+ return
135
+ }
136
+
137
+ segments.push({ type: 'link', attachments: [attachment] })
138
+ }
139
+
140
+ for (const attachment of attachments) {
141
+ if (isLinkAttachment(attachment)) {
142
+ pushOrMergeLink(attachment)
143
+ continue
144
+ }
145
+
146
+ const kind = getAttachmentMediaKind(attachment)
147
+ if (!kind) continue
148
+
149
+ pushOrMergeMedia(kind, attachment)
150
+ }
151
+
152
+ return segments
153
+ }
154
+
155
+ export function countAttachmentSegmentBubbles(
156
+ segments: AttachmentSegment[]
157
+ ): number {
158
+ let count = 0
159
+
160
+ for (const segment of segments) {
161
+ count += segment.type === 'link' ? segment.attachments.length : 1
162
+ }
163
+
164
+ return count
165
+ }
166
+
167
+ export function countMediaSegmentBubbles(segments: AttachmentSegment[]): number {
168
+ return segments.filter((segment) => segment.type === 'media').length
169
+ }
170
+
171
+ export function bubblePositionInRun(
172
+ index: number,
173
+ total: number,
174
+ streamPosition: BubbleGroupPosition
175
+ ): BubbleGroupPosition {
176
+ if (total <= 1) return streamPosition
177
+ if (index === 0) return 'first'
178
+ if (index === total - 1) return 'end'
179
+ return 'middle'
180
+ }
181
+
182
+ function imageSrcFromAttachment(attachment: Attachment): string {
183
+ return (
184
+ trimToUndefined((attachment as { image_url?: string }).image_url) ??
185
+ trimToUndefined(attachment.asset_url) ??
186
+ ''
187
+ )
188
+ }
189
+
190
+ function messageAttachmentNamespaceForKind(kind: StreamMediaKind) {
191
+ switch (kind) {
192
+ case 'image':
193
+ return MessageAttachment.Image
194
+ case 'video':
195
+ return MessageAttachment.Video
196
+ case 'audio':
197
+ return MessageAttachment.Audio
198
+ case 'pdf':
199
+ return MessageAttachment.Pdf
200
+ default:
201
+ return MessageAttachment.File
202
+ }
203
+ }
204
+
205
+ function buildMediaClusterRenderProps(
206
+ kind: StreamMediaKind,
207
+ attachments: Attachment[]
208
+ ): Record<string, unknown> {
209
+ if (attachments.length > 1) {
210
+ switch (kind) {
211
+ case 'image':
212
+ return {
213
+ items: attachments.map((attachment) => ({
214
+ src: imageSrcFromAttachment(attachment),
215
+ alt: trimToUndefined((attachment as { title?: string }).title),
216
+ })),
217
+ }
218
+ case 'video':
219
+ return {
220
+ items: attachments.map((attachment) => ({
221
+ src: trimToUndefined(attachment.asset_url) ?? '',
222
+ poster: trimToUndefined(
223
+ (attachment as { thumb_url?: string }).thumb_url
224
+ ),
225
+ mimeType: trimToUndefined(attachment.mime_type),
226
+ })),
227
+ }
228
+ case 'audio':
229
+ return {
230
+ items: attachments.map((attachment) => ({
231
+ src: trimToUndefined(attachment.asset_url) ?? '',
232
+ mimeType:
233
+ trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
234
+ filename: trimToUndefined(
235
+ (attachment as { title?: string }).title
236
+ ),
237
+ })),
238
+ }
239
+ case 'pdf':
240
+ return {
241
+ items: attachments.map((attachment) => ({
242
+ src: trimToUndefined(attachment.asset_url) ?? '',
243
+ filename: trimToUndefined(
244
+ (attachment as { title?: string }).title
245
+ ),
246
+ fileSize: (attachment as { file_size?: number }).file_size,
247
+ })),
248
+ }
249
+ case 'file':
250
+ return {
251
+ items: attachments.map((attachment) => ({
252
+ src: trimToUndefined(attachment.asset_url) ?? '',
253
+ filename: trimToUndefined(
254
+ (attachment as { title?: string }).title
255
+ ),
256
+ fileSize: (attachment as { file_size?: number }).file_size,
257
+ mimeType:
258
+ trimToUndefined(attachment.mime_type) ??
259
+ 'application/octet-stream',
260
+ })),
261
+ }
262
+ }
263
+ }
264
+
265
+ const attachment = attachments[0]
266
+ if (!attachment) return {}
267
+
268
+ switch (kind) {
269
+ case 'image':
270
+ return {
271
+ src: imageSrcFromAttachment(attachment),
272
+ alt: trimToUndefined((attachment as { title?: string }).title),
273
+ }
274
+ case 'video':
275
+ return {
276
+ src: trimToUndefined(attachment.asset_url) ?? '',
277
+ poster: trimToUndefined((attachment as { thumb_url?: string }).thumb_url),
278
+ mimeType: trimToUndefined(attachment.mime_type),
279
+ }
280
+ case 'audio':
281
+ return {
282
+ src: trimToUndefined(attachment.asset_url) ?? '',
283
+ mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
284
+ filename: trimToUndefined((attachment as { title?: string }).title),
285
+ }
286
+ case 'pdf':
287
+ return {
288
+ src: trimToUndefined(attachment.asset_url) ?? '',
289
+ filename: trimToUndefined((attachment as { title?: string }).title),
290
+ fileSize: (attachment as { file_size?: number }).file_size,
291
+ }
292
+ case 'file':
293
+ return {
294
+ src: trimToUndefined(attachment.asset_url) ?? '',
295
+ filename: trimToUndefined((attachment as { title?: string }).title),
296
+ fileSize: (attachment as { file_size?: number }).file_size,
297
+ mimeType:
298
+ trimToUndefined(attachment.mime_type) ?? 'application/octet-stream',
299
+ }
300
+ }
301
+ }
302
+
303
+ function MediaClusterBubble({
304
+ groupPosition,
305
+ isMyMessage,
306
+ segment,
307
+ text,
308
+ }: {
309
+ groupPosition: BubbleGroupPosition
310
+ isMyMessage: boolean
311
+ segment: Extract<AttachmentSegment, { type: 'media' }>
312
+ text?: string
313
+ }) {
314
+ const TypeNamespace = messageAttachmentNamespaceForKind(segment.kind)
315
+ const props = {
316
+ ...buildMediaClusterRenderProps(segment.kind, segment.attachments),
317
+ groupPosition,
318
+ ...(text ? { text } : {}),
319
+ }
320
+
321
+ if (isMyMessage) {
322
+ return <TypeNamespace.Sent {...props} />
323
+ }
324
+
325
+ return <TypeNamespace.Received {...props} />
326
+ }
327
+
328
+ export interface StreamAttachmentMessageProps {
329
+ groupPosition: BubbleGroupPosition
330
+ isMyMessage: boolean
331
+ message: StreamThreadMessage
332
+ }
333
+
334
+ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
335
+ groupPosition,
336
+ isMyMessage,
337
+ message,
338
+ }) => {
339
+ const segments = buildOrderedAttachmentSegments(message.attachments)
340
+ if (segments.length === 0) return null
341
+
342
+ const totalBubbles = countAttachmentSegmentBubbles(segments)
343
+ const totalMediaBubbles = countMediaSegmentBubbles(segments)
344
+ const rawCaption = message.text?.trim()
345
+ const linkSegment = segments.find((segment) => segment.type === 'link')
346
+ const primaryLinkUrl =
347
+ linkSegment?.type === 'link'
348
+ ? linkCardPropsFromStreamAttachment(linkSegment.attachments[0] ?? {}).url
349
+ : undefined
350
+ const caption =
351
+ rawCaption &&
352
+ linkCaptionDuplicatesAttachmentUrl(rawCaption, primaryLinkUrl)
353
+ ? undefined
354
+ : rawCaption
355
+
356
+ let bubbleIndex = 0
357
+ let mediaBubbleIndex = 0
358
+ const rows: React.ReactNode[] = []
359
+
360
+ const pushLinkRow = (attachment: Attachment, key: string) => {
361
+ const isLastBubble = bubbleIndex === totalBubbles - 1
362
+ bubbleIndex += 1
363
+
364
+ const Card = isMyMessage ? LinkAttachment.Sent : LinkAttachment.Received
365
+
366
+ rows.push(
367
+ <React.Fragment key={key}>
368
+ <Card {...linkCardPropsFromStreamAttachment(attachment)} />
369
+ {isLastBubble && caption ? (
370
+ <div className={isMyMessage ? SENT_CAPTION_CLASS : RECEIVED_CAPTION_CLASS}>
371
+ {caption}
372
+ </div>
373
+ ) : null}
374
+ </React.Fragment>
375
+ )
376
+ }
377
+
378
+ const pushMediaCluster = (
379
+ segment: Extract<AttachmentSegment, { type: 'media' }>,
380
+ key: string
381
+ ) => {
382
+ const isLastBubble = bubbleIndex === totalBubbles - 1
383
+ const position = bubblePositionInRun(
384
+ mediaBubbleIndex,
385
+ totalMediaBubbles,
386
+ groupPosition
387
+ )
388
+
389
+ bubbleIndex += 1
390
+ mediaBubbleIndex += 1
391
+
392
+ rows.push(
393
+ <MediaClusterBubble
394
+ key={key}
395
+ groupPosition={position}
396
+ isMyMessage={isMyMessage}
397
+ segment={segment}
398
+ text={isLastBubble ? caption : undefined}
399
+ />
400
+ )
401
+ }
402
+
403
+ segments.forEach((segment, segmentIndex) => {
404
+ if (segment.type === 'link') {
405
+ segment.attachments.forEach((attachment, attachmentIndex) => {
406
+ pushLinkRow(attachment, `link-${segmentIndex}-${attachmentIndex}`)
407
+ })
408
+ return
409
+ }
410
+
411
+ pushMediaCluster(segment, `media-${segmentIndex}`)
412
+ })
413
+
414
+ return <>{rows}</>
415
+ }
416
+
417
+ export default StreamAttachmentMessage
@@ -34,6 +34,9 @@ import { getMessageDisplayText } from '../../utils/getMessageDisplayText'
34
34
  import { Avatar } from '../Avatar'
35
35
  import LockedAttachment from '../LockedAttachment'
36
36
  import { isLinkAttachment } from '../MediaMessage'
37
+ import {
38
+ bubbleGroupPositionFromStream as messageAttachmentGroupPositionFromStream,
39
+ } from '../MessageAttachment'
37
40
 
38
41
  import { useCustomMessage } from './context'
39
42
  import {
@@ -43,6 +46,9 @@ import {
43
46
  isTipOnlyMessage,
44
47
  } from './MessageTag'
45
48
  import { MessageVoteButtons } from './MessageVoteButtons'
49
+ import StreamAttachmentMessage, {
50
+ buildOrderedAttachmentSegments,
51
+ } from './StreamAttachmentMessage'
46
52
 
47
53
  type CustomMessageUIComponentProps = MessageUIComponentProps & {
48
54
  chatbotVotingEnabled?: boolean
@@ -113,12 +119,38 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
113
119
  const filtered = raw.filter((a) => !('type' in a) || !isLinkAttachment(a))
114
120
  return filtered.length === raw.length ? raw : filtered
115
121
  }, [message])
122
+ const attachmentSegments = useMemo(
123
+ () => buildOrderedAttachmentSegments(finalAttachments),
124
+ [finalAttachments]
125
+ )
116
126
  const displayMessage = useMemo(() => {
117
127
  const displayText = getMessageDisplayText({ message, viewerLanguage })
118
128
  return displayText === message.text
119
129
  ? message
120
130
  : { ...message, text: displayText }
121
131
  }, [message, viewerLanguage])
132
+ // Route every generic Stream-attachment message (link OG previews and
133
+ // image/video/audio/pdf/file media) through the toolkit renderer, so
134
+ // messaging-react owns all shared attachment rendering rather than leaving
135
+ // media on stream-chat-react's default Attachment. App-specific types
136
+ // (locked/paid, tips, chatbot) are handled by the branches above.
137
+ const hasAttachmentSegments = attachmentSegments.length > 0
138
+ // Only own the message when every attachment maps to a representable segment.
139
+ // Otherwise a mixed message (e.g. an unsupported shared_location alongside a
140
+ // photo) would silently drop the unrepresented attachment — those fall
141
+ // through to the default `Attachment` renderer, which handles all of them.
142
+ const allAttachmentsRepresented =
143
+ attachmentSegments.reduce(
144
+ (total, segment) => total + segment.attachments.length,
145
+ 0
146
+ ) === (finalAttachments?.length ?? 0)
147
+ const streamAttachmentGroupPosition = messageAttachmentGroupPositionFromStream(
148
+ {
149
+ endOfGroup,
150
+ firstOfGroup,
151
+ groupedByUser,
152
+ }
153
+ )
122
154
 
123
155
  if (isDateSeparatorMessage(message)) {
124
156
  return null
@@ -179,6 +211,19 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
179
211
  )
180
212
  const useAttachmentFooterChatbotTag =
181
213
  isChatbot && isMine && hasRenderableAttachments
214
+ // Route generic Stream attachments through the toolkit renderer only when it
215
+ // can faithfully own the whole message. Quoted replies (attachments are
216
+ // suppressed there), polls, AI-streamed, and chatbot messages fall through to
217
+ // the default branch so their Poll / StreamedMessageText / message-text and
218
+ // chatbot attribution (MessageTag) handling is preserved — the accompanying
219
+ // message text and attribution must never be dropped.
220
+ const canRenderAttachmentsInToolkit =
221
+ hasAttachmentSegments &&
222
+ allAttachmentsRepresented &&
223
+ !message.quoted_message &&
224
+ !poll &&
225
+ !isAIGenerated &&
226
+ !isChatbot
182
227
 
183
228
  return (
184
229
  <>
@@ -279,6 +324,28 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
279
324
  ) : isTipOnly ? (
280
325
  /* Tip-only messages render as a standalone bubble */
281
326
  <MessageTag message={message} standalone />
327
+ ) : canRenderAttachmentsInToolkit ? (
328
+ <div className="str-chat__message-bubble-wrapper">
329
+ <div
330
+ className="str-chat__message-bubble"
331
+ style={{
332
+ background: 'transparent',
333
+ borderRadius: 0,
334
+ overflow: 'visible',
335
+ padding: 0,
336
+ }}
337
+ >
338
+ <StreamAttachmentMessage
339
+ groupPosition={streamAttachmentGroupPosition}
340
+ isMyMessage={isMine}
341
+ message={{
342
+ attachments: finalAttachments,
343
+ text: displayMessage.text,
344
+ }}
345
+ />
346
+ <MessageErrorIcon />
347
+ </div>
348
+ </div>
282
349
  ) : (
283
350
  <div className="str-chat__message-bubble-wrapper">
284
351
  <div className="str-chat__message-bubble">