@linktr.ee/messaging-react 3.4.2-rc-1782453771 → 3.4.2-rc-1782480798

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-ql9pImRa.js → Card-BKi2g6x-.js} +2 -2
  2. package/dist/{Card-ql9pImRa.js.map → Card-BKi2g6x-.js.map} +1 -1
  3. package/dist/{Card-BIa-47Ne.cjs → Card-BMmrCMqJ.cjs} +2 -2
  4. package/dist/{Card-BIa-47Ne.cjs.map → Card-BMmrCMqJ.cjs.map} +1 -1
  5. package/dist/{Card-BaXLsSK2.js → Card-BYu6OBuX.js} +2 -2
  6. package/dist/{Card-BaXLsSK2.js.map → Card-BYu6OBuX.js.map} +1 -1
  7. package/dist/{Card-BkVj6kGN.cjs → Card-DtIjtWy6.cjs} +2 -2
  8. package/dist/{Card-BkVj6kGN.cjs.map → Card-DtIjtWy6.cjs.map} +1 -1
  9. package/dist/{Card-52LnCirC.js → Card-DvE5BCPf.js} +3 -3
  10. package/dist/{Card-52LnCirC.js.map → Card-DvE5BCPf.js.map} +1 -1
  11. package/dist/{Card-CcTyYY9N.cjs → Card-VO-i6CuV.cjs} +2 -2
  12. package/dist/{Card-CcTyYY9N.cjs.map → Card-VO-i6CuV.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-D0ASm7LP.cjs → LockedThumbnail-Cl8uwCxM.cjs} +2 -2
  14. package/dist/{LockedThumbnail-D0ASm7LP.cjs.map → LockedThumbnail-Cl8uwCxM.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-C3YOnS-o.js → LockedThumbnail-DTkd_kEt.js} +2 -2
  16. package/dist/{LockedThumbnail-C3YOnS-o.js.map → LockedThumbnail-DTkd_kEt.js.map} +1 -1
  17. package/dist/index-CSSe9eu7.cjs +2 -0
  18. package/dist/index-CSSe9eu7.cjs.map +1 -0
  19. package/dist/{index-CbWEp3Zs.js → index-IzhF38yj.js} +3447 -3212
  20. package/dist/index-IzhF38yj.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +25 -13
  23. package/dist/index.js +14 -13
  24. package/package.json +2 -2
  25. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +0 -63
  26. package/src/components/ChannelList/CustomChannelPreview.test.tsx +8 -58
  27. package/src/components/ChannelList/CustomChannelPreview.tsx +10 -7
  28. package/src/components/ChannelView.stories.tsx +7 -38
  29. package/src/components/ChannelView.test.tsx +15 -29
  30. package/src/components/ChannelView.tsx +9 -19
  31. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +109 -0
  32. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +408 -0
  33. package/src/components/CustomMessage/index.tsx +42 -0
  34. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +34 -0
  35. package/src/components/CustomMessageInput/index.tsx +23 -1
  36. package/src/components/MessagingShell/index.tsx +2 -0
  37. package/src/hooks/useChannelStar.ts +6 -9
  38. package/src/index.ts +1 -0
  39. package/src/stream-custom-data.ts +1 -16
  40. package/src/types.ts +18 -0
  41. package/dist/index-CbWEp3Zs.js.map +0 -1
  42. package/dist/index-DkqM2z_j.cjs +0 -2
  43. package/dist/index-DkqM2z_j.cjs.map +0 -1
  44. package/src/components/ParticipantBadges.tsx +0 -39
@@ -32,7 +32,6 @@ import CustomTypingIndicator from './CustomTypingIndicator'
32
32
  import { DmAgentEnabledContext } from './CustomTypingIndicator/DmAgentContext'
33
33
  import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState'
34
34
  import { LoadingState } from './MessagingShell/LoadingState'
35
- import { ParticipantBadges } from './ParticipantBadges'
36
35
 
37
36
  const ICON_BTN_CLASS =
38
37
  'size-10 rounded-full hover:bg-[#E5E4E1] flex items-center justify-center transition-colors duration-150 focus-ring'
