@linktr.ee/messaging-react 4.2.0-rc-1787083791 → 4.2.0-rc-1787181113

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 (71) hide show
  1. package/dist/{Card-B4lMv-0o.js → Card-BOudQBd6.js} +2 -2
  2. package/dist/{Card-B4lMv-0o.js.map → Card-BOudQBd6.js.map} +1 -1
  3. package/dist/{Card-DWL5I62S.cjs → Card-k2959zNX.cjs} +2 -2
  4. package/dist/{Card-DWL5I62S.cjs.map → Card-k2959zNX.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-C93LTGLc.js → index-6RdyZh6F.js} +1809 -1912
  7. package/dist/index-6RdyZh6F.js.map +1 -0
  8. package/dist/index-BcU18EjV.cjs +3 -0
  9. package/dist/index-BcU18EjV.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +78 -59
  12. package/dist/index.js +30 -26
  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 +2 -2
  18. package/src/components/AttachmentCard/MediaPlayer.test.tsx +346 -0
  19. package/src/components/AttachmentCard/Thumbnail.test.tsx +215 -0
  20. package/src/components/AttachmentCard/index.test.tsx +160 -0
  21. package/src/components/Avatar/Avatar.test.tsx +126 -0
  22. package/src/components/Avatar/getAvatarEmoji.test.ts +39 -0
  23. package/src/components/ChannelList/ChannelListContext.test.tsx +87 -0
  24. package/src/components/ChannelList/CustomChannelPreview.test.tsx +410 -3
  25. package/src/components/ChannelList/index.test.tsx +132 -1
  26. package/src/components/CustomMessage/CustomMessage.stories.tsx +6 -314
  27. package/src/components/CustomMessage/CustomMessage.test.tsx +56 -2
  28. package/src/components/CustomMessage/Locked.stories.tsx +108 -0
  29. package/src/components/CustomMessage/MessageTag.stories.tsx +17 -53
  30. package/src/components/CustomMessage/MessageTag.test.tsx +7 -40
  31. package/src/components/CustomMessage/MessageTag.tsx +7 -39
  32. package/src/components/CustomMessage/PaidMessage.stories.tsx +42 -0
  33. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +8 -7
  34. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +2 -1
  35. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
  36. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -6
  37. package/src/components/CustomMessage/Tip.stories.tsx +48 -0
  38. package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +51 -0
  39. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +65 -0
  40. package/src/components/CustomMessage/TipMessage/index.tsx +27 -0
  41. package/src/components/CustomMessage/index.tsx +22 -19
  42. package/src/components/FaqList/FaqList.test.tsx +204 -0
  43. package/src/components/LinkAttachment/LinkAttachment.test.tsx +327 -0
  44. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +76 -0
  45. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +248 -0
  46. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +123 -0
  47. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +193 -0
  48. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +242 -0
  49. package/src/components/LinkAttachment/components/_shared/hex.test.ts +37 -0
  50. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +72 -0
  51. package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +162 -0
  52. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +58 -165
  53. package/src/components/MessageAttachment/Audio/index.tsx +77 -71
  54. package/src/components/MessageAttachment/MessageAttachment.test.tsx +74 -176
  55. package/src/components/MessageAttachment/index.tsx +9 -25
  56. package/src/components/MessageAttachment/types.ts +9 -19
  57. package/src/components/MessagingShell/MessagingShell.test.tsx +20 -0
  58. package/src/components/MessagingShell/index.tsx +2 -0
  59. package/src/index.ts +4 -0
  60. package/src/logging/index.test.tsx +136 -0
  61. package/src/logging/index.tsx +48 -0
  62. package/src/providers/MessagingProvider.test.tsx +113 -1
  63. package/src/providers/MessagingProvider.tsx +25 -15
  64. package/src/stories/customMessageStoryTemplate.tsx +199 -0
  65. package/src/styles.css +10 -121
  66. package/src/testing/createMockMessagingClient.ts +2 -2
  67. package/src/types.ts +9 -0
  68. package/dist/index-B-9LkWep.cjs +0 -3
  69. package/dist/index-B-9LkWep.cjs.map +0 -1
  70. package/dist/index-C93LTGLc.js.map +0 -1
  71. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +0 -335
@@ -7,7 +7,7 @@ import { MessageTag } from './MessageTag'
7
7
 
