@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
@@ -11,25 +11,16 @@ const {
11
11
  ChannelListProviderMock,
12
12
  CustomChannelPreviewMock,
13
13
  InfiniteScrollMock,
14
- restorePendingMessagesMock,
15
- streamMountMock,
16
- messagingContextMock,
17
14
  } = vi.hoisted(() => ({
18
15
  ChannelListProviderMock: vi.fn(
19
16
  ({ children }: { children: React.ReactNode; value: unknown }) => children
20
17
  ),
21
18
  CustomChannelPreviewMock: () => null,
22
19
  InfiniteScrollMock: () => null,
23
- restorePendingMessagesMock: vi.fn(),
24
- streamMountMock: vi.fn(),
25
- messagingContextMock: { current: { debug: false } },
26
20
  }))
27
21
 
28
22
  vi.mock('stream-chat-react', () => ({
29
23
  ChannelList: (props: unknown) => {
30
- React.useEffect(() => {
31
- streamMountMock()
32
- }, [])
33
24
  streamChannelListMock(props)
34
25
  return <div data-testid="stream-channel-list" />
35
26
  },
@@ -37,11 +28,7 @@ vi.mock('stream-chat-react', () => ({
37
28
  }))
38
29
 
39
30
  vi.mock('../../providers/MessagingProvider', () => ({
40
- useMessagingContext: () => messagingContextMock.current,
41
- }))
42
-
43
- vi.mock('../../hooks/useRestorePendingMessages', () => ({
44
- restorePendingMessages: restorePendingMessagesMock,
31
+ useMessagingContext: () => ({ debug: false }),
45
32
  }))
46
33
 
47
34
  vi.mock('./ChannelListContext', () => ({
@@ -61,9 +48,6 @@ describe('ChannelList', () => {
61
48
  beforeEach(() => {
62
49
  ChannelListProviderMock.mockClear()
63
50
  streamChannelListMock.mockClear()
64
- restorePendingMessagesMock.mockClear()
65
- streamMountMock.mockClear()
66
- messagingContextMock.current = { debug: false }
67
51
  })
68
52
 
69
53
  it('defaults unseen-channel promotion to false', () => {
@@ -216,119 +200,4 @@ describe('ChannelList', () => {
216
200
  streamProps.channelRenderFilterFn!(mockChannels)
217
201
  expect(filterFn).toHaveBeenCalledWith(mockChannels)
218
202
  })
219
-
220
- it('restores pending messages for every channel and preserves channels without a filter', () => {
221
- const channels = [
222
- { cid: 'ch-1', state: { pending_messages: [] } },
223
- { cid: 'ch-2', state: { pending_messages: [{ id: 'pending-2' }] } },
224
- ] as unknown as Channel[]
225
-
226
- renderWithProviders(<ChannelList {...defaultProps} />)
227
- const streamProps = streamChannelListMock.mock.calls[0][0] as {
228
- channelRenderFilterFn: (channels: Channel[]) => Channel[]
229
- }
230
-
231
- expect(streamProps.channelRenderFilterFn(channels)).toBe(channels)
232
- expect(restorePendingMessagesMock).toHaveBeenCalledTimes(2)
233
- expect(restorePendingMessagesMock).toHaveBeenNthCalledWith(1, channels[0])
234
- expect(restorePendingMessagesMock).toHaveBeenNthCalledWith(2, channels[1])
235
- })
236
-
237
- it('remounts Stream ChannelList when filters change but not when they stay the same', () => {
238
- const { rerender } = renderWithProviders(<ChannelList {...defaultProps} />)
239
-
240
- expect(streamMountMock).toHaveBeenCalledOnce()
241
-
242
- rerender(<ChannelList {...defaultProps} />)
243
- expect(streamMountMock).toHaveBeenCalledOnce()
244
-
245
- rerender(
246
- <ChannelList
247
- {...defaultProps}
248
- filters={{ type: 'messaging', members: { $in: ['another-user'] } }}
249
- />
250
- )
251
- expect(streamMountMock).toHaveBeenCalledTimes(2)
252
- })
253
-
254
- it('forwards the default and explicit sort values and the channel limit', () => {
255
- renderWithProviders(<ChannelList {...defaultProps} />)
256
- expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
257
- sort: { last_message_at: -1 },
258
- options: { limit: 30 },
259
- })
260
-
261
- const explicitSort = { updated_at: 1 as const }
262
- renderWithProviders(<ChannelList {...defaultProps} sort={explicitSort} />)
263
- expect(streamChannelListMock.mock.calls[1][0]).toMatchObject({
264
- sort: explicitSort,
265
- options: { limit: 30 },
266
- })
267
- })
268
-
269
- it('provides channel list context values from props and messaging context', () => {
270
- const selectedChannel = { id: 'selected-channel' } as Channel
271
- const onChannelSelect = vi.fn()
272
- const renderMessagePreview = vi.fn()
273
- messagingContextMock.current = { debug: true }
274
-
275
- renderWithProviders(
276
- <ChannelList
277
- {...defaultProps}
278
- selectedChannel={selectedChannel}
279
- onChannelSelect={onChannelSelect}
280
- renderMessagePreview={renderMessagePreview}
281
- viewerLanguage="fr"
282
- />
283
- )
284
-
285
- const contextValue = ChannelListProviderMock.mock.calls[0][0].value
286
- expect(contextValue).toMatchObject({
287
- selectedChannel,
288
- onChannelSelect,
289
- debug: true,
290
- renderMessagePreview,
291
- viewerLanguage: 'fr',
292
- })
293
- })
294
-
295
- it('logs the channel list render when messaging debug is enabled', () => {
296
- const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
297
- messagingContextMock.current = { debug: true }
298
-
299
- renderWithProviders(<ChannelList {...defaultProps} />)
300
-
301
- expect(consoleLog).toHaveBeenCalledWith(
302
- '📺 [ChannelList] 🔄 RENDER START',
303
- expect.objectContaining({
304
- renderCount: 1,
305
- selectedChannelId: undefined,
306
- filters: defaultProps.filters,
307
- })
308
- )
309
- })
310
-
311
- it('passes wrapper classes and the custom empty state indicator through', () => {
312
- const EmptyStateIndicator = () => <div>Empty state</div>
313
- const { container } = renderWithProviders(
314
- <ChannelList
315
- {...defaultProps}
316
- className="custom-channel-list"
317
- customEmptyStateIndicator={EmptyStateIndicator}
318
- />
319
- )
320
-
321
- expect(container.firstElementChild).toHaveClass(
322
- 'messaging-channel-list',
323
- 'h-full',
324
- 'flex',
325
- 'flex-col',
326
- 'min-w-0',
327
- 'overflow-hidden',
328
- 'custom-channel-list'
329
- )
330
- expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
331
- EmptyStateIndicator,
332
- })
333
- })
334
203
  })
@@ -682,10 +682,6 @@ WithTypingIndicator.args = {
682
682
  },
683
683
  }
684
684
  WithTypingIndicator.parameters = {
685
- // The typing indicator is animation-dominant, so a snapshot mostly captures
686
- // whichever frame the animation happened to pause at — a false-positive
687
- // source. Skip it in Chromatic; the story stays browsable in Storybook.
688
- chromatic: { disableSnapshot: true },
689
685
  docs: {
690
686
  description: {
691
687
  story:
@@ -223,14 +223,10 @@ describe('ChannelView', () => {
223
223
 
224
224
  it('widens the content column in the official (read-only) channel via the width var', () => {
225
225
  const { container } = renderWithProviders(
226
- <ChannelView
227
- channel={createChannel({ channelType: OFFICIAL_CHANNEL_TYPE })}
228
- />
226
+ <ChannelView channel={createChannel({ channelType: OFFICIAL_CHANNEL_TYPE })} />
229
227
  )
230
228
 
231
- const root = container.querySelector(
232
- '.messaging-channel-view'
233
- ) as HTMLElement
229
+ const root = container.querySelector('.messaging-channel-view') as HTMLElement
234
230
  expect(root.style.getPropertyValue('--lt-msg-card-width')).toBe('368px')
235
231
  })
236
232
 
@@ -239,9 +235,7 @@ describe('ChannelView', () => {
239
235
  <ChannelView channel={createChannel({ channelType: 'messaging' })} />
240
236
  )
241
237
 
242
- const root = container.querySelector(
243
- '.messaging-channel-view'
244
- ) as HTMLElement
238
+ const root = container.querySelector('.messaging-channel-view') as HTMLElement
245
239
  expect(root.style.getPropertyValue('--lt-msg-card-width')).toBe('')
246
240
  })
247
241
 
@@ -2,7 +2,11 @@ import '../../stream-custom-data'
2
2
 
3
3
  import type { Meta, StoryFn } from '@storybook/react'
4
4
  import React, { useEffect } from 'react'
5
- import { Channel as ChannelType } from 'stream-chat'
5
+ import {
6
+ Channel as ChannelType,
7
+ QueryChannelAPIResponse,
8
+ StreamChat,
9
+ } from 'stream-chat'
6
10
  import {
7
11
  Channel,
8
12
  Chat,
@@ -12,20 +16,18 @@ import {
12
16
  WithComponents,
13
17
  } from 'stream-chat-react'
14
18
 
15
- import {
16
- Template,
17
- createMockChannel,
18
- type TemplateProps,
19
- } from '../../stories/customMessageStoryTemplate'
19
+ import { minutesAgo } from '../../stories/decorators/storyTime'
20
20
  import {
21
21
  currentUserArgType,
22
22
  StoryUser,
23
23
  storyUsers,
24
24
  } from '../../stories/decorators/storyUser'
25
25
  import { createMockStreamChatClient } from '../../testing/createMockStreamChatClient'
26
+ import type { OutboundResolver } from '../../types'
26
27
  import CustomTypingIndicator from '../CustomTypingIndicator'
27
28
 
28
29
  import { CustomMessageActions } from './CustomMessageActions'
30
+ import { OutboundContext, buildOutboundsByRecordId } from './OutboundContext'
29
31
 
30
32
  import { CustomMessage } from './index'
31
33
 
@@ -40,6 +42,180 @@ const meta: Meta = {
40
42
  }
41
43
  export default meta
42
44
 
45
+ const createMockChannel = async (
46
+ client: StreamChat,
47
+ messages: TemplateProps['messages']
48
+ ) => {
49
+ const mockMessages = messages.map((msg, index) => ({
50
+ ...msg,
51
+ type: msg.type ?? ('regular' as const),
52
+ created_at: minutesAgo(messages.length - index),
53
+ updated_at: minutesAgo(messages.length - index),
54
+ attachments: msg.attachments ?? [],
55
+ latest_reactions: [],
56
+ own_reactions: [],
57
+ reaction_counts: {},
58
+ reaction_scores: {},
59
+ reply_count: 0,
60
+ status: 'received',
61
+ cid: 'messaging:storybook-channel-1',
62
+ mentioned_users: msg.mentioned_users ?? [],
63
+ }))
64
+
65
+ const channelData = {
66
+ members: [storyUsers.creator.id, storyUsers.visitor.id],
67
+ // Required for useUserRole: canDelete needs delete-own-message, canFlag needs flag-message
68
+ own_capabilities: ['delete-own-message', 'flag-message'],
69
+ }
70
+
71
+ const channel = client.channel(
72
+ 'messaging',
73
+ 'storybook-channel-1',
74
+ channelData
75
+ )
76
+
77
+ channel.watch = async () => {
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ channel.state.messages = mockMessages as unknown as any[]
80
+ channel.state.members = {
81
+ [storyUsers.creator.id]: {
82
+ user: storyUsers.creator,
83
+ user_id: storyUsers.creator.id,
84
+ },
85
+ [storyUsers.visitor.id]: {
86
+ user: storyUsers.visitor,
87
+ user_id: storyUsers.visitor.id,
88
+ },
89
+ }
90
+ return {
91
+ channel: channelData,
92
+ members: [],
93
+ messages: mockMessages,
94
+ watchers: [],
95
+ pinned_messages: [],
96
+ duration: '0ms',
97
+ } as unknown as QueryChannelAPIResponse
98
+ }
99
+
100
+ try {
101
+ await channel.watch()
102
+ } catch {
103
+ // Ignore errors in mock mode
104
+ }
105
+
106
+ return channel
107
+ }
108
+
109
+ interface TemplateProps {
110
+ currentUser: StoryUser
111
+ typingUser?: StoryUser
112
+ resolveOutbound?: OutboundResolver
113
+ messages: Array<{
114
+ id: string
115
+ text: string
116
+ user: StoryUser
117
+ mentioned_users?: StoryUser[]
118
+ type?: 'regular' | 'system'
119
+ attachments?: Array<Record<string, unknown>>
120
+ metadata?: {
121
+ custom_type?:
122
+ | 'MESSAGE_TIP'
123
+ | 'MESSAGE_PAID'
124
+ | 'MESSAGE_CHATBOT'
125
+ | 'MESSAGE_ATTACHMENT'
126
+ | 'MESSAGE_OUTBOUND'
127
+ payment_status?: 'pending' | 'paid' | 'failed' | 'refunded'
128
+ amount_text?: string
129
+ attachment_title?: string
130
+ attachment_mime_type?: string
131
+ attachment_thumbnail?: string
132
+ attachment_detail?: string
133
+ attachment_content_type?: 'text' | 'media'
134
+ outbound_id?: string
135
+ }
136
+ }>
137
+ }
138
+
139
+ const TemplateInner: React.FC<{
140
+ currentUser: StoryUser
141
+ typingUser?: StoryUser
142
+ messages: TemplateProps['messages']
143
+ resolveOutbound?: OutboundResolver
144
+ }> = ({ currentUser, typingUser, messages, resolveOutbound }) => {
145
+ const [client] = React.useState(() => createMockStreamChatClient(currentUser))
146
+
147
+ const [channel, setChannel] = React.useState<ChannelType | null>(null)
148
+
149
+ useEffect(() => {
150
+ createMockChannel(client, messages).then(setChannel)
151
+ }, [client, messages])
152
+
153
+ useEffect(() => {
154
+ if (!channel || !typingUser) {
155
+ return
156
+ }
157
+
158
+ // Stream Channel UI only updates typing context from typing.start/typing.stop events.
159
+ const timer = setTimeout(() => {
160
+ client.dispatchEvent({
161
+ type: 'typing.start',
162
+ cid: channel.cid,
163
+ user: typingUser,
164
+ })
165
+ }, 0)
166
+
167
+ return () => clearTimeout(timer)
168
+ }, [channel, client, typingUser])
169
+
170
+ const outboundsByRecordId = React.useMemo(
171
+ () => buildOutboundsByRecordId(messages, resolveOutbound),
172
+ [messages, resolveOutbound]
173
+ )
174
+
175
+ const MessageComponent = React.useMemo(() => {
176
+ return function CustomMessageComponent(props: MessageUIComponentProps) {
177
+ return <CustomMessage {...props} onOutboundClick={() => undefined} />
178
+ }
179
+ }, [])
180
+
181
+ if (!channel) {
182
+ return <div className="p-4">Loading...</div>
183
+ }
184
+
185
+ return (
186
+ <OutboundContext.Provider value={outboundsByRecordId}>
187
+ <Chat client={client}>
188
+ <div className="h-screen w-full bg-white">
189
+ <Channel
190
+ channel={channel}
191
+ Message={MessageComponent}
192
+ TypingIndicator={CustomTypingIndicator}
193
+ >
194
+ <Window>
195
+ <MessageList />
196
+ </Window>
197
+ </Channel>
198
+ </div>
199
+ </Chat>
200
+ </OutboundContext.Provider>
201
+ )
202
+ }
203
+
204
+ const Template: StoryFn<TemplateProps> = ({
205
+ currentUser = storyUsers.creator,
206
+ typingUser,
207
+ messages,
208
+ resolveOutbound,
209
+ }) => (
210
+ <TemplateInner
211
+ key={currentUser.id}
212
+ currentUser={currentUser}
213
+ typingUser={typingUser}
214
+ messages={messages}
215
+ resolveOutbound={resolveOutbound}
216
+ />
217
+ )
218
+
43
219
  export const Default: StoryFn<TemplateProps> = Template.bind({})
44
220
  Default.args = {
45
221
  messages: [
@@ -166,6 +342,28 @@ OutboundStatusResolved.args = {
166
342
  }),
167
343
  }
168
344
 
345
+ export const WithTipTag: StoryFn<TemplateProps> = Template.bind({})
346
+ WithTipTag.args = {
347
+ messages: [
348
+ {
349
+ id: 'msg-1',
350
+ text: 'Love your content!',
351
+ user: storyUsers.visitor,
352
+ },
353
+ {
354
+ id: 'msg-2',
355
+ text: "Here's a tip for you! Keep up the great work.",
356
+ user: storyUsers.visitor,
357
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$5.50' },
358
+ },
359
+ {
360
+ id: 'msg-3',
361
+ text: 'Thank you so much! 🙏',
362
+ user: storyUsers.creator,
363
+ },
364
+ ],
365
+ }
366
+
169
367
  export const ThreeConsecutiveSentMessages: StoryFn<TemplateProps> =
170
368
  Template.bind({})
171
369
  ThreeConsecutiveSentMessages.args = {
@@ -193,6 +391,28 @@ ThreeConsecutiveSentMessages.args = {
193
391
  ],
194
392
  }
195
393
 
394
+ export const TipOnly: StoryFn<TemplateProps> = Template.bind({})
395
+ TipOnly.args = {
396
+ messages: [
397
+ {
398
+ id: 'msg-1',
399
+ text: 'Hello!',
400
+ user: storyUsers.visitor,
401
+ },
402
+ {
403
+ id: 'msg-2',
404
+ text: '',
405
+ user: storyUsers.visitor,
406
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$50.00' },
407
+ },
408
+ {
409
+ id: 'msg-3',
410
+ text: 'Wow, thank you for the tip!',
411
+ user: storyUsers.creator,
412
+ },
413
+ ],
414
+ }
415
+
196
416
  export const MixedTags: StoryFn<TemplateProps> = Template.bind({})
197
417
  MixedTags.args = {
198
418
  messages: [
@@ -248,6 +468,94 @@ MixedTags.args = {
248
468
  ],
249
469
  }
250
470
 
471
+ export const LockedAttachment: StoryFn<TemplateProps> = Template.bind({})
472
+ LockedAttachment.args = {
473
+ messages: [
474
+ {
475
+ id: 'msg-1',
476
+ text: 'Can I have your workout plan?',
477
+ user: storyUsers.visitor,
478
+ },
479
+ {
480
+ id: 'msg-2',
481
+ text: 'Yes, of course!',
482
+ user: storyUsers.creator,
483
+ },
484
+ {
485
+ id: 'msg-3',
486
+ text: 'let me know if you have any questions!',
487
+ user: storyUsers.creator,
488
+ metadata: {
489
+ custom_type: 'MESSAGE_ATTACHMENT',
490
+ amount_text: 'AU$9.99',
491
+ attachment_title: "Alicia's Workout Plan",
492
+ attachment_mime_type: 'video/mp4',
493
+ attachment_thumbnail: '/video-thumbnail-blurred.jpg',
494
+ attachment_detail: '1:20',
495
+ },
496
+ },
497
+ {
498
+ id: 'msg-4',
499
+ text: 'Looks amazing, unlocking now!',
500
+ user: storyUsers.visitor,
501
+ },
502
+ ],
503
+ }
504
+
505
+ export const LockedTextAttachment: StoryFn<TemplateProps> = Template.bind({})
506
+ LockedTextAttachment.args = {
507
+ messages: [
508
+ {
509
+ id: 'msg-1',
510
+ text: 'Can you send me the details privately?',
511
+ user: storyUsers.visitor,
512
+ },
513
+ {
514
+ id: 'msg-2',
515
+ // Empty text: the real content is redacted server-side until purchase,
516
+ // so the bubble renders the blurred placeholder instead.
517
+ text: '',
518
+ user: storyUsers.creator,
519
+ metadata: {
520
+ custom_type: 'MESSAGE_ATTACHMENT',
521
+ amount_text: 'AU$4.99',
522
+ attachment_content_type: 'text',
523
+ },
524
+ },
525
+ {
526
+ id: 'msg-3',
527
+ text: '',
528
+ user: storyUsers.creator,
529
+ metadata: {
530
+ custom_type: 'MESSAGE_ATTACHMENT',
531
+ amount_text: 'AU$4.99',
532
+ payment_status: 'paid',
533
+ attachment_content_type: 'text',
534
+ },
535
+ },
536
+ {
537
+ id: 'msg-4',
538
+ // Buyer reveal: backend writes the real text back onto the message.
539
+ text: "Here's the address — see you Thursday at 7!",
540
+ user: storyUsers.creator,
541
+ metadata: {
542
+ custom_type: 'MESSAGE_ATTACHMENT',
543
+ amount_text: 'AU$4.99',
544
+ payment_status: 'paid',
545
+ attachment_content_type: 'text',
546
+ },
547
+ },
548
+ ],
549
+ }
550
+ LockedTextAttachment.parameters = {
551
+ docs: {
552
+ description: {
553
+ story:
554
+ 'A paid text-only message renders as a regular bubble with blurred placeholder copy (msg-2: pending, msg-3: purchased-but-not-yet-revealed) until the real text is revealed (msg-4).',
555
+ },
556
+ },
557
+ }
558
+
251
559
  export const ChatbotVariants: StoryFn<TemplateProps> = Template.bind({})
252
560
  ChatbotVariants.args = {
253
561
  messages: [
@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
5
5
  import { renderWithProviders, screen } from '../../test/utils'
6
6
 
7
7
  import { OutboundContext } from './OutboundContext'
8
- import { isTipMessage } from './TipMessage'
9
8
 
10
9
  import { CustomMessage } from './index'
11
10
 
@@ -59,16 +58,12 @@ vi.mock('./MessageTag', () => ({
59
58
  isAttachmentMessage: () => false,
60
59
  isChatbotMessage: () => false,
61
60
  isMediaAttachmentMessage: () => false,
62
- isPaidMessage: () => false,
63
61
  isTextAttachmentMessage: () => false,
62
+ isTipOnlyMessage: () => false,
63
+ isTipMessage: () => false,
64
64
  hasPaidDeliveryTag: () => false,
65
65
  }))
66
66
 
67
- vi.mock('./TipMessage', () => ({
68
- TipMessage: () => null,
69
- isTipMessage: vi.fn(() => false),
70
- }))
71
-
72
67
  vi.mock('./LockedAttachment', () => ({
73
68
  default: () => null,
74
69
  }))
@@ -84,11 +79,9 @@ vi.mock('../MediaMessage', () => ({
84
79
 
85
80
  const mockedUseChannelStateContext = vi.mocked(useChannelStateContext)
86
81
  const mockedUseMessageContext = vi.mocked(useMessageContext)
87
- const mockedIsTipMessage = vi.mocked(isTipMessage)
88
82
 
89
83
  describe('CustomMessage', () => {
90
84
  beforeEach(() => {
91
- mockedIsTipMessage.mockReturnValue(false)
92
85
  mockedUseChannelStateContext.mockReturnValue({
93
86
  messages: [{ id: 'message-1', user: { id: 'me' } }],
94
87
  } as never)
@@ -214,51 +207,4 @@ describe('CustomMessage', () => {
214
207
  screen.getByTestId('sent-message-outbound-status')
215
208
  ).toHaveTextContent('July 9th Class absentee')
216
209
  })
217
-
218
- it('does not render an accompanying text bubble for a whitespace-only tip message', () => {
219
- mockedIsTipMessage.mockReturnValue(true)
220
- mockedUseMessageContext.mockReturnValue({
221
- message: {
222
- id: 'message-1',
223
- text: ' \n ',
224
- type: 'regular',
225
- status: 'received',
226
- user: { id: 'me', name: 'Me' },
227
- metadata: {},
228
- },
229
- isMyMessage: () => true,
230
- threadList: false,
231
- editing: false,
232
- endOfGroup: true,
233
- firstOfGroup: true,
234
- groupedByUser: true,
235
- handleAction: vi.fn(),
236
- handleOpenThread: vi.fn(),
237
- handleRetry: vi.fn(),
238
- highlighted: false,
239
- renderText: undefined,
240
- isMessageAIGenerated: undefined,
241
- } as never)
242
-
243
- renderWithProviders(
244
- <OutboundContext.Provider value={{}}>
245
- <CustomMessage />
246
- </OutboundContext.Provider>
247
- )
248
-
249
- expect(screen.queryByText('Message')).not.toBeInTheDocument()
250
- })
251
-
252
- it('renders an accompanying text bubble for a tip message with real text', () => {
253
- mockedIsTipMessage.mockReturnValue(true)
254
- // Default mocked message.text ('Hello') from beforeEach.
255
-
256
- renderWithProviders(
257
- <OutboundContext.Provider value={{}}>
258
- <CustomMessage />
259
- </OutboundContext.Provider>
260
- )
261
-
262
- expect(screen.getByText('Message')).toBeInTheDocument()
263
- })
264
210
  })