@linktr.ee/messaging-react 4.2.0-rc-1787325521 → 4.2.0-rc-1787599724

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 (96) hide show
  1. package/dist/{Card-BYJyA83X.js → Card-D7UPq5-g.js} +2 -2
  2. package/dist/{Card-BYJyA83X.js.map → Card-D7UPq5-g.js.map} +1 -1
  3. package/dist/{Card-BplJtZao.cjs → Card-DcBZqMBt.cjs} +2 -2
  4. package/dist/{Card-BplJtZao.cjs.map → Card-DcBZqMBt.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-BUC9vqhJ.js → index-CSkr_CO5.js} +2089 -1952
  7. package/dist/index-CSkr_CO5.js.map +1 -0
  8. package/dist/index-CwkY7QwT.cjs +3 -0
  9. package/dist/index-CwkY7QwT.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +59 -78
  12. package/dist/index.js +26 -30
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.js +2 -2
  16. package/dist/testing.js.map +1 -1
  17. package/package.json +13 -11
  18. package/src/components/AttachmentCard/utils/icons.ts +7 -6
  19. package/src/components/Avatar/Avatar.test.tsx +0 -126
  20. package/src/components/Avatar/getAvatarEmoji.test.ts +0 -39
  21. package/src/components/ChannelList/CustomChannelPreview.test.tsx +3 -410
  22. package/src/components/ChannelList/index.test.tsx +1 -132
  23. package/src/components/ChannelView.stories.tsx +0 -4
  24. package/src/components/ChannelView.test.tsx +3 -9
  25. package/src/components/CustomMessage/CustomMessage.stories.tsx +314 -6
  26. package/src/components/CustomMessage/CustomMessage.test.tsx +2 -56
  27. package/src/components/CustomMessage/MessageTag.stories.tsx +53 -17
  28. package/src/components/CustomMessage/MessageTag.test.tsx +40 -7
  29. package/src/components/CustomMessage/MessageTag.tsx +39 -7
  30. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +7 -8
  31. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +1 -2
  32. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +33 -0
  33. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +12 -5
  34. package/src/components/CustomMessage/index.tsx +19 -22
  35. package/src/components/CustomMessageInput/index.tsx +1 -1
  36. package/src/components/CustomSystemMessage/index.tsx +2 -3
  37. package/src/components/LinkAttachment/LinkAttachment.test.tsx +0 -327
  38. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
  39. package/src/components/MessageAttachment/Audio/WaveformAudioRow.test.tsx +153 -0
  40. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +450 -0
  41. package/src/components/MessageAttachment/Audio/audioRowRequester.test.ts +24 -0
  42. package/src/components/MessageAttachment/Audio/audioRowRequester.ts +16 -0
  43. package/src/components/MessageAttachment/Audio/index.tsx +83 -77
  44. package/src/components/MessageAttachment/MessageAttachment.test.tsx +176 -74
  45. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +0 -44
  46. package/src/components/MessageAttachment/_shared/useIsClient.ts +23 -0
  47. package/src/components/MessageAttachment/index.tsx +25 -9
  48. package/src/components/MessageAttachment/types.ts +19 -9
  49. package/src/components/MessagingShell/MessagingShell.test.tsx +0 -20
  50. package/src/components/MessagingShell/index.tsx +0 -2
  51. package/src/components/RichCard/RichCard.test.tsx +1 -3
  52. package/src/index.ts +0 -4
  53. package/src/providers/MessagingProvider.test.tsx +2 -130
  54. package/src/providers/MessagingProvider.tsx +15 -26
  55. package/src/styles.css +210 -57
  56. package/src/testing/createMockMessagingClient.ts +2 -2
  57. package/src/types.ts +0 -9
  58. package/dist/index-BUC9vqhJ.js.map +0 -1
  59. package/dist/index-BkqyKPJP.cjs +0 -3
  60. package/dist/index-BkqyKPJP.cjs.map +0 -1
  61. package/src/components/AttachmentCard/MediaPlayer.test.tsx +0 -346
  62. package/src/components/AttachmentCard/Thumbnail.test.tsx +0 -215
  63. package/src/components/AttachmentCard/index.test.tsx +0 -160
  64. package/src/components/ChannelList/ChannelListContext.test.tsx +0 -87
  65. package/src/components/CustomMessage/Locked.stories.tsx +0 -108
  66. package/src/components/CustomMessage/PaidMessage.stories.tsx +0 -42
  67. package/src/components/CustomMessage/Tip.stories.tsx +0 -48
  68. package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +0 -51
  69. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +0 -65
  70. package/src/components/CustomMessage/TipMessage/index.tsx +0 -27
  71. package/src/components/FaqList/FaqList.test.tsx +0 -204
  72. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +0 -76
  73. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +0 -248
  74. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +0 -123
  75. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +0 -193
  76. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +0 -242
  77. package/src/components/LinkAttachment/components/_shared/hex.test.ts +0 -37
  78. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +0 -72
  79. package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +0 -162
  80. package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +0 -182
  81. package/src/components/MessageAttachment/_shared/Bubble.test.tsx +0 -111
  82. package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +0 -36
  83. package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +0 -114
  84. package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +0 -42
  85. package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +0 -168
  86. package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +0 -52
  87. package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +0 -69
  88. package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +0 -45
  89. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +0 -55
  90. package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +0 -246
  91. package/src/components/MessageAttachment/_shared/triggerDownload.test.ts +0 -113
  92. package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +0 -68
  93. package/src/components/MessageAttachment/_shared/viewerChromeClasses.test.ts +0 -16
  94. package/src/logging/index.test.tsx +0 -136
  95. package/src/logging/index.tsx +0 -48
  96. package/src/stories/customMessageStoryTemplate.tsx +0 -202
