@linktr.ee/messaging-react 3.4.2-rc-1782454787 → 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.
- package/dist/{Card-BQZcPeQn.js → Card-BKi2g6x-.js} +2 -2
- package/dist/{Card-BQZcPeQn.js.map → Card-BKi2g6x-.js.map} +1 -1
- package/dist/{Card-CSqZXHCM.cjs → Card-BMmrCMqJ.cjs} +2 -2
- package/dist/{Card-CSqZXHCM.cjs.map → Card-BMmrCMqJ.cjs.map} +1 -1
- package/dist/{Card-U9LAeCEA.js → Card-BYu6OBuX.js} +2 -2
- package/dist/{Card-U9LAeCEA.js.map → Card-BYu6OBuX.js.map} +1 -1
- package/dist/{Card-Cp0Ioui8.cjs → Card-DtIjtWy6.cjs} +2 -2
- package/dist/{Card-Cp0Ioui8.cjs.map → Card-DtIjtWy6.cjs.map} +1 -1
- package/dist/{Card-DFaRAFV1.js → Card-DvE5BCPf.js} +3 -3
- package/dist/{Card-DFaRAFV1.js.map → Card-DvE5BCPf.js.map} +1 -1
- package/dist/{Card-DWmbH7oz.cjs → Card-VO-i6CuV.cjs} +2 -2
- package/dist/{Card-DWmbH7oz.cjs.map → Card-VO-i6CuV.cjs.map} +1 -1
- package/dist/{LockedThumbnail-nLWi4Kxt.cjs → LockedThumbnail-Cl8uwCxM.cjs} +2 -2
- package/dist/{LockedThumbnail-nLWi4Kxt.cjs.map → LockedThumbnail-Cl8uwCxM.cjs.map} +1 -1
- package/dist/{LockedThumbnail-JOZFwdjw.js → LockedThumbnail-DTkd_kEt.js} +2 -2
- package/dist/{LockedThumbnail-JOZFwdjw.js.map → LockedThumbnail-DTkd_kEt.js.map} +1 -1
- package/dist/index-CSSe9eu7.cjs +2 -0
- package/dist/index-CSSe9eu7.cjs.map +1 -0
- package/dist/{index-DyG_ZuPp.js → index-IzhF38yj.js} +3440 -3205
- package/dist/index-IzhF38yj.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +25 -13
- package/dist/index.js +14 -13
- package/package.json +2 -2
- package/src/components/ChannelList/CustomChannelPreview.stories.tsx +0 -63
- package/src/components/ChannelList/CustomChannelPreview.test.tsx +8 -58
- package/src/components/ChannelList/CustomChannelPreview.tsx +12 -7
- package/src/components/ChannelView.stories.tsx +7 -38
- package/src/components/ChannelView.test.tsx +27 -44
- package/src/components/ChannelView.tsx +9 -19
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +109 -0
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +408 -0
- package/src/components/CustomMessage/index.tsx +42 -0
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +34 -0
- package/src/components/CustomMessageInput/index.tsx +23 -1
- package/src/components/MessagingShell/index.tsx +2 -0
- package/src/hooks/useChannelStar.ts +6 -9
- package/src/index.ts +1 -0
- package/src/stream-custom-data.ts +1 -16
- package/src/types.ts +18 -0
- package/dist/index-BSQPos2Q.cjs +0 -2
- package/dist/index-BSQPos2Q.cjs.map +0 -1
- package/dist/index-DyG_ZuPp.js.map +0 -1
- package/src/components/ParticipantBadges.tsx +0 -42
|
@@ -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
|
-
channel={channel}
|
|
153
|
-
participant={participant}
|
|
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
|
-
channel={channel}
|
|
165
|
-
participant={participant}
|
|
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
|
-
channel={channel}
|
|
253
|
-
participant={participant}
|
|
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
|
-
channel={channel}
|
|
265
|
-
participant={participant}
|
|
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">
|