8
8
  interface MockMessageOptions {
9
9
  metadata?: {
10
- custom_type?: 'MESSAGE_TIP' | 'MESSAGE_PAID' | 'MESSAGE_CHATBOT'
10
+ custom_type?: 'MESSAGE_CHATBOT' | 'MESSAGE_PAID'
11
11
  amount_text?: string
12
12
  }
13
13
  text?: string
@@ -78,35 +78,7 @@ describe('MessageTag', () => {
78
78
  expect(indicator.children[1]).toHaveTextContent('Sent with AI')
79
79
  })
80
80
 
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', () => {
81
+ it('renders the "Delivered with $X message" paid tag', () => {
110
82
  renderWithProviders(
111
83
  <MessageTag
112
84
  message={createMockMessage({
@@ -116,24 +88,19 @@ describe('MessageTag', () => {
116
88
  )
117
89
 
118
90
  const paidTag = screen.getByText('Delivered with $10.00 message')
119
- expect(paidTag.closest('.message-tag--tip')).not.toBeNull()
120
- expect(screen.queryByText(/tip/i)).toBeNull()
91
+ expect(paidTag.closest('.message-tag--paid')).not.toBeNull()
121
92
  })
122
93
 
123
- it('renders paid standalone label for textless paid messages', () => {
124
- renderWithProviders(
94
+ it('returns null for a paid message with no amount_text', () => {
95
+ const { container } = renderWithProviders(
125
96
  <MessageTag
126
97
  message={createMockMessage({
127
- text: '',
128
- metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$25.00' },
98
+ metadata: { custom_type: 'MESSAGE_PAID' },
129
99
  })}
130
- standalone
131
100
  />
132
101
  )
133
102
 
134
- const paidTag = screen.getByText('$25.00 message')
135
- expect(paidTag.closest('.message-tip-standalone')).not.toBeNull()
136
- expect(screen.queryByText(/tip/i)).toBeNull()
103
+ expect(container.firstChild).toBeNull()
137
104
  })
138
105
 
139
106
  it('returns null for non-custom messages', () => {
@@ -4,8 +4,6 @@ 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
9
7
  /** When true, renders sender-side chatbot variants */
10
8
  isMyMessage?: boolean
11
9
  /** Whether the message includes an attachment */
@@ -28,11 +26,6 @@ const SparkleIcon = ({ size = 15 }: { size?: number }) => (
28
26
  </svg>
29
27
  )
30
28
 
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
-
36
29
  /** Check if a message is a paid message based on metadata */
37
30
  const isPaidMessage = (message: LocalMessage): boolean => {
38
31
  return message.metadata?.custom_type === MESSAGE_CUSTOM_TYPE.PAID
@@ -62,55 +55,31 @@ export const isMediaAttachmentMessage = (message: LocalMessage): boolean => {
62
55
  )
63
56
  }
64
57
 
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
-
70
58
  /** Check if the footer tag on this message already reads "Delivered with …" */
71
59
  export const hasPaidDeliveryTag = (message: LocalMessage): boolean => {
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()
60
+ return isPaidMessage(message) && !!message.metadata?.amount_text
82
61
  }
83
62
 
84
63
  export const MessageTag = ({
85
64
  message,
86
- standalone = false,
87
65
  isMyMessage = false,
88
66
  hasAttachment = false,
89
67
  }: MessageTagProps) => {
90
- const isTipOrPaid = isTipOrPaidMessage(message)
68
+ const isPaid = isPaidMessage(message)
91
69
  const isChatbot = isChatbotMessage(message)
92
70
 
93
- if (!isTipOrPaid && !isChatbot) {
71
+ if (!isPaid && !isChatbot) {
94
72
  return null
95
73
  }
96
74
 
97
- if (isTipOrPaid) {
75
+ if (isPaid) {
98
76
  const amountText = message.metadata?.amount_text
99
77
  if (!amountText) return null
100
78
 
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
-
110
79
  return (
111
- <div className={className}>
112
- <GiftIcon size={standalone ? 14 : 12} />
113
- <span>{label}</span>
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>
114
83
  </div>
115
84
  )
116
85
  }
@@ -135,7 +104,6 @@ export const MessageTag = ({
135
104
  </span>
136
105
  )
137
106
 
138
- // Chatbot indicator variant
139
107
  return (
140
108
  <div
141
109
  className={`${chatbotBase} ${chatbotVariant}`}
@@ -0,0 +1,42 @@
1
+ import type { Meta } from '@storybook/react'
2
+
3
+ import {
4
+ Template,
5
+ type TemplateProps,
6
+ } from '../../stories/customMessageStoryTemplate'
7
+ import {
8
+ currentUserArgType,
9
+ storyUsers,
10
+ } from '../../stories/decorators/storyUser'
11
+
12
+ const meta: Meta = {
13
+ title: 'CustomMessage/Paid Message',
14
+ argTypes: currentUserArgType,
15
+ args: { currentUser: storyUsers.creator },
16
+ parameters: {
17
+ layout: 'fullscreen',
18
+ },
19
+ }
20
+ export default meta
21
+
22
+ // A paid message keeps the original small "Delivered with $X message" tag
23
+ // (gift icon + green text) under the bubble — unlike a tip, it never gets
24
+ // the pill treatment.
25
+ export const Default = Template.bind({})
26
+ Default.args = {
27
+ messages: [
28
+ {
29
+ id: 'msg-1',
30
+ text: "Here's the private coaching link we discussed!",
31
+ user: storyUsers.creator,
32
+ metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$10' },
33
+ },
34
+ // Trailing reply so the paid message is no longer the latest own
35
+ // message — keeps the "✓✓ Delivered" stamp off this example.
36
+ {
37
+ id: 'msg-2',
38
+ text: 'Thank you so much!',
39
+ user: storyUsers.visitor,
40
+ },
41
+ ],
42
+ } satisfies TemplateProps
@@ -392,14 +392,14 @@ describe('SentMessageDeliveryStatus', () => {
392
392
  })
393
393
 
394
394
  it.each([
395
- ['paid', 'MESSAGE_PAID'],
396
- ['tip', 'MESSAGE_TIP'],
397
- ])('does not render the delivery stamp on a %s message', (_label, type) => {
395
+ ['a paid message with text', 'thanks!'],
396
+ ['a textless paid message', undefined],
397
+ ])('does not render the delivery stamp on %s', (_label, text) => {
398
398
  mockedUseMessageContext.mockReturnValue({
399
399
  message: {
400
400
  id: 'own-2',
401
- text: 'thanks!',
402
- metadata: { custom_type: type, amount_text: '$10.00' },
401
+ text,
402
+ metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$10.00' },
403
403
  },
404
404
  isMyMessage: () => true,
405
405
  threadList: false,
@@ -413,11 +413,12 @@ describe('SentMessageDeliveryStatus', () => {
413
413
  expect(screen.queryByTestId('sending-slot')).not.toBeInTheDocument()
414
414
  })
415
415
 
416
- it('keeps the delivery stamp on a textless paid message, which has no footer tag', () => {
416
+ it('keeps the delivery stamp on a tip, which renders a pill instead of a footer tag', () => {
417
417
  mockedUseMessageContext.mockReturnValue({
418
418
  message: {
419
419
  id: 'own-2',
420
- metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$10.00' },
420
+ text: 'thanks!',
421
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$5.00' },
421
422
  },
422
423
  isMyMessage: () => true,
423
424
  threadList: false,
@@ -13,7 +13,8 @@ import {
13
13
 
14
14
  import type { OutboundClickHandler, ResolvedOutbound } from '../../types'
15
15
 
16
- import { hasPaidDeliveryTag, isTipMessage } from './MessageTag'
16
+ import { hasPaidDeliveryTag } from './MessageTag'
17
+ import { isTipMessage } from './TipMessage'
17
18
 
18
19
  /**
19
20
  * Message status stamps for sending and terminal `MessageStatus` states
@@ -257,37 +257,4 @@ 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
- })
293
260
  })
@@ -221,12 +221,6 @@ function mediaItemForKind(
221
221
  src: trimToUndefined(attachment.asset_url) ?? '',
222
222
  mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
223
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,
230
224
  }
231
225
  case 'pdf':
232
226
  return {
@@ -0,0 +1,48 @@
1
+ import type { Meta } from '@storybook/react'
2
+
3
+ import {
4
+ Template,
5
+ type TemplateProps,
6
+ } from '../../stories/customMessageStoryTemplate'
7
+ import {
8
+ currentUserArgType,
9
+ storyUsers,
10
+ } from '../../stories/decorators/storyUser'
11
+
12
+ const meta: Meta = {
13
+ title: 'CustomMessage/Tip Message',
14
+ argTypes: currentUserArgType,
15
+ args: { currentUser: storyUsers.creator },
16
+ parameters: {
17
+ layout: 'fullscreen',
18
+ },
19
+ }
20
+ export default meta
21
+
22
+ // Figma "Tip with message" (node 2353:14548): a sent tip pill stacked
23
+ // directly above its accompanying text bubble, tail + delivery status below.
24
+ export const TipWithMessage = Template.bind({})
25
+ TipWithMessage.args = {
26
+ messages: [
27
+ {
28
+ id: 'msg-1',
29
+ text: 'Thanks you so much!',
30
+ user: storyUsers.creator,
31
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$1' },
32
+ },
33
+ ],
34
+ } satisfies TemplateProps
35
+
36
+ // Same "Tip with message" layout, but textless — only the tip pill renders,
37
+ // no second bubble.
38
+ export const TipWithoutMessage = Template.bind({})
39
+ TipWithoutMessage.args = {
40
+ messages: [
41
+ {
42
+ id: 'msg-1',
43
+ text: '',
44
+ user: storyUsers.creator,
45
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$1' },
46
+ },
47
+ ],
48
+ } satisfies TemplateProps
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+ import { LocalMessage } from 'stream-chat'
4
+
5
+ import { now } from '../../../stories/decorators/storyTime'
6
+
7
+ import { TipMessage } from '.'
8
+
9
+ type ComponentProps = React.ComponentProps<typeof TipMessage>
10
+
11
+ const meta: Meta<ComponentProps> = {
12
+ title: 'TipMessage',
13
+ component: TipMessage,
14
+ parameters: {
15
+ layout: 'centered',
16
+ },
17
+ }
18
+ export default meta
19
+
20
+ interface MockMessageOptions {
21
+ metadata?: {
22
+ custom_type?: 'MESSAGE_TIP'
23
+ amount_text?: string
24
+ }
25
+ text?: string
26
+ }
27
+
28
+ const createMockMessage = (options?: MockMessageOptions): LocalMessage =>
29
+ ({
30
+ id: 'msg-1',
31
+ text: options?.text ?? '',
32
+ type: 'regular',
33
+ created_at: now(),
34
+ updated_at: now(),
35
+ metadata: options?.metadata,
36
+ }) as LocalMessage
37
+
38
+ const Template: StoryFn<ComponentProps> = (args) => {
39
+ return (
40
+ <div className="p-12">
41
+ <TipMessage {...args} />
42
+ </div>
43
+ )
44
+ }
45
+
46
+ export const Standalone: StoryFn<ComponentProps> = Template.bind({})
47
+ Standalone.args = {
48
+ message: createMockMessage({
49
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$50.00' },
50
+ }),
51
+ }
@@ -0,0 +1,65 @@
1
+ import { LocalMessage } from 'stream-chat'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { renderWithProviders, screen } from '../../../test/utils'
5
+
6
+ import { TipMessage } from '.'
7
+
8
+ interface MockMessageOptions {
9
+ metadata?: {
10
+ custom_type?: 'MESSAGE_TIP'
11
+ amount_text?: string
12
+ }
13
+ text?: string
14
+ }
15
+
16
+ const createMockMessage = (options?: MockMessageOptions): LocalMessage =>
17
+ ({
18
+ id: 'msg-1',
19
+ text: options?.text ?? 'Hello world',
20
+ type: 'regular',
21
+ created_at: new Date(),
22
+ updated_at: new Date(),
23
+ metadata: options?.metadata,
24
+ }) as LocalMessage
25
+
26
+ describe('TipMessage', () => {
27
+ it('renders tip pill regardless of accompanying text', () => {
28
+ renderWithProviders(
29
+ <TipMessage
30
+ message={createMockMessage({
31
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$5.50' },
32
+ })}
33
+ />
34
+ )
35
+
36
+ const tipTag = screen.getByText('$5.50 tip!')
37
+ expect(tipTag.closest('.message-tip-pill')).not.toBeNull()
38
+ })
39
+
40
+ it('renders tip pill label for textless tip messages', () => {
41
+ renderWithProviders(
42
+ <TipMessage
43
+ message={createMockMessage({
44
+ text: '',
45
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$50.00' },
46
+ })}
47
+ />
48
+ )
49
+
50
+ const tipTag = screen.getByText('$50.00 tip!')
51
+ expect(tipTag.closest('.message-tip-pill')).not.toBeNull()
52
+ })
53
+
54
+ it('returns null when amount_text is missing', () => {
55
+ const { container } = renderWithProviders(
56
+ <TipMessage
57
+ message={createMockMessage({
58
+ metadata: { custom_type: 'MESSAGE_TIP' },
59
+ })}
60
+ />
61
+ )
62
+
63
+ expect(container.firstChild).toBeNull()
64
+ })
65
+ })
@@ -0,0 +1,27 @@
1
+ import { MESSAGE_CUSTOM_TYPE } from '@linktr.ee/messaging-taxonomy'
2
+ import { LocalMessage } from 'stream-chat'
3
+
4
+ /** Check if a message is a tip based on metadata */
5
+ export const isTipMessage = (message: LocalMessage): boolean => {
6
+ return message.metadata?.custom_type === MESSAGE_CUSTOM_TYPE.TIP
7
+ }
8
+
9
+ /** Tip pill — the gift-emoji bubble a tip message attaches, with or without accompanying text */
10
+ export const TipMessage = ({ message }: { message: LocalMessage }) => {
11
+ const amountText = message.metadata?.amount_text
12
+ if (!amountText) return null
13
+
14
+ return (
15
+ <div className="message-tip-pill inline-flex items-end gap-0.5 max-w-[520px] rounded-[1.5rem] bg-[#43e660] px-4 py-3 text-white">
16
+ <span
17
+ className="flex h-5 items-center text-[20px] leading-none"
18
+ aria-hidden="true"
19
+ >
20
+ 🎁
21
+ </span>
22
+ <span className="text-[14px] font-bold leading-[1.43] tracking-[0.28px] text-[rgba(4,4,3,0.9)]">
23
+ {amountText} tip!
24
+ </span>
25
+ </div>
26
+ )
27
+ }
@@ -43,7 +43,6 @@ import {
43
43
  isChatbotMessage,
44
44
  isMediaAttachmentMessage,
45
45
  isTextAttachmentMessage,
46
- isTipOnlyMessage,
47
46
  } from './MessageTag'
48
47
  import { MessageTail } from './MessageTail'
49
48
  import { OutboundContext } from './OutboundContext'
@@ -51,6 +50,7 @@ import { SentMessageDeliveryStatus } from './SentMessageDeliveryStatus'
51
50
  import StreamAttachmentMessage, {
52
51
  buildOrderedAttachmentSegments,
53
52
  } 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 isTipOnly = isTipOnlyMessage(message)
219
+ const isTip = isTipMessage(message)
220
220
  const isChatbot = isChatbotMessage(message)
221
221
  const isAttachment = isAttachmentMessage(message)
222
222
  const hasRenderableAttachments = !!(
@@ -258,6 +258,18 @@ 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
+ )
261
273
  return (
262
274
  <>
263
275
  {editing && (
@@ -307,8 +319,11 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
307
319
  marginInlineStart: 0,
308
320
  }}
309
321
  >
310
- {isTipOnly ? (
311
- <MessageTag message={message} standalone />
322
+ {isTip ? (
323
+ <div className="str-chat__message-bubble-wrapper message-tip-wrapper">
324
+ <TipMessage message={message} />
325
+ {message.text?.trim() && accompanyingTextBubble}
326
+ </div>
312
327
  ) : isTextAttachmentMessage(message) ? (
313
328
  <div className="str-chat__message-bubble-wrapper">
314
329
  <div className="flex items-center gap-2">
@@ -327,7 +342,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
327
342
  </div>
328
343
  </div>
329
344
  ) : isMediaAttachmentMessage(message) ? (
330
- <div className="str-chat__message-bubble-wrapper">
345
+ <div className="str-chat__message-bubble-wrapper message-locked-attachment-wrapper">
331
346
  <div className="flex items-center gap-2">
332
347
  {isMine && MessageActions && <MessageActions />}
333
348
  {isMine ? (
@@ -359,19 +374,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
359
374
  )}
360
375
  {!isMine && MessageActions && <MessageActions />}
361
376
  </div>
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
- )}
377
+ {message.text && accompanyingTextBubble}
375
378
  </div>
376
379
  ) : canRenderAttachmentsInToolkit ? (
377
380
  <StreamAttachmentMessage
@@ -425,7 +428,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
425
428
  </div>
426
429
  )}
427
430
  </div>
428
- {!isAttachment && !isTipOnly && (
431
+ {!isAttachment && !isTip && (
429
432
  <div className="str-chat__message-footer">
430
433
  {(!isChatbot || useAttachmentFooterChatbotTag) && (
431
434
  <MessageTag