@linktr.ee/messaging-react 3.5.7-rc-1783071111 → 3.6.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-rblNjC58.js → Card-COTwOzVb.js} +2 -2
- package/dist/{Card-rblNjC58.js.map → Card-COTwOzVb.js.map} +1 -1
- package/dist/{Card-DYe76Xh3.cjs → Card-Cb4yfxFK.cjs} +2 -2
- package/dist/{Card-DYe76Xh3.cjs.map → Card-Cb4yfxFK.cjs.map} +1 -1
- package/dist/{Card-CAJnEq1K.cjs → Card-DmjLrV64.cjs} +2 -2
- package/dist/{Card-CAJnEq1K.cjs.map → Card-DmjLrV64.cjs.map} +1 -1
- package/dist/{Card-D_N5hclc.cjs → Card-DzT94KFC.cjs} +2 -2
- package/dist/{Card-D_N5hclc.cjs.map → Card-DzT94KFC.cjs.map} +1 -1
- package/dist/{Card-B5lE5A2D.js → Card-J8f28GLS.js} +3 -3
- package/dist/{Card-B5lE5A2D.js.map → Card-J8f28GLS.js.map} +1 -1
- package/dist/{Card-MBp3Wg6r.js → Card-ra-g17Ur.js} +2 -2
- package/dist/{Card-MBp3Wg6r.js.map → Card-ra-g17Ur.js.map} +1 -1
- package/dist/{LockedThumbnail-BqyPPVrO.js → LockedThumbnail-CVq5LPXE.js} +2 -2
- package/dist/{LockedThumbnail-BqyPPVrO.js.map → LockedThumbnail-CVq5LPXE.js.map} +1 -1
- package/dist/{LockedThumbnail-C7PpVZQ0.cjs → LockedThumbnail-Ds_vBmYm.cjs} +2 -2
- package/dist/{LockedThumbnail-C7PpVZQ0.cjs.map → LockedThumbnail-Ds_vBmYm.cjs.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/{index-BXzwYXLT.js → index-D1XVCH1o.js} +3415 -3133
- package/dist/index-D1XVCH1o.js.map +1 -0
- package/dist/index-hvrSZScU.cjs +2 -0
- package/dist/index-hvrSZScU.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/components/CustomMessage/StreamAttachmentMessage.stories.tsx +178 -0
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +153 -0
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +411 -0
- package/src/components/CustomMessage/index.tsx +65 -0
- package/src/styles.css +0 -18
- package/dist/index-BXzwYXLT.js.map +0 -1
- package/dist/index-BflWZq0e.cjs +0 -2
- package/dist/index-BflWZq0e.cjs.map +0 -1
|
@@ -0,0 +1,411 @@
|
|
|
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
|
+
// Playable clip embedded in a link preview — lets LinkAttachment render its
|
|
72
|
+
// native video/audio player in the hero instead of a static thumbnail.
|
|
73
|
+
sourceUrl: trimToUndefined(a.asset_url),
|
|
74
|
+
layout: thumbnailUrl ? 'featured' : 'classic',
|
|
75
|
+
mimeType: trimToUndefined(a.mime_type),
|
|
76
|
+
} as const
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getAttachmentMediaKind(
|
|
80
|
+
attachment: Attachment
|
|
81
|
+
): StreamMediaKind | null {
|
|
82
|
+
if (isLinkAttachment(attachment)) return null
|
|
83
|
+
|
|
84
|
+
if (attachment.type === 'image') {
|
|
85
|
+
const imageUrl = (attachment as { image_url?: string }).image_url
|
|
86
|
+
if (imageUrl || attachment.asset_url) return 'image'
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (attachment.type === 'video' && attachment.asset_url) return 'video'
|
|
91
|
+
if (attachment.type === 'audio' && attachment.asset_url) return 'audio'
|
|
92
|
+
|
|
93
|
+
if (attachment.type === 'file' && attachment.asset_url) {
|
|
94
|
+
if (attachment.mime_type === 'application/pdf') return 'pdf'
|
|
95
|
+
if (attachment.mime_type?.startsWith('audio/')) return 'audio'
|
|
96
|
+
return 'file'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isLinkAttachment(a: Attachment): boolean {
|
|
103
|
+
const ogScrapeUrl = trimToUndefined(a.og_scrape_url)
|
|
104
|
+
return a.type === 'link' || (!!ogScrapeUrl && !a.asset_url)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function buildOrderedAttachmentSegments(
|
|
108
|
+
attachments: Attachment[] | undefined
|
|
109
|
+
): AttachmentSegment[] {
|
|
110
|
+
if (!attachments?.length) return []
|
|
111
|
+
|
|
112
|
+
const segments: AttachmentSegment[] = []
|
|
113
|
+
|
|
114
|
+
const pushOrMergeMedia = (kind: StreamMediaKind, attachment: Attachment) => {
|
|
115
|
+
const last = segments[segments.length - 1]
|
|
116
|
+
if (last?.type === 'media' && last.kind === kind) {
|
|
117
|
+
last.attachments.push(attachment)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
segments.push({ type: 'media', kind, attachments: [attachment] })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const pushOrMergeLink = (attachment: Attachment) => {
|
|
125
|
+
const last = segments[segments.length - 1]
|
|
126
|
+
if (last?.type === 'link') {
|
|
127
|
+
last.attachments.push(attachment)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
segments.push({ type: 'link', attachments: [attachment] })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const attachment of attachments) {
|
|
135
|
+
if (isLinkAttachment(attachment)) {
|
|
136
|
+
pushOrMergeLink(attachment)
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const kind = getAttachmentMediaKind(attachment)
|
|
141
|
+
if (!kind) continue
|
|
142
|
+
|
|
143
|
+
pushOrMergeMedia(kind, attachment)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return segments
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function countAttachmentSegmentBubbles(
|
|
150
|
+
segments: AttachmentSegment[]
|
|
151
|
+
): number {
|
|
152
|
+
let count = 0
|
|
153
|
+
|
|
154
|
+
for (const segment of segments) {
|
|
155
|
+
count += segment.type === 'link' ? segment.attachments.length : 1
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return count
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function countMediaSegmentBubbles(segments: AttachmentSegment[]): number {
|
|
162
|
+
return segments.filter((segment) => segment.type === 'media').length
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function bubblePositionInRun(
|
|
166
|
+
index: number,
|
|
167
|
+
total: number,
|
|
168
|
+
streamPosition: BubbleGroupPosition
|
|
169
|
+
): BubbleGroupPosition {
|
|
170
|
+
if (total <= 1) return streamPosition
|
|
171
|
+
if (index === 0) return 'first'
|
|
172
|
+
if (index === total - 1) return 'end'
|
|
173
|
+
return 'middle'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function imageSrcFromAttachment(attachment: Attachment): string {
|
|
177
|
+
return (
|
|
178
|
+
trimToUndefined((attachment as { image_url?: string }).image_url) ??
|
|
179
|
+
trimToUndefined(attachment.asset_url) ??
|
|
180
|
+
''
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function messageAttachmentNamespaceForKind(kind: StreamMediaKind) {
|
|
185
|
+
switch (kind) {
|
|
186
|
+
case 'image':
|
|
187
|
+
return MessageAttachment.Image
|
|
188
|
+
case 'video':
|
|
189
|
+
return MessageAttachment.Video
|
|
190
|
+
case 'audio':
|
|
191
|
+
return MessageAttachment.Audio
|
|
192
|
+
case 'pdf':
|
|
193
|
+
return MessageAttachment.Pdf
|
|
194
|
+
default:
|
|
195
|
+
return MessageAttachment.File
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function buildMediaClusterRenderProps(
|
|
200
|
+
kind: StreamMediaKind,
|
|
201
|
+
attachments: Attachment[]
|
|
202
|
+
): Record<string, unknown> {
|
|
203
|
+
if (attachments.length > 1) {
|
|
204
|
+
switch (kind) {
|
|
205
|
+
case 'image':
|
|
206
|
+
return {
|
|
207
|
+
items: attachments.map((attachment) => ({
|
|
208
|
+
src: imageSrcFromAttachment(attachment),
|
|
209
|
+
alt: trimToUndefined((attachment as { title?: string }).title),
|
|
210
|
+
})),
|
|
211
|
+
}
|
|
212
|
+
case 'video':
|
|
213
|
+
return {
|
|
214
|
+
items: attachments.map((attachment) => ({
|
|
215
|
+
src: trimToUndefined(attachment.asset_url) ?? '',
|
|
216
|
+
poster: trimToUndefined(
|
|
217
|
+
(attachment as { thumb_url?: string }).thumb_url
|
|
218
|
+
),
|
|
219
|
+
mimeType: trimToUndefined(attachment.mime_type),
|
|
220
|
+
})),
|
|
221
|
+
}
|
|
222
|
+
case 'audio':
|
|
223
|
+
return {
|
|
224
|
+
items: attachments.map((attachment) => ({
|
|
225
|
+
src: trimToUndefined(attachment.asset_url) ?? '',
|
|
226
|
+
mimeType:
|
|
227
|
+
trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
|
|
228
|
+
filename: trimToUndefined(
|
|
229
|
+
(attachment as { title?: string }).title
|
|
230
|
+
),
|
|
231
|
+
})),
|
|
232
|
+
}
|
|
233
|
+
case 'pdf':
|
|
234
|
+
return {
|
|
235
|
+
items: attachments.map((attachment) => ({
|
|
236
|
+
src: trimToUndefined(attachment.asset_url) ?? '',
|
|
237
|
+
filename: trimToUndefined(
|
|
238
|
+
(attachment as { title?: string }).title
|
|
239
|
+
),
|
|
240
|
+
fileSize: (attachment as { file_size?: number }).file_size,
|
|
241
|
+
})),
|
|
242
|
+
}
|
|
243
|
+
case 'file':
|
|
244
|
+
return {
|
|
245
|
+
items: attachments.map((attachment) => ({
|
|
246
|
+
src: trimToUndefined(attachment.asset_url) ?? '',
|
|
247
|
+
filename: trimToUndefined(
|
|
248
|
+
(attachment as { title?: string }).title
|
|
249
|
+
),
|
|
250
|
+
fileSize: (attachment as { file_size?: number }).file_size,
|
|
251
|
+
mimeType:
|
|
252
|
+
trimToUndefined(attachment.mime_type) ??
|
|
253
|
+
'application/octet-stream',
|
|
254
|
+
})),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const attachment = attachments[0]
|
|
260
|
+
if (!attachment) return {}
|
|
261
|
+
|
|
262
|
+
switch (kind) {
|
|
263
|
+
case 'image':
|
|
264
|
+
return {
|
|
265
|
+
src: imageSrcFromAttachment(attachment),
|
|
266
|
+
alt: trimToUndefined((attachment as { title?: string }).title),
|
|
267
|
+
}
|
|
268
|
+
case 'video':
|
|
269
|
+
return {
|
|
270
|
+
src: trimToUndefined(attachment.asset_url) ?? '',
|
|
271
|
+
poster: trimToUndefined((attachment as { thumb_url?: string }).thumb_url),
|
|
272
|
+
mimeType: trimToUndefined(attachment.mime_type),
|
|
273
|
+
}
|
|
274
|
+
case 'audio':
|
|
275
|
+
return {
|
|
276
|
+
src: trimToUndefined(attachment.asset_url) ?? '',
|
|
277
|
+
mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
|
|
278
|
+
filename: trimToUndefined((attachment as { title?: string }).title),
|
|
279
|
+
}
|
|
280
|
+
case 'pdf':
|
|
281
|
+
return {
|
|
282
|
+
src: trimToUndefined(attachment.asset_url) ?? '',
|
|
283
|
+
filename: trimToUndefined((attachment as { title?: string }).title),
|
|
284
|
+
fileSize: (attachment as { file_size?: number }).file_size,
|
|
285
|
+
}
|
|
286
|
+
case 'file':
|
|
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
|
+
mimeType:
|
|
292
|
+
trimToUndefined(attachment.mime_type) ?? 'application/octet-stream',
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function MediaClusterBubble({
|
|
298
|
+
groupPosition,
|
|
299
|
+
isMyMessage,
|
|
300
|
+
segment,
|
|
301
|
+
text,
|
|
302
|
+
}: {
|
|
303
|
+
groupPosition: BubbleGroupPosition
|
|
304
|
+
isMyMessage: boolean
|
|
305
|
+
segment: Extract<AttachmentSegment, { type: 'media' }>
|
|
306
|
+
text?: string
|
|
307
|
+
}) {
|
|
308
|
+
const TypeNamespace = messageAttachmentNamespaceForKind(segment.kind)
|
|
309
|
+
const props = {
|
|
310
|
+
...buildMediaClusterRenderProps(segment.kind, segment.attachments),
|
|
311
|
+
groupPosition,
|
|
312
|
+
...(text ? { text } : {}),
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (isMyMessage) {
|
|
316
|
+
return <TypeNamespace.Sent {...props} />
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return <TypeNamespace.Received {...props} />
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface StreamAttachmentMessageProps {
|
|
323
|
+
groupPosition: BubbleGroupPosition
|
|
324
|
+
isMyMessage: boolean
|
|
325
|
+
message: StreamThreadMessage
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
|
|
329
|
+
groupPosition,
|
|
330
|
+
isMyMessage,
|
|
331
|
+
message,
|
|
332
|
+
}) => {
|
|
333
|
+
const segments = buildOrderedAttachmentSegments(message.attachments)
|
|
334
|
+
if (segments.length === 0) return null
|
|
335
|
+
|
|
336
|
+
const totalBubbles = countAttachmentSegmentBubbles(segments)
|
|
337
|
+
const totalMediaBubbles = countMediaSegmentBubbles(segments)
|
|
338
|
+
const rawCaption = message.text?.trim()
|
|
339
|
+
const linkSegment = segments.find((segment) => segment.type === 'link')
|
|
340
|
+
const primaryLinkUrl =
|
|
341
|
+
linkSegment?.type === 'link'
|
|
342
|
+
? linkCardPropsFromStreamAttachment(linkSegment.attachments[0] ?? {}).url
|
|
343
|
+
: undefined
|
|
344
|
+
const caption =
|
|
345
|
+
rawCaption &&
|
|
346
|
+
linkCaptionDuplicatesAttachmentUrl(rawCaption, primaryLinkUrl)
|
|
347
|
+
? undefined
|
|
348
|
+
: rawCaption
|
|
349
|
+
|
|
350
|
+
let bubbleIndex = 0
|
|
351
|
+
let mediaBubbleIndex = 0
|
|
352
|
+
const rows: React.ReactNode[] = []
|
|
353
|
+
|
|
354
|
+
const pushLinkRow = (attachment: Attachment, key: string) => {
|
|
355
|
+
const isLastBubble = bubbleIndex === totalBubbles - 1
|
|
356
|
+
bubbleIndex += 1
|
|
357
|
+
|
|
358
|
+
const Card = isMyMessage ? LinkAttachment.Sent : LinkAttachment.Received
|
|
359
|
+
|
|
360
|
+
rows.push(
|
|
361
|
+
<React.Fragment key={key}>
|
|
362
|
+
<Card {...linkCardPropsFromStreamAttachment(attachment)} />
|
|
363
|
+
{isLastBubble && caption ? (
|
|
364
|
+
<div className={isMyMessage ? SENT_CAPTION_CLASS : RECEIVED_CAPTION_CLASS}>
|
|
365
|
+
{caption}
|
|
366
|
+
</div>
|
|
367
|
+
) : null}
|
|
368
|
+
</React.Fragment>
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const pushMediaCluster = (
|
|
373
|
+
segment: Extract<AttachmentSegment, { type: 'media' }>,
|
|
374
|
+
key: string
|
|
375
|
+
) => {
|
|
376
|
+
const isLastBubble = bubbleIndex === totalBubbles - 1
|
|
377
|
+
const position = bubblePositionInRun(
|
|
378
|
+
mediaBubbleIndex,
|
|
379
|
+
totalMediaBubbles,
|
|
380
|
+
groupPosition
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
bubbleIndex += 1
|
|
384
|
+
mediaBubbleIndex += 1
|
|
385
|
+
|
|
386
|
+
rows.push(
|
|
387
|
+
<MediaClusterBubble
|
|
388
|
+
key={key}
|
|
389
|
+
groupPosition={position}
|
|
390
|
+
isMyMessage={isMyMessage}
|
|
391
|
+
segment={segment}
|
|
392
|
+
text={isLastBubble ? caption : undefined}
|
|
393
|
+
/>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
segments.forEach((segment, segmentIndex) => {
|
|
398
|
+
if (segment.type === 'link') {
|
|
399
|
+
segment.attachments.forEach((attachment, attachmentIndex) => {
|
|
400
|
+
pushLinkRow(attachment, `link-${segmentIndex}-${attachmentIndex}`)
|
|
401
|
+
})
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
pushMediaCluster(segment, `media-${segmentIndex}`)
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
return <>{rows}</>
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
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,17 @@ 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, and AI-streamed messages fall through to the
|
|
217
|
+
// default branch so their Poll / StreamedMessageText / message-text handling
|
|
218
|
+
// is preserved — the accompanying message text must never be dropped.
|
|
219
|
+
const canRenderAttachmentsInToolkit =
|
|
220
|
+
hasAttachmentSegments &&
|
|
221
|
+
allAttachmentsRepresented &&
|
|
222
|
+
!message.quoted_message &&
|
|
223
|
+
!poll &&
|
|
224
|
+
!isAIGenerated
|
|
182
225
|
|
|
183
226
|
return (
|
|
184
227
|
<>
|
|
@@ -279,6 +322,28 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
|
|
|
279
322
|
) : isTipOnly ? (
|
|
280
323
|
/* Tip-only messages render as a standalone bubble */
|
|
281
324
|
<MessageTag message={message} standalone />
|
|
325
|
+
) : canRenderAttachmentsInToolkit ? (
|
|
326
|
+
<div className="str-chat__message-bubble-wrapper">
|
|
327
|
+
<div
|
|
328
|
+
className="str-chat__message-bubble"
|
|
329
|
+
style={{
|
|
330
|
+
background: 'transparent',
|
|
331
|
+
borderRadius: 0,
|
|
332
|
+
overflow: 'visible',
|
|
333
|
+
padding: 0,
|
|
334
|
+
}}
|
|
335
|
+
>
|
|
336
|
+
<StreamAttachmentMessage
|
|
337
|
+
groupPosition={streamAttachmentGroupPosition}
|
|
338
|
+
isMyMessage={isMine}
|
|
339
|
+
message={{
|
|
340
|
+
attachments: finalAttachments,
|
|
341
|
+
text: displayMessage.text,
|
|
342
|
+
}}
|
|
343
|
+
/>
|
|
344
|
+
<MessageErrorIcon />
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
282
347
|
) : (
|
|
283
348
|
<div className="str-chat__message-bubble-wrapper">
|
|
284
349
|
<div className="str-chat__message-bubble">
|
package/src/styles.css
CHANGED
|
@@ -299,24 +299,6 @@
|
|
|
299
299
|
background-color: transparent;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
-
/* iOS/WebKit soft-keyboard safety: complete the flex-column min-height chain.
|
|
303
|
-
WebKit (every iOS browser — Safari and in-app browsers alike) won't shrink a
|
|
304
|
-
flex child below its content height unless each ancestor in the column opts
|
|
305
|
-
out of the default `min-height: auto`. Stream sets `height: 100%` on this
|
|
306
|
-
container chain but no `min-height`, and only `.str-chat__main-panel-inner`
|
|
307
|
-
ships `min-height: 0`. Without completing the chain above it, a keyboard-
|
|
308
|
-
shrunk surface can't squeeze `main-panel`, so the composer's link-preview
|
|
309
|
-
list keeps its full height and pushes the textarea + send button below the
|
|
310
|
-
keyboard (the composer internals already handle their own squeeze via
|
|
311
|
-
`min-h-0` in CustomMessageInput — this lets that squeeze actually reach them).
|
|
312
|
-
Chromium is lenient here, so this only manifests on-device. */
|
|
313
|
-
.str-chat,
|
|
314
|
-
.str-chat__channel,
|
|
315
|
-
.str-chat__channel .str-chat__container,
|
|
316
|
-
.str-chat__channel .str-chat__container .str-chat__main-panel {
|
|
317
|
-
min-height: 0;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
302
|
.str-chat__message.str-chat__message--me {
|
|
321
303
|
.str-chat__attachment-list .str-chat__message-attachment--card {
|
|
322
304
|
color: white;
|