@@ -137,6 +136,7 @@ const CustomChannelHeader: React.FC<{
137
136
  id={participant?.user?.id || channel.id || 'unknown'}
138
137
  name={participantName}
139
138
  image={participantImage}
139
+ starred={showStarButton && isStarred}
140
140
  dmAgentEnabled={dmAgentEnabled}
141
141
  size={48}
142
142
  />
@@ -148,10 +148,6 @@ const CustomChannelHeader: React.FC<{
148
148
  className="flex max-w-full items-center gap-0.5 rounded text-center text-xs font-medium text-black/90 transition-opacity hover:opacity-70"
149
149
  >
150
150
  <span className="min-w-0 truncate">{participantName}</span>
151
- <ParticipantBadges
152
- participant={participant}
153
- isStarred={isStarred}
154
- />
155
151
  {headerTitleBadges && (
156
152
  <span className="shrink-0">{headerTitleBadges}</span>
157
153
  )}
@@ -160,10 +156,6 @@ const CustomChannelHeader: React.FC<{
160
156
  ) : (
161
157
  <p className="flex max-w-full items-center justify-center gap-1 text-center text-xs font-medium text-black/90">
162
158
  <span className="min-w-0 truncate">{participantName}</span>
163
- <ParticipantBadges
164
- participant={participant}
165
- isStarred={isStarred}
166
- />
167
159
  {headerTitleBadges && (
168
160
  <span className="shrink-0">{headerTitleBadges}</span>
169
161
  )}
@@ -236,6 +228,7 @@ const CustomChannelHeader: React.FC<{
236
228
  id={participant?.user?.id || channel.id || 'unknown'}
237
229
  name={participantName}
238
230
  image={participantImage}
231
+ starred={showStarButton && isStarred}
239
232
  dmAgentEnabled={dmAgentEnabled}
240
233
  size={48}
241
234
  />
@@ -248,10 +241,6 @@ const CustomChannelHeader: React.FC<{
248
241
  className="flex min-w-0 max-w-full items-center gap-1 rounded font-medium text-black/90 transition-opacity hover:opacity-70"
249
242
  >
250
243
  <span className="min-w-0 truncate">{participantName}</span>
251
- <ParticipantBadges
252
- participant={participant}
253
- isStarred={isStarred}
254
- />
255
244
  {headerTitleBadges && (
256
245
  <span className="shrink-0">{headerTitleBadges}</span>
257
246
  )}
@@ -260,10 +249,6 @@ const CustomChannelHeader: React.FC<{
260
249
  ) : (
261
250
  <h1 className="flex min-w-0 items-center gap-1 font-medium text-black/90">
262
251
  <span className="min-w-0 truncate">{participantName}</span>
263
- <ParticipantBadges
264
- participant={participant}
265
- isStarred={isStarred}
266
- />
267
252
  {headerTitleBadges && (
268
253
  <span className="shrink-0">{headerTitleBadges}</span>
269
254
  )}
@@ -355,6 +340,7 @@ const ChannelViewInner: React.FC<{
355
340
  viewerLanguage?: string
356
341
  showChannelInfo?: boolean
357
342
  onParticipantNameClick?: () => void
343
+ composerInput?: ChannelViewProps['composerInput']
358
344
  }> = ({
359
345
  onBack,
360
346
  showBackButton,
@@ -382,6 +368,7 @@ const ChannelViewInner: React.FC<{
382
368
  viewerLanguage,
383
369
  showChannelInfo = true,
384
370
  onParticipantNameClick,
371
+ composerInput,
385
372
  }) => {
386
373
  const { channel } = useChannelStateContext()
387
374
 
@@ -403,10 +390,10 @@ const ChannelViewInner: React.FC<{
403
390
  }, [channel._client?.userID, channel.state?.members])
404
391
 
405
392
  const currentUserIsAccount =
406
- currentMember?.user?.is_account ??
393
+ (currentMember?.user as { is_account?: boolean } | undefined)?.is_account ??
407
394
  (currentMember as { is_account?: boolean } | undefined)?.is_account
408
395
  const participantIsAccount =
409
- participant?.user?.is_account ??
396
+ (participant?.user as { is_account?: boolean } | undefined)?.is_account ??
410
397
  (participant as { is_account?: boolean } | undefined)?.is_account
411
398
 
412
399
  const showDmAgentHeader =
@@ -498,6 +485,7 @@ const ChannelViewInner: React.FC<{
498
485
  renderFooter={() => renderMessageInputFooter?.(channel)}
499
486
  disabled={composerDisabled}
500
487
  disabledReason={composerDisabledReason}
488
+ composerInput={composerInput}
501
489
  />
502
490
  </Window>
503
491
  </WithComponents>
@@ -544,6 +532,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
544
532
  viewerLanguage,
545
533
  showChannelInfo = true,
546
534
  onParticipantNameClick,
535
+ composerInput,
547
536
  }) => {
548
537
  // Custom send message handler that:
549
538
  // 1. Applies messageMetadata if provided
@@ -658,6 +647,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
658
647
  viewerLanguage={viewerLanguage}
659
648
  showChannelInfo={showChannelInfo}
660
649
  onParticipantNameClick={onParticipantNameClick}
650
+ composerInput={composerInput}
661
651
  />
662
652
  </Channel>
663
653
  </DmAgentEnabledContext.Provider>
@@ -0,0 +1,109 @@
1
+ import React from 'react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { renderWithProviders, screen } from '../../test/utils'
5
+
6
+ import StreamAttachmentMessage, {
7
+ linkCaptionDuplicatesAttachmentUrl,
8
+ } from './StreamAttachmentMessage'
9
+
10
+ describe('linkCaptionDuplicatesAttachmentUrl', () => {
11
+ it('suppresses captions that are just the link plus punctuation', () => {
12
+ expect(
13
+ linkCaptionDuplicatesAttachmentUrl(
14
+ 'https://tr.ee/abc !',
15
+ 'https://tr.ee/abc'
16
+ )
17
+ ).toBe(true)
18
+ })
19
+
20
+ it('keeps captions that add emoji after the link', () => {
21
+ expect(
22
+ linkCaptionDuplicatesAttachmentUrl(
23
+ 'https://tr.ee/abc 😎',
24
+ 'https://tr.ee/abc'
25
+ )
26
+ ).toBe(false)
27
+ })
28
+ })
29
+
30
+ describe('StreamAttachmentMessage', () => {
31
+ it('renders a sent link card and suppresses a URL-only caption', () => {
32
+ renderWithProviders(
33
+ <StreamAttachmentMessage
34
+ groupPosition="single"
35
+ isMyMessage={true}
36
+ message={{
37
+ attachments: [
38
+ {
39
+ type: 'link',
40
+ og_scrape_url: 'https://linktr.ee/someone',
41
+ title: 'My Linktree',
42
+ text: 'Check out my links',
43
+ image_url: 'https://cdn.example.com/thumb.jpg',
44
+ },
45
+ ],
46
+ text: 'https://linktr.ee/someone !',
47
+ }}
48
+ />
49
+ )
50
+
51
+ expect(
52
+ screen.getByRole('link', { name: /my linktree/i })
53
+ ).toHaveAttribute('href', 'https://linktr.ee/someone')
54
+ expect(screen.queryByText('https://linktr.ee/someone !')).toBeNull()
55
+ })
56
+
57
+ it('keeps an emoji-only caption instead of dropping it', () => {
58
+ renderWithProviders(
59
+ <StreamAttachmentMessage
60
+ groupPosition="single"
61
+ isMyMessage={false}
62
+ message={{
63
+ attachments: [
64
+ {
65
+ type: 'link',
66
+ og_scrape_url: 'https://linktr.ee/someone',
67
+ title: 'My Linktree',
68
+ },
69
+ ],
70
+ text: 'https://linktr.ee/someone 😎',
71
+ }}
72
+ />
73
+ )
74
+
75
+ expect(screen.getByText('https://linktr.ee/someone 😎')).toBeInTheDocument()
76
+ })
77
+
78
+ it('renders mixed link and media attachments in order', () => {
79
+ const { container } = renderWithProviders(
80
+ <StreamAttachmentMessage
81
+ groupPosition="single"
82
+ isMyMessage={true}
83
+ message={{
84
+ attachments: [
85
+ {
86
+ type: 'link',
87
+ og_scrape_url: 'https://linktr.ee/someone',
88
+ title: 'My Linktree',
89
+ },
90
+ {
91
+ type: 'image',
92
+ image_url: 'https://cdn.example.com/photo.jpg',
93
+ title: 'Vacation',
94
+ },
95
+ ],
96
+ text: 'caption',
97
+ }}
98
+ />
99
+ )
100
+
101
+ const link = screen.getByRole('link', { name: /my linktree/i })
102
+ const imageBubble = screen.getByTestId('image-attachment')
103
+
104
+ expect(link.compareDocumentPosition(imageBubble)).toBe(
105
+ Node.DOCUMENT_POSITION_FOLLOWING
106
+ )
107
+ expect(container).toHaveTextContent('caption')
108
+ })
109
+ })
@@ -0,0 +1,408 @@
1
+ import React from 'react'
2
+ import type { Attachment } from 'stream-chat'
3
+
4
+ import LinkAttachment from '../LinkAttachment'
5
+ import MessageAttachment from '../MessageAttachment'
6
+ import type { BubbleGroupPosition } from '../MessageAttachment/types'
7
+
8
+ type StreamMediaKind = 'image' | 'video' | 'audio' | 'pdf' | 'file'
9
+
10
+ export type AttachmentSegment =
11
+ | { type: 'link'; attachments: Attachment[] }
12
+ | { type: 'media'; kind: StreamMediaKind; attachments: Attachment[] }
13
+
14
+ type StreamThreadMessage = {
15
+ attachments?: Attachment[]
16
+ text?: string
17
+ }
18
+
19
+ const SENT_CAPTION_CLASS =
20
+ 'mt-[2px] max-w-[280px] whitespace-pre-wrap break-words rounded-[18px] bg-[#121110] px-4 py-2 text-white'
21
+ const RECEIVED_CAPTION_CLASS =
22
+ 'mt-[2px] max-w-[280px] whitespace-pre-wrap break-words rounded-[18px] bg-[#e9eaed] px-4 py-2 text-[#080707]'
23
+
24
+ function trimToUndefined(value?: string | null): string | undefined {
25
+ const trimmed = value?.trim()
26
+ return trimmed ? trimmed : undefined
27
+ }
28
+
29
+ export function normalizeLinkUrl(url: string): string {
30
+ return url
31
+ .trim()
32
+ .replace(/^https?:\/\//i, '')
33
+ .replace(/\/+$/, '')
34
+ .toLowerCase()
35
+ }
36
+
37
+ export function linkCaptionDuplicatesAttachmentUrl(
38
+ caption: string | undefined,
39
+ attachmentUrl?: string
40
+ ): boolean {
41
+ if (!caption?.trim() || !attachmentUrl?.trim()) {
42
+ return false
43
+ }
44
+
45
+ const normalizedCaption = normalizeLinkUrl(caption)
46
+ const normalizedUrl = normalizeLinkUrl(attachmentUrl)
47
+
48
+ if (normalizedCaption === normalizedUrl) {
49
+ return true
50
+ }
51
+
52
+ if (!normalizedCaption.includes(normalizedUrl)) {
53
+ return false
54
+ }
55
+
56
+ const remainder = normalizedCaption.split(normalizedUrl).join('')
57
+ return !/[^\p{P}\p{Z}\p{C}]/u.test(remainder)
58
+ }
59
+
60
+ export function linkCardPropsFromStreamAttachment(a: Attachment) {
61
+ const url = trimToUndefined(a.og_scrape_url) ?? trimToUndefined(a.title_link)
62
+ const thumbnailUrl =
63
+ trimToUndefined((a as { image_url?: string }).image_url) ??
64
+ trimToUndefined((a as { thumb_url?: string }).thumb_url)
65
+
66
+ return {
67
+ title: trimToUndefined((a as { title?: string }).title),
68
+ description: trimToUndefined((a as { text?: string }).text),
69
+ url,
70
+ thumbnailUrl,
71
+ layout: thumbnailUrl ? 'featured' : 'classic',
72
+ mimeType: trimToUndefined(a.mime_type),
73
+ } as const
74
+ }
75
+
76
+ export function getAttachmentMediaKind(
77
+ attachment: Attachment
78
+ ): StreamMediaKind | null {
79
+ if (isLinkAttachment(attachment)) return null
80
+
81
+ if (attachment.type === 'image') {
82
+ const imageUrl = (attachment as { image_url?: string }).image_url
83
+ if (imageUrl || attachment.asset_url) return 'image'
84
+ return null
85
+ }
86
+
87
+ if (attachment.type === 'video' && attachment.asset_url) return 'video'
88
+ if (attachment.type === 'audio' && attachment.asset_url) return 'audio'
89
+
90
+ if (attachment.type === 'file' && attachment.asset_url) {
91
+ if (attachment.mime_type === 'application/pdf') return 'pdf'
92
+ if (attachment.mime_type?.startsWith('audio/')) return 'audio'
93
+ return 'file'
94
+ }
95
+
96
+ return null
97
+ }
98
+
99
+ export function isLinkAttachment(a: Attachment): boolean {
100
+ const ogScrapeUrl = trimToUndefined(a.og_scrape_url)
101
+ return a.type === 'link' || (!!ogScrapeUrl && !a.asset_url)
102
+ }
103
+
104
+ export function buildOrderedAttachmentSegments(
105
+ attachments: Attachment[] | undefined
106
+ ): AttachmentSegment[] {
107
+ if (!attachments?.length) return []
108
+
109
+ const segments: AttachmentSegment[] = []
110
+
111
+ const pushOrMergeMedia = (kind: StreamMediaKind, attachment: Attachment) => {
112
+ const last = segments[segments.length - 1]
113
+ if (last?.type === 'media' && last.kind === kind) {
114
+ last.attachments.push(attachment)
115
+ return
116
+ }
117
+
118
+ segments.push({ type: 'media', kind, attachments: [attachment] })
119
+ }
120
+
121
+ const pushOrMergeLink = (attachment: Attachment) => {
122
+ const last = segments[segments.length - 1]
123
+ if (last?.type === 'link') {
124
+ last.attachments.push(attachment)
125
+ return
126
+ }
127
+
128
+ segments.push({ type: 'link', attachments: [attachment] })
129
+ }
130
+
131
+ for (const attachment of attachments) {
132
+ if (isLinkAttachment(attachment)) {
133
+ pushOrMergeLink(attachment)
134
+ continue
135
+ }
136
+
137
+ const kind = getAttachmentMediaKind(attachment)
138
+ if (!kind) continue
139
+
140
+ pushOrMergeMedia(kind, attachment)
141
+ }
142
+
143
+ return segments
144
+ }
145
+
146
+ export function countAttachmentSegmentBubbles(
147
+ segments: AttachmentSegment[]
148
+ ): number {
149
+ let count = 0
150
+
151
+ for (const segment of segments) {
152
+ count += segment.type === 'link' ? segment.attachments.length : 1
153
+ }
154
+
155
+ return count
156
+ }
157
+
158
+ export function countMediaSegmentBubbles(segments: AttachmentSegment[]): number {
159
+ return segments.filter((segment) => segment.type === 'media').length
160
+ }
161
+
162
+ export function bubblePositionInRun(
163
+ index: number,
164
+ total: number,
165
+ streamPosition: BubbleGroupPosition
166
+ ): BubbleGroupPosition {
167
+ if (total <= 1) return streamPosition
168
+ if (index === 0) return 'first'
169
+ if (index === total - 1) return 'end'
170
+ return 'middle'
171
+ }
172
+
173
+ function imageSrcFromAttachment(attachment: Attachment): string {
174
+ return (
175
+ trimToUndefined((attachment as { image_url?: string }).image_url) ??
176
+ trimToUndefined(attachment.asset_url) ??
177
+ ''
178
+ )
179
+ }
180
+
181
+ function messageAttachmentNamespaceForKind(kind: StreamMediaKind) {
182
+ switch (kind) {
183
+ case 'image':
184
+ return MessageAttachment.Image
185
+ case 'video':
186
+ return MessageAttachment.Video
187
+ case 'audio':
188
+ return MessageAttachment.Audio
189
+ case 'pdf':
190
+ return MessageAttachment.Pdf
191
+ default:
192
+ return MessageAttachment.File
193
+ }
194
+ }
195
+
196
+ function buildMediaClusterRenderProps(
197
+ kind: StreamMediaKind,
198
+ attachments: Attachment[]
199
+ ): Record<string, unknown> {
200
+ if (attachments.length > 1) {
201
+ switch (kind) {
202
+ case 'image':
203
+ return {
204
+ items: attachments.map((attachment) => ({
205
+ src: imageSrcFromAttachment(attachment),
206
+ alt: trimToUndefined((attachment as { title?: string }).title),
207
+ })),
208
+ }
209
+ case 'video':
210
+ return {
211
+ items: attachments.map((attachment) => ({
212
+ src: trimToUndefined(attachment.asset_url) ?? '',
213
+ poster: trimToUndefined(
214
+ (attachment as { thumb_url?: string }).thumb_url
215
+ ),
216
+ mimeType: trimToUndefined(attachment.mime_type),
217
+ })),
218
+ }
219
+ case 'audio':
220
+ return {
221
+ items: attachments.map((attachment) => ({
222
+ src: trimToUndefined(attachment.asset_url) ?? '',
223
+ mimeType:
224
+ trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
225
+ filename: trimToUndefined(
226
+ (attachment as { title?: string }).title
227
+ ),
228
+ })),
229
+ }
230
+ case 'pdf':
231
+ return {
232
+ items: attachments.map((attachment) => ({
233
+ src: trimToUndefined(attachment.asset_url) ?? '',
234
+ filename: trimToUndefined(
235
+ (attachment as { title?: string }).title
236
+ ),
237
+ fileSize: (attachment as { file_size?: number }).file_size,
238
+ })),
239
+ }
240
+ case 'file':
241
+ return {
242
+ items: attachments.map((attachment) => ({
243
+ src: trimToUndefined(attachment.asset_url) ?? '',
244
+ filename: trimToUndefined(
245
+ (attachment as { title?: string }).title
246
+ ),
247
+ fileSize: (attachment as { file_size?: number }).file_size,
248
+ mimeType:
249
+ trimToUndefined(attachment.mime_type) ??
250
+ 'application/octet-stream',
251
+ })),
252
+ }
253
+ }
254
+ }
255
+
256
+ const attachment = attachments[0]
257
+ if (!attachment) return {}
258
+
259
+ switch (kind) {
260
+ case 'image':
261
+ return {
262
+ src: imageSrcFromAttachment(attachment),
263
+ alt: trimToUndefined((attachment as { title?: string }).title),
264
+ }
265
+ case 'video':
266
+ return {
267
+ src: trimToUndefined(attachment.asset_url) ?? '',
268
+ poster: trimToUndefined((attachment as { thumb_url?: string }).thumb_url),
269
+ mimeType: trimToUndefined(attachment.mime_type),
270
+ }
271
+ case 'audio':
272
+ return {
273
+ src: trimToUndefined(attachment.asset_url) ?? '',
274
+ mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
275
+ filename: trimToUndefined((attachment as { title?: string }).title),
276
+ }
277
+ case 'pdf':
278
+ return {
279
+ src: trimToUndefined(attachment.asset_url) ?? '',
280
+ filename: trimToUndefined((attachment as { title?: string }).title),
281
+ fileSize: (attachment as { file_size?: number }).file_size,
282
+ }
283
+ case 'file':
284
+ return {
285
+ src: trimToUndefined(attachment.asset_url) ?? '',
286
+ filename: trimToUndefined((attachment as { title?: string }).title),
287
+ fileSize: (attachment as { file_size?: number }).file_size,
288
+ mimeType:
289
+ trimToUndefined(attachment.mime_type) ?? 'application/octet-stream',
290
+ }
291
+ }
292
+ }
293
+
294
+ function MediaClusterBubble({
295
+ groupPosition,
296
+ isMyMessage,
297
+ segment,
298
+ text,
299
+ }: {
300
+ groupPosition: BubbleGroupPosition
301
+ isMyMessage: boolean
302
+ segment: Extract<AttachmentSegment, { type: 'media' }>
303
+ text?: string
304
+ }) {
305
+ const TypeNamespace = messageAttachmentNamespaceForKind(segment.kind)
306
+ const props = {
307
+ ...buildMediaClusterRenderProps(segment.kind, segment.attachments),
308
+ groupPosition,
309
+ ...(text ? { text } : {}),
310
+ }
311
+
312
+ if (isMyMessage) {
313
+ return <TypeNamespace.Sent {...props} />
314
+ }
315
+
316
+ return <TypeNamespace.Received {...props} />
317
+ }
318
+
319
+ export interface StreamAttachmentMessageProps {
320
+ groupPosition: BubbleGroupPosition
321
+ isMyMessage: boolean
322
+ message: StreamThreadMessage
323
+ }
324
+
325
+ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
326
+ groupPosition,
327
+ isMyMessage,
328
+ message,
329
+ }) => {
330
+ const segments = buildOrderedAttachmentSegments(message.attachments)
331
+ if (segments.length === 0) return null
332
+
333
+ const totalBubbles = countAttachmentSegmentBubbles(segments)
334
+ const totalMediaBubbles = countMediaSegmentBubbles(segments)
335
+ const rawCaption = message.text?.trim()
336
+ const linkSegment = segments.find((segment) => segment.type === 'link')
337
+ const primaryLinkUrl =
338
+ linkSegment?.type === 'link'
339
+ ? linkCardPropsFromStreamAttachment(linkSegment.attachments[0] ?? {}).url
340
+ : undefined
341
+ const caption =
342
+ rawCaption &&
343
+ linkCaptionDuplicatesAttachmentUrl(rawCaption, primaryLinkUrl)
344
+ ? undefined
345
+ : rawCaption
346
+
347
+ let bubbleIndex = 0
348
+ let mediaBubbleIndex = 0
349
+ const rows: React.ReactNode[] = []
350
+
351
+ const pushLinkRow = (attachment: Attachment, key: string) => {
352
+ const isLastBubble = bubbleIndex === totalBubbles - 1
353
+ bubbleIndex += 1
354
+
355
+ const Card = isMyMessage ? LinkAttachment.Sent : LinkAttachment.Received
356
+
357
+ rows.push(
358
+ <React.Fragment key={key}>
359
+ <Card {...linkCardPropsFromStreamAttachment(attachment)} />
360
+ {isLastBubble && caption ? (
361
+ <div className={isMyMessage ? SENT_CAPTION_CLASS : RECEIVED_CAPTION_CLASS}>
362
+ {caption}
363
+ </div>
364
+ ) : null}
365
+ </React.Fragment>
366
+ )
367
+ }
368
+
369
+ const pushMediaCluster = (
370
+ segment: Extract<AttachmentSegment, { type: 'media' }>,
371
+ key: string
372
+ ) => {
373
+ const isLastBubble = bubbleIndex === totalBubbles - 1
374
+ const position = bubblePositionInRun(
375
+ mediaBubbleIndex,
376
+ totalMediaBubbles,
377
+ groupPosition
378
+ )
379
+
380
+ bubbleIndex += 1
381
+ mediaBubbleIndex += 1
382
+
383
+ rows.push(
384
+ <MediaClusterBubble
385
+ key={key}
386
+ groupPosition={position}
387
+ isMyMessage={isMyMessage}
388
+ segment={segment}
389
+ text={isLastBubble ? caption : undefined}
390
+ />
391
+ )
392
+ }
393
+
394
+ segments.forEach((segment, segmentIndex) => {
395
+ if (segment.type === 'link') {
396
+ segment.attachments.forEach((attachment, attachmentIndex) => {
397
+ pushLinkRow(attachment, `link-${segmentIndex}-${attachmentIndex}`)
398
+ })
399
+ return
400
+ }
401
+
402
+ pushMediaCluster(segment, `media-${segmentIndex}`)
403
+ })
404
+
405
+ return <>{rows}</>
406
+ }
407
+
408
+ 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,26 @@ 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
+ const hasLinkAttachmentSegments = attachmentSegments.some(
133
+ (segment) => segment.type === 'link'
134
+ )
135
+ const streamAttachmentGroupPosition = messageAttachmentGroupPositionFromStream(
136
+ {
137
+ endOfGroup,
138
+ firstOfGroup,
139
+ groupedByUser,
140
+ }
141
+ )
122
142
 
123
143
  if (isDateSeparatorMessage(message)) {
124
144
  return null
@@ -278,6 +298,28 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
278
298
  ) : isTipOnly ? (
279
299
  /* Tip-only messages render as a standalone bubble */
280
300
  <MessageTag message={message} standalone />
301
+ ) : hasLinkAttachmentSegments ? (
302
+ <div className="str-chat__message-bubble-wrapper">
303
+ <div
304
+ className="str-chat__message-bubble"
305
+ style={{
306
+ background: 'transparent',
307
+ borderRadius: 0,
308
+ overflow: 'visible',
309
+ padding: 0,
310
+ }}
311
+ >
312
+ <StreamAttachmentMessage
313
+ groupPosition={streamAttachmentGroupPosition}
314
+ isMyMessage={isMine}
315
+ message={{
316
+ attachments: finalAttachments,
317
+ text: displayMessage.text,
318
+ }}
319
+ />
320
+ <MessageErrorIcon />
321
+ </div>
322
+ </div>
281
323
  ) : (
282
324
  <div className="str-chat__message-bubble-wrapper">
283
325
  <div className="str-chat__message-bubble">