@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.
Files changed (32) hide show
  1. package/dist/{Card-rblNjC58.js → Card-COTwOzVb.js} +2 -2
  2. package/dist/{Card-rblNjC58.js.map → Card-COTwOzVb.js.map} +1 -1
  3. package/dist/{Card-DYe76Xh3.cjs → Card-Cb4yfxFK.cjs} +2 -2
  4. package/dist/{Card-DYe76Xh3.cjs.map → Card-Cb4yfxFK.cjs.map} +1 -1
  5. package/dist/{Card-CAJnEq1K.cjs → Card-DmjLrV64.cjs} +2 -2
  6. package/dist/{Card-CAJnEq1K.cjs.map → Card-DmjLrV64.cjs.map} +1 -1
  7. package/dist/{Card-D_N5hclc.cjs → Card-DzT94KFC.cjs} +2 -2
  8. package/dist/{Card-D_N5hclc.cjs.map → Card-DzT94KFC.cjs.map} +1 -1
  9. package/dist/{Card-B5lE5A2D.js → Card-J8f28GLS.js} +3 -3
  10. package/dist/{Card-B5lE5A2D.js.map → Card-J8f28GLS.js.map} +1 -1
  11. package/dist/{Card-MBp3Wg6r.js → Card-ra-g17Ur.js} +2 -2
  12. package/dist/{Card-MBp3Wg6r.js.map → Card-ra-g17Ur.js.map} +1 -1
  13. package/dist/{LockedThumbnail-BqyPPVrO.js → LockedThumbnail-CVq5LPXE.js} +2 -2
  14. package/dist/{LockedThumbnail-BqyPPVrO.js.map → LockedThumbnail-CVq5LPXE.js.map} +1 -1
  15. package/dist/{LockedThumbnail-C7PpVZQ0.cjs → LockedThumbnail-Ds_vBmYm.cjs} +2 -2
  16. package/dist/{LockedThumbnail-C7PpVZQ0.cjs.map → LockedThumbnail-Ds_vBmYm.cjs.map} +1 -1
  17. package/dist/assets/index.css +1 -1
  18. package/dist/{index-BXzwYXLT.js → index-D1XVCH1o.js} +3415 -3133
  19. package/dist/index-D1XVCH1o.js.map +1 -0
  20. package/dist/index-hvrSZScU.cjs +2 -0
  21. package/dist/index-hvrSZScU.cjs.map +1 -0
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.js +1 -1
  24. package/package.json +2 -2
  25. package/src/components/CustomMessage/StreamAttachmentMessage.stories.tsx +178 -0
  26. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +153 -0
  27. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +411 -0
  28. package/src/components/CustomMessage/index.tsx +65 -0
  29. package/src/styles.css +0 -18
  30. package/dist/index-BXzwYXLT.js.map +0 -1
  31. package/dist/index-BflWZq0e.cjs +0 -2
  32. 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;