@@ -19,7 +19,7 @@ export default meta
19
19
 
20
20
  interface MockMessageOptions {
21
21
  metadata?: {
22
- custom_type?: 'MESSAGE_CHATBOT' | 'MESSAGE_PAID'
22
+ custom_type?: 'MESSAGE_TIP' | 'MESSAGE_PAID' | 'MESSAGE_CHATBOT'
23
23
  amount_text?: string
24
24
  }
25
25
  text?: string
@@ -48,6 +48,32 @@ const Template: StoryFn<ComponentProps> = (args) => {
48
48
  // the variant matrix as the single snapshot source of truth.
49
49
  const skipInChromatic = { chromatic: { disableSnapshot: true } }
50
50
 
51
+ export const Tip: StoryFn<ComponentProps> = Template.bind({})
52
+ Tip.args = {
53
+ message: createMockMessage({
54
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$5.50' },
55
+ }),
56
+ }
57
+ Tip.parameters = skipInChromatic
58
+
59
+ export const TipStandalone: StoryFn<ComponentProps> = Template.bind({})
60
+ TipStandalone.args = {
61
+ message: createMockMessage({
62
+ text: '',
63
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$50.00' },
64
+ }),
65
+ standalone: true,
66
+ }
67
+ TipStandalone.parameters = skipInChromatic
68
+
69
+ export const Paid: StoryFn<ComponentProps> = Template.bind({})
70
+ Paid.args = {
71
+ message: createMockMessage({
72
+ metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$25.00' },
73
+ }),
74
+ }
75
+ Paid.parameters = skipInChromatic
76
+
51
77
  export const ChatbotReceiverText: StoryFn<ComponentProps> = Template.bind({})
52
78
  ChatbotReceiverText.args = {
53
79
  message: createMockMessage({ metadata: { custom_type: 'MESSAGE_CHATBOT' } }),
@@ -79,14 +105,6 @@ ChatbotSenderAttachment.args = {
79
105
  }
80
106
  ChatbotSenderAttachment.parameters = skipInChromatic
81
107
 
82
- export const Paid: StoryFn<ComponentProps> = Template.bind({})
83
- Paid.args = {
84
- message: createMockMessage({
85
- metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$10.00' },
86
- }),
87
- }
88
- Paid.parameters = skipInChromatic
89
-
90
108
  export const NoTag: StoryFn<ComponentProps> = Template.bind({})
91
109
  NoTag.args = {
92
110
  message: createMockMessage(),
@@ -96,6 +114,32 @@ NoTag.parameters = skipInChromatic
96
114
  export const AllVariants: StoryFn = () => {
97
115
  return (
98
116
  <div className="p-12 flex flex-col gap-4">
117
+ <div className="flex items-center gap-4">
118
+ <span className="text-sm w-32">Tip:</span>
119
+ <MessageTag
120
+ message={createMockMessage({
121
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$10.50' },
122
+ })}
123
+ />
124
+ </div>
125
+ <div className="flex items-center gap-4">
126
+ <span className="text-sm w-32">Paid:</span>
127
+ <MessageTag
128
+ message={createMockMessage({
129
+ metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$25.00' },
130
+ })}
131
+ />
132
+ </div>
133
+ <div className="flex items-center gap-4">
134
+ <span className="text-sm w-32">Tip (standalone):</span>
135
+ <MessageTag
136
+ message={createMockMessage({
137
+ text: '',
138
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$50.00' },
139
+ })}
140
+ standalone
141
+ />
142
+ </div>
99
143
  <div className="flex items-center gap-4">
100
144
  <span className="text-sm w-32">Chatbot (receiver):</span>
101
145
  <MessageTag
@@ -127,14 +171,6 @@ export const AllVariants: StoryFn = () => {
127
171
  />
128
172
  </div>
129
173
  </div>
130
- <div className="flex items-center gap-4">
131
- <span className="text-sm w-32">Paid:</span>
132
- <MessageTag
133
- message={createMockMessage({
134
- metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$10.00' },
135
- })}
136
- />
137
- </div>
138
174
  <div className="flex items-center gap-4">
139
175
  <span className="text-sm w-32">No tag:</span>
140
176
  <MessageTag message={createMockMessage()} />
@@ -7,7 +7,7 @@ import { MessageTag } from './MessageTag'
7
7
 
8
8
  interface MockMessageOptions {
9
9
  metadata?: {
10
- custom_type?: 'MESSAGE_CHATBOT' | 'MESSAGE_PAID'
10
+ custom_type?: 'MESSAGE_TIP' | 'MESSAGE_PAID' | 'MESSAGE_CHATBOT'
11
11
  amount_text?: string
12
12
  }
13
13
  text?: string
@@ -78,7 +78,35 @@ describe('MessageTag', () => {
78
78
  expect(indicator.children[1]).toHaveTextContent('Sent with AI')
79
79
  })
80
80
 
81
- it('renders the "Delivered with $X message" paid tag', () => {
81
+ it('keeps tip tag behavior unchanged', () => {
82
+ renderWithProviders(
83
+ <MessageTag
84
+ message={createMockMessage({
85
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$5.50' },
86
+ })}
87
+ />
88
+ )
89
+
90
+ const tipTag = screen.getByText('Delivered with $5.50 tip')
91
+ expect(tipTag.closest('.message-tag--tip')).not.toBeNull()
92
+ })
93
+
94
+ it('renders tip standalone label for textless tip messages', () => {
95
+ renderWithProviders(
96
+ <MessageTag
97
+ message={createMockMessage({
98
+ text: '',
99
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$50.00' },
100
+ })}
101
+ standalone
102
+ />
103
+ )
104
+
105
+ const tipTag = screen.getByText('$50.00 tip')
106
+ expect(tipTag.closest('.message-tip-standalone')).not.toBeNull()
107
+ })
108
+
109
+ it('renders paid footer tag with message copy, not tip copy', () => {
82
110
  renderWithProviders(
83
111
  <MessageTag
84
112
  message={createMockMessage({
@@ -88,19 +116,24 @@ describe('MessageTag', () => {
88
116
  )
89
117
 
90
118
  const paidTag = screen.getByText('Delivered with $10.00 message')
91
- expect(paidTag.closest('.message-tag--paid')).not.toBeNull()
119
+ expect(paidTag.closest('.message-tag--tip')).not.toBeNull()
120
+ expect(screen.queryByText(/tip/i)).toBeNull()
92
121
  })
93
122
 
94
- it('returns null for a paid message with no amount_text', () => {
95
- const { container } = renderWithProviders(
123
+ it('renders paid standalone label for textless paid messages', () => {
124
+ renderWithProviders(
96
125
  <MessageTag
97
126
  message={createMockMessage({
98
- metadata: { custom_type: 'MESSAGE_PAID' },
127
+ text: '',
128
+ metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$25.00' },
99
129
  })}
130
+ standalone
100
131
  />
101
132
  )
102
133
 
103
- expect(container.firstChild).toBeNull()
134
+ const paidTag = screen.getByText('$25.00 message')
135
+ expect(paidTag.closest('.message-tip-standalone')).not.toBeNull()
136
+ expect(screen.queryByText(/tip/i)).toBeNull()
104
137
  })
105
138
 
106
139
  it('returns null for non-custom messages', () => {
@@ -4,6 +4,8 @@ import { LocalMessage } from 'stream-chat'
4
4
 
5
5
  interface MessageTagProps {
6
6
  message: LocalMessage
7
+ /** When true, renders as a standalone bubble instead of a small tag */
8
+ standalone?: boolean
7
9
  /** When true, renders sender-side chatbot variants */
8
10
  isMyMessage?: boolean
9
11
  /** Whether the message includes an attachment */
@@ -26,6 +28,11 @@ const SparkleIcon = ({ size = 15 }: { size?: number }) => (
26
28
  </svg>
27
29
  )
28
30
 
31
+ /** Check if a message is a tip based on metadata */
32
+ export const isTipMessage = (message: LocalMessage): boolean => {
33
+ return message.metadata?.custom_type === MESSAGE_CUSTOM_TYPE.TIP
34
+ }
35
+
29
36
  /** Check if a message is a paid message based on metadata */
30
37
  const isPaidMessage = (message: LocalMessage): boolean => {
31
38
  return message.metadata?.custom_type === MESSAGE_CUSTOM_TYPE.PAID
@@ -55,31 +62,55 @@ export const isMediaAttachmentMessage = (message: LocalMessage): boolean => {
55
62
  )
56
63
  }
57
64
 
65
+ /** Check if a message has a tip or paid tag (shared layout, distinct copy) */
66
+ const isTipOrPaidMessage = (message: LocalMessage): boolean => {
67
+ return isTipMessage(message) || isPaidMessage(message)
68
+ }
69
+
58
70
  /** Check if the footer tag on this message already reads "Delivered with …" */
59
71
  export const hasPaidDeliveryTag = (message: LocalMessage): boolean => {
60
- return isPaidMessage(message) && !!message.metadata?.amount_text
72
+ return (
73
+ isTipOrPaidMessage(message) &&
74
+ !isTipOnlyMessage(message) &&
75
+ !!message.metadata?.amount_text
76
+ )
77
+ }
78
+
79
+ /** Check if a message is a tip/paid-only message (no text) */
80
+ export const isTipOnlyMessage = (message: LocalMessage): boolean => {
81
+ return isTipOrPaidMessage(message) && !message.text?.trim()
61
82
  }
62
83
 
63
84
  export const MessageTag = ({
64
85
  message,
86
+ standalone = false,
65
87
  isMyMessage = false,
66
88
  hasAttachment = false,
67
89
  }: MessageTagProps) => {
68
- const isPaid = isPaidMessage(message)
90
+ const isTipOrPaid = isTipOrPaidMessage(message)
69
91
  const isChatbot = isChatbotMessage(message)
70
92
 
71
- if (!isPaid && !isChatbot) {
93
+ if (!isTipOrPaid && !isChatbot) {
72
94
  return null
73
95
  }
74
96
 
75
- if (isPaid) {
97
+ if (isTipOrPaid) {
76
98
  const amountText = message.metadata?.amount_text
77
99
  if (!amountText) return null
78
100
 
101
+ const className = standalone
102
+ ? 'message-tip-standalone inline-flex items-center gap-1.5 px-3.5 py-2.5 rounded-[18px] text-[14px] font-medium text-[#016e1a] bg-[#dbf0e0]'
103
+ : 'message-tag--tip inline-flex items-center gap-1 text-[12px] leading-[2] font-medium text-[#016630]'
104
+
105
+ const amountLabel = isPaidMessage(message) ? 'message' : 'tip'
106
+ const label = standalone
107
+ ? `${amountText} ${amountLabel}`
108
+ : `Delivered with ${amountText} ${amountLabel}`
109
+
79
110
  return (
80
- <div className="message-tag--paid inline-flex items-center gap-1 text-[12px] font-medium leading-[2] text-[#016630]">
81
- <GiftIcon size={12} />
82
- <span>Delivered with {amountText} message</span>
111
+ <div className={className}>
112
+ <GiftIcon size={standalone ? 14 : 12} />
113
+ <span>{label}</span>
83
114
  </div>
84
115
  )
85
116
  }
@@ -104,6 +135,7 @@ export const MessageTag = ({
104
135
  </span>
105
136
  )
106
137
 
138
+ // Chatbot indicator variant
107
139
  return (
108
140
  <div
109
141
  className={`${chatbotBase} ${chatbotVariant}`}
@@ -392,14 +392,14 @@ describe('SentMessageDeliveryStatus', () => {
392
392
  })
393
393
 
394
394
  it.each([
395
- ['a paid message with text', 'thanks!'],
396
- ['a textless paid message', undefined],
397
- ])('does not render the delivery stamp on %s', (_label, text) => {
395
+ ['paid', 'MESSAGE_PAID'],
396
+ ['tip', 'MESSAGE_TIP'],
397
+ ])('does not render the delivery stamp on a %s message', (_label, type) => {
398
398
  mockedUseMessageContext.mockReturnValue({
399
399
  message: {
400
400
  id: 'own-2',
401
- text,
402
- metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$10.00' },
401
+ text: 'thanks!',
402
+ metadata: { custom_type: type, amount_text: '$10.00' },
403
403
  },
404
404
  isMyMessage: () => true,
405
405
  threadList: false,
@@ -413,12 +413,11 @@ describe('SentMessageDeliveryStatus', () => {
413
413
  expect(screen.queryByTestId('sending-slot')).not.toBeInTheDocument()
414
414
  })
415
415
 
416
- it('keeps the delivery stamp on a tip, which renders a pill instead of a footer tag', () => {
416
+ it('keeps the delivery stamp on a textless paid message, which has no footer tag', () => {
417
417
  mockedUseMessageContext.mockReturnValue({
418
418
  message: {
419
419
  id: 'own-2',
420
- text: 'thanks!',
421
- metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$5.00' },
420
+ metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$10.00' },
422
421
  },
423
422
  isMyMessage: () => true,
424
423
  threadList: false,
@@ -13,8 +13,7 @@ import {
13
13
 
14
14
  import type { OutboundClickHandler, ResolvedOutbound } from '../../types'
15
15
 
16
- import { hasPaidDeliveryTag } from './MessageTag'
17
- import { isTipMessage } from './TipMessage'
16
+ import { hasPaidDeliveryTag, isTipMessage } from './MessageTag'
18
17
 
19
18
  /**
20
19
  * Message status stamps for sending and terminal `MessageStatus` states
@@ -257,4 +257,37 @@ describe('StreamAttachmentMessage', () => {
257
257
  )
258
258
  ).toHaveTextContent('from the trip')
259
259
  })
260
+
261
+ it('forwards a persisted duration and waveform to the audio card', () => {
262
+ // Audio is sent as `type: "file"` with an `audio/*` mime type (for
263
+ // mobile compatibility), and the composer persists `duration` +
264
+ // `waveform_data` on the outgoing attachment. Without this
265
+ // passthrough every sent and received card would fall back to
266
+ // `--:--` and a flat placeholder even though the data is there.
267
+ renderWithProviders(
268
+ <StreamAttachmentMessage
269
+ groupPosition="single"
270
+ isMyMessage
271
+ message={message([
272
+ {
273
+ type: 'file',
274
+ asset_url: 'https://cdn.example.com/take-1.mp3',
275
+ mime_type: 'audio/mpeg',
276
+ title: 'take-1.mp3',
277
+ duration: 12,
278
+ waveform_data: [0.2, 0.9, 0.5, 0.7],
279
+ },
280
+ ])}
281
+ />
282
+ )
283
+
284
+ expect(screen.getByText('00:12')).toBeInTheDocument()
285
+ // The flat placeholder renders every bar at 0%; a real waveform is
286
+ // observable as bars drawn above that floor.
287
+ const barHeights = screen
288
+ .getAllByTestId('amplitude-bar')
289
+ .map((bar) => bar.style.getPropertyValue('--mes-audio-wave-bar'))
290
+ expect(barHeights).toHaveLength(35)
291
+ expect(barHeights.some((height) => height !== '0%')).toBe(true)
292
+ })
260
293
  })
@@ -155,7 +155,9 @@ export function buildOrderedAttachmentSegments(
155
155
  return segments
156
156
  }
157
157
 
158
- function countMediaSegmentBubbles(segments: AttachmentSegment[]): number {
158
+ function countMediaSegmentBubbles(
159
+ segments: AttachmentSegment[]
160
+ ): number {
159
161
  return segments.filter((segment) => segment.type === 'media').length
160
162
  }
161
163
 
@@ -219,6 +221,12 @@ function mediaItemForKind(
219
221
  src: trimToUndefined(attachment.asset_url) ?? '',
220
222
  mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
221
223
  filename: trimToUndefined((attachment as { title?: string }).title),
224
+ // Both are first-class `stream-chat` Attachment fields. The
225
+ // composer writes them at send time so the waveform card paints
226
+ // the real amplitudes and the correct duration with no remote
227
+ // decode and no CDN CORS exposure.
228
+ durationSeconds: attachment.duration,
229
+ waveformData: attachment.waveform_data,
222
230
  }
223
231
  case 'pdf':
224
232
  return {
@@ -243,9 +251,7 @@ function buildMediaClusterRenderProps(
243
251
  ): Record<string, unknown> {
244
252
  if (attachments.length > 1) {
245
253
  return {
246
- items: attachments.map((attachment) =>
247
- mediaItemForKind(kind, attachment)
248
- ),
254
+ items: attachments.map((attachment) => mediaItemForKind(kind, attachment)),
249
255
  }
250
256
  }
251
257
 
@@ -318,7 +324,8 @@ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
318
324
  : groupPosition
319
325
  // The trailing caption bubble carries the tail on single / last-in-run
320
326
  // messages, matching the in-channel text bubbles.
321
- const captionShowsTail = groupPosition === 'single' || groupPosition === 'end'
327
+ const captionShowsTail =
328
+ groupPosition === 'single' || groupPosition === 'end'
322
329
 
323
330
  const pushLinkRow = (
324
331
  attachment: Attachment,
@@ -43,6 +43,7 @@ import {
43
43
  isChatbotMessage,
44
44
  isMediaAttachmentMessage,
45
45
  isTextAttachmentMessage,
46
+ isTipOnlyMessage,
46
47
  } from './MessageTag'
47
48
  import { MessageTail } from './MessageTail'
48
49
  import { OutboundContext } from './OutboundContext'
@@ -50,7 +51,6 @@ import { SentMessageDeliveryStatus } from './SentMessageDeliveryStatus'
50
51
  import StreamAttachmentMessage, {
51
52
  buildOrderedAttachmentSegments,
52
53
  } from './StreamAttachmentMessage'
53
- import { TipMessage, isTipMessage } from './TipMessage'
54
54
 
55
55
  type CustomMessageUIComponentProps = MessageUIComponentProps & {
56
56
  viewerLanguage?: string
@@ -216,7 +216,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
216
216
  )
217
217
 
218
218
  const poll = message.poll_id && client.polls.fromState(message.poll_id)
219
- const isTip = isTipMessage(message)
219
+ const isTipOnly = isTipOnlyMessage(message)
220
220
  const isChatbot = isChatbotMessage(message)
221
221
  const isAttachment = isAttachmentMessage(message)
222
222
  const hasRenderableAttachments = !!(
@@ -258,18 +258,6 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
258
258
  ? () => onFetchSource(message, channel)
259
259
  : undefined,
260
260
  }
261
- // Shared by the tip and locked-media-attachment branches below: an
262
- // accompanying text bubble stacked under the pill / card.
263
- const accompanyingTextBubble = (
264
- <div
265
- className={classNames('str-chat__message-bubble', {
266
- 'relative isolate': showTail,
267
- })}
268
- >
269
- <MessageText message={displayMessage} renderText={renderText} />
270
- {showTail && <MessageTail side={isMine ? 'me' : 'other'} />}
271
- </div>
272
- )
273
261
  return (
274
262
  <>
275
263
  {editing && (
@@ -319,11 +307,8 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
319
307
  marginInlineStart: 0,
320
308
  }}
321
309
  >
322
- {isTip ? (
323
- <div className="str-chat__message-bubble-wrapper message-tip-wrapper">
324
- <TipMessage message={message} />
325
- {message.text?.trim() && accompanyingTextBubble}
326
- </div>
310
+ {isTipOnly ? (
311
+ <MessageTag message={message} standalone />
327
312
  ) : isTextAttachmentMessage(message) ? (
328
313
  <div className="str-chat__message-bubble-wrapper">
329
314
  <div className="flex items-center gap-2">
@@ -342,7 +327,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
342
327
  </div>
343
328
  </div>
344
329
  ) : isMediaAttachmentMessage(message) ? (
345
- <div className="str-chat__message-bubble-wrapper message-locked-attachment-wrapper">
330
+ <div className="str-chat__message-bubble-wrapper">
346
331
  <div className="flex items-center gap-2">
347
332
  {isMine && MessageActions && <MessageActions />}
348
333
  {isMine ? (
@@ -374,7 +359,19 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
374
359
  )}
375
360
  {!isMine && MessageActions && <MessageActions />}
376
361
  </div>
377
- {message.text && accompanyingTextBubble}
362
+ {message.text && (
363
+ <div
364
+ className={classNames('str-chat__message-bubble', {
365
+ 'relative isolate': showTail,
366
+ })}
367
+ >
368
+ <MessageText
369
+ message={displayMessage}
370
+ renderText={renderText}
371
+ />
372
+ {showTail && <MessageTail side={isMine ? 'me' : 'other'} />}
373
+ </div>
374
+ )}
378
375
  </div>
379
376
  ) : canRenderAttachmentsInToolkit ? (
380
377
  <StreamAttachmentMessage
@@ -428,7 +425,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
428
425
  </div>
429
426
  )}
430
427
  </div>
431
- {!isAttachment && !isTip && (
428
+ {!isAttachment && !isTipOnly && (
432
429
  <div className="str-chat__message-footer">
433
430
  {(!isChatbot || useAttachmentFooterChatbotTag) && (
434
431
  <MessageTag
@@ -496,7 +496,7 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
496
496
 
497
497
  <div className="relative z-10 flex min-h-0 flex-col">
498
498
  {renderHeader?.()}
499
- <div className="flex min-h-0 flex-col gap-4 p-3 pb-5 md:p-5">
499
+ <div className="flex min-h-0 flex-col gap-4 p-4">
500
500
  <div
501
501
  // @ts-expect-error Only React 19 onwards has `inert` in its types.
502
502
  inert={isFrozen ? '' : undefined}
@@ -19,9 +19,8 @@ const DM_AGENT_SYSTEM_MESSAGE_FALLBACK_TEXT: Record<DmAgentSystemType, string> =
19
19
  * explicitly added. Keep in sync with the mobile client (cerberus
20
20
  * `CustomMessageSystem`); there is no shared package between the two surfaces.
21
21
  */
22
- const SHOW_SYSTEM_MESSAGE_TIMESTAMP_TYPES: readonly AgeSafetySystemType[] = [
23
- 'SYSTEM_AGE_SAFETY_BLOCKED',
24
- ]
22
+ const SHOW_SYSTEM_MESSAGE_TIMESTAMP_TYPES: readonly AgeSafetySystemType[] =
23
+ ['SYSTEM_AGE_SAFETY_BLOCKED']
25
24
 
26
25
  const AGE_SAFETY_SYSTEM_MESSAGE_FALLBACK_TEXT: Record<
27
26
  AgeSafetySystemType,