@linktr.ee/messaging-react 1.29.0-rc-1776320021 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "1.29.0-rc-1776320021",
3
+ "version": "1.29.0",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,4 +1,5 @@
1
1
  import React from 'react'
2
+ import type { Channel } from 'stream-chat'
2
3
  import { describe, expect, it, vi, beforeEach } from 'vitest'
3
4
 
4
5
  import { renderWithProviders } from '../../test/utils'
@@ -77,8 +78,8 @@ describe('ChannelList', () => {
77
78
  expect(streamProps.onAddedToChannel).toBe(onAddedToChannel)
78
79
  })
79
80
 
80
- it('passes channelRenderFilterFn through to Stream ChannelList', () => {
81
- const filterFn = vi.fn()
81
+ it('wraps channelRenderFilterFn to restore pending messages and delegates to consumer filter', () => {
82
+ const filterFn = vi.fn((channels: Channel[]) => channels)
82
83
 
83
84
  renderWithProviders(
84
85
  React.createElement(ChannelList, {
@@ -89,9 +90,18 @@ describe('ChannelList', () => {
89
90
 
90
91
  expect(streamChannelListMock).toHaveBeenCalledOnce()
91
92
  const streamProps = streamChannelListMock.mock.calls[0][0] as {
92
- channelRenderFilterFn?: unknown
93
+ channelRenderFilterFn?: (channels: unknown[]) => unknown[]
93
94
  }
94
95
 
95
- expect(streamProps.channelRenderFilterFn).toBe(filterFn)
96
+ // The wrapper should not be the same reference as the original filter
97
+ expect(streamProps.channelRenderFilterFn).not.toBe(filterFn)
98
+ expect(typeof streamProps.channelRenderFilterFn).toBe('function')
99
+
100
+ // When the wrapper is called, it should delegate to the consumer's filter
101
+ const mockChannels = [
102
+ { cid: 'ch-1', state: { pending_messages: [], addMessageSorted: vi.fn() } },
103
+ ]
104
+ streamProps.channelRenderFilterFn!(mockChannels)
105
+ expect(filterFn).toHaveBeenCalledWith(mockChannels)
96
106
  })
97
107
  })
@@ -1,7 +1,9 @@
1
1
  import classNames from 'classnames'
2
2
  import React from 'react'
3
+ import type { Channel } from 'stream-chat'
3
4
  import { ChannelList as StreamChannelList } from 'stream-chat-react'
4
5
 
6
+ import { restorePendingMessages } from '../../hooks/useRestorePendingMessages'
5
7
  import { useMessagingContext } from '../../providers/MessagingProvider'
6
8
  import type { ChannelListProps } from '../../types'
7
9
 
@@ -34,6 +36,20 @@ export const ChannelList = React.memo<ChannelListProps>(
34
36
  // Get debug flag from context
35
37
  const { debug = false } = useMessagingContext()
36
38
 
39
+ // Wrap channelRenderFilterFn to restore pending messages for all channels
40
+ // as soon as they are loaded, without waiting for the user to click into each one.
41
+ const wrappedChannelRenderFilterFn = React.useCallback(
42
+ (channels: Channel[]) => {
43
+ for (const channel of channels) {
44
+ restorePendingMessages(channel)
45
+ }
46
+ return channelRenderFilterFn
47
+ ? channelRenderFilterFn(channels)
48
+ : channels
49
+ },
50
+ [channelRenderFilterFn]
51
+ )
52
+
37
53
  if (debug) {
38
54
  console.log('📺 [ChannelList] 🔄 RENDER START', {
39
55
  renderCount: renderCountRef.current,
@@ -72,7 +88,7 @@ export const ChannelList = React.memo<ChannelListProps>(
72
88
  }
73
89
  onMessageNew={onMessageNew}
74
90
  onAddedToChannel={onAddedToChannel}
75
- channelRenderFilterFn={channelRenderFilterFn}
91
+ channelRenderFilterFn={wrappedChannelRenderFilterFn}
76
92
  Preview={CustomChannelPreview}
77
93
  EmptyStateIndicator={customEmptyStateIndicator}
78
94
  />
@@ -1,8 +1,4 @@
1
- import {
2
- ArrowLeftIcon,
3
- DotsThreeIcon,
4
- StarIcon,
5
- } from '@phosphor-icons/react'
1
+ import { ArrowLeftIcon, DotsThreeIcon, StarIcon } from '@phosphor-icons/react'
6
2
  import classNames from 'classnames'
7
3
  import React, { useCallback, useRef } from 'react'
8
4
  import { Channel as ChannelType, LocalMessage } from 'stream-chat'
@@ -17,7 +13,6 @@ import {
17
13
  } from 'stream-chat-react'
18
14
 
19
15
  import { useChannelStar } from '../hooks/useChannelStar'
20
- import { useRestorePendingMessages } from '../hooks/useRestorePendingMessages'
21
16
  import type { ChannelViewProps, LockedAttachmentSource } from '../types'
22
17
 
23
18
  import { Avatar } from './Avatar'
@@ -212,7 +207,10 @@ const ChannelViewInner: React.FC<{
212
207
  onReportParticipantClick?: () => void
213
208
  showStarButton?: boolean
214
209
  chatbotVotingEnabled?: boolean
215
- onAttachmentUnlock?: (message: LocalMessage, channel: ChannelType) => Promise<LockedAttachmentSource>
210
+ onAttachmentUnlock?: (
211
+ message: LocalMessage,
212
+ channel: ChannelType
213
+ ) => Promise<LockedAttachmentSource>
216
214
  onAttachmentDownload?: (message: LocalMessage, channel: ChannelType) => void
217
215
  renderChannelBanner?: () => React.ReactNode
218
216
  customProfileContent?: React.ReactNode
@@ -242,7 +240,6 @@ const ChannelViewInner: React.FC<{
242
240
  renderMessage,
243
241
  }) => {
244
242
  const { channel } = useChannelStateContext()
245
- useRestorePendingMessages(channel)
246
243
  const infoDialogRef = useRef<HTMLDialogElement>(null)
247
244
 
248
245
  // Get participant info for info dialog
@@ -106,6 +106,11 @@ interface TemplateProps {
106
106
  | 'MESSAGE_PAID'
107
107
  | 'MESSAGE_CHATBOT'
108
108
  | 'MESSAGE_ATTACHMENT'
109
+ payment_status?:
110
+ | 'pending'
111
+ | 'paid'
112
+ | 'failed'
113
+ | 'refunded'
109
114
  amount_text?: string
110
115
  attachment_title?: string
111
116
  attachment_mime_type?: string
@@ -316,7 +321,12 @@ PaidAttachment.args = {
316
321
  },
317
322
  {
318
323
  id: 'msg-2',
319
- text: 'yes here it is',
324
+ text: 'Yes, of course!',
325
+ user: storyUsers.creator,
326
+ },
327
+ {
328
+ id: 'msg-3',
329
+ text: 'let me know if you have any questions!',
320
330
  user: storyUsers.creator,
321
331
  metadata: {
322
332
  custom_type: 'MESSAGE_ATTACHMENT',
@@ -327,11 +337,6 @@ PaidAttachment.args = {
327
337
  attachment_detail: '1:20',
328
338
  },
329
339
  },
330
- {
331
- id: 'msg-3',
332
- text: 'let me know if you have any questions!',
333
- user: storyUsers.creator,
334
- },
335
340
  {
336
341
  id: 'msg-4',
337
342
  text: 'Looks amazing, unlocking now!',
@@ -202,7 +202,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
202
202
  }}
203
203
  >
204
204
  {isAttachment ? (
205
- <div className="flex flex-col gap-2">
205
+ <div className={classNames("flex flex-col gap-1", isMine ? "items-end" : "items-start")}>
206
206
  <LockedAttachment
207
207
  title={message.metadata?.attachment_title}
208
208
  mimeType={message.metadata?.attachment_mime_type}
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryFn } from '@storybook/react'
2
- import React, { useEffect, useState } from 'react'
2
+ import React from 'react'
3
3
 
4
- import LockedAttachment, { type CreatorCardProps, type VisitorCardProps } from '.'
4
+ import LockedAttachment from '.'
5
5
 
6
6
  const VIDEO_THUMBNAIL_BLURRED = '/video-thumbnail-blurred.jpg'
7
7
  const VIDEO_SOURCE = '/video-source.mp4'
@@ -22,42 +22,11 @@ const meta: Meta = {
22
22
  }
23
23
  export default meta
24
24
 
25
- type InteractiveProps = Omit<VisitorCardProps, 'onUnlock' | 'onDownload'> & {
26
- unlockedSource: string
25
+ const withDelay = <T,>(fn: () => Promise<T> | T) => async (): Promise<T> => {
26
+ await new Promise((resolve) => setTimeout(resolve, 1000))
27
+ return fn()
27
28
  }
28
29
 
29
- /** Simulates a slow network by withholding the source for 2s, then loading via blob URL. */
30
- type SlowCreatorPreviewProps = Omit<CreatorCardProps, 'isPreview'> & { sourcePath: string }
31
- const SlowCreatorPreview = ({ sourcePath, ...props }: SlowCreatorPreviewProps) => {
32
- const [source, setSource] = useState<string | undefined>(undefined)
33
- useEffect(() => {
34
- setSource(undefined)
35
- let cancelled = false
36
- const load = async () => {
37
- await new Promise((resolve) => setTimeout(resolve, 2000))
38
- const res = await fetch(sourcePath)
39
- const blob = await res.blob()
40
- if (!cancelled) setSource(URL.createObjectURL(blob))
41
- }
42
- void load()
43
- return () => { cancelled = true }
44
- }, [sourcePath])
45
- return <LockedAttachment isCreator isPreview source={source} {...props} />
46
- }
47
-
48
- const Interactive = ({ unlockedSource, ...props }: InteractiveProps) => (
49
- <LockedAttachment
50
- {...props}
51
- onUnlock={async () => {
52
- await new Promise((resolve) => setTimeout(resolve, 1000))
53
- const res = await fetch(unlockedSource)
54
- const blob = await res.blob()
55
- return { source: URL.createObjectURL(blob) }
56
- }}
57
- onDownload={() => {}}
58
- />
59
- )
60
-
61
30
  const VARIANTS = [
62
31
  {
63
32
  label: 'Video',
@@ -168,13 +137,14 @@ export const Visitor: StoryFn = () => (
168
137
  </td>
169
138
  {VARIANTS.map(({ title, mimeType, detail, thumbnail, source }) => (
170
139
  <td key={mimeType} className="align-top">
171
- <Interactive
140
+ <LockedAttachment
172
141
  title={title}
173
142
  amountText="AU$9.99"
174
143
  thumbnail={thumbnail}
175
144
  mimeType={mimeType}
176
145
  detail={detail}
177
- unlockedSource={source}
146
+ onUnlock={withDelay(() => ({ source }))}
147
+ onDownload={() => {}}
178
148
  />
179
149
  </td>
180
150
  ))}
@@ -185,14 +155,15 @@ export const Visitor: StoryFn = () => (
185
155
  </td>
186
156
  {VARIANTS.map(({ title, mimeType, detail, thumbnail, source }) => (
187
157
  <td key={mimeType} className="align-top">
188
- <Interactive
158
+ <LockedAttachment
189
159
  title={title}
190
160
  amountText="AU$9.99"
191
161
  thumbnail={thumbnail}
192
162
  mimeType={mimeType}
193
163
  detail={detail}
194
- unlockedSource={source}
195
164
  paymentStatus="paid"
165
+ onUnlock={withDelay(() => ({ source }))}
166
+ onDownload={() => {}}
196
167
  />
197
168
  </td>
198
169
  ))}
@@ -230,9 +201,10 @@ export const Creator: StoryFn = () => (
230
201
  </td>
231
202
  {VARIANTS.map(({ mimeType, detail, thumbnail, source }) => (
232
203
  <td key={mimeType} className="align-top">
233
- <SlowCreatorPreview
204
+ <LockedAttachment
205
+ isPreview
234
206
  thumbnail={thumbnail}
235
- sourcePath={source}
207
+ source={source}
236
208
  mimeType={mimeType}
237
209
  detail={detail}
238
210
  placeholderAmountText="A$0.00"
@@ -247,7 +219,6 @@ export const Creator: StoryFn = () => (
247
219
  {VARIANTS.map(({ title, mimeType, detail, thumbnail, source }) => (
248
220
  <td key={mimeType} className="align-top">
249
221
  <LockedAttachment
250
- isCreator
251
222
  title={title}
252
223
  thumbnail={thumbnail}
253
224
  source={source}
@@ -266,7 +237,7 @@ export const Creator: StoryFn = () => (
266
237
  {VARIANTS.map(({ title, mimeType, detail, thumbnail, source }) => (
267
238
  <td key={mimeType} className="align-top">
268
239
  <LockedAttachment
269
- isCreator
240
+
270
241
  title={title}
271
242
  thumbnail={thumbnail}
272
243
  source={source}
@@ -284,7 +255,6 @@ export const Creator: StoryFn = () => (
284
255
  {VARIANTS.map(({ title, mimeType, detail, thumbnail, source }) => (
285
256
  <td key={mimeType} className="align-top">
286
257
  <LockedAttachment
287
- isCreator
288
258
  title={title}
289
259
  thumbnail={thumbnail}
290
260
  source={source}
@@ -311,7 +281,6 @@ export const NoThumbnail: StoryFn = () => (
311
281
  {NO_THUMBNAIL_VARIANTS.map(({ title, mimeType, detail, source }) => (
312
282
  <td key={mimeType} className="align-top">
313
283
  <LockedAttachment
314
- isCreator
315
284
  title={title}
316
285
  source={source}
317
286
  mimeType={mimeType}
@@ -327,12 +296,13 @@ export const NoThumbnail: StoryFn = () => (
327
296
  </td>
328
297
  {NO_THUMBNAIL_VARIANTS.map(({ title, mimeType, detail, source }) => (
329
298
  <td key={mimeType} className="align-top">
330
- <Interactive
299
+ <LockedAttachment
331
300
  title={title}
332
301
  amountText="AU$9.99"
333
302
  mimeType={mimeType}
334
303
  detail={detail}
335
- unlockedSource={source}
304
+ onUnlock={withDelay(() => ({ source }))}
305
+ onDownload={() => {}}
336
306
  />
337
307
  </td>
338
308
  ))}
@@ -1,8 +1,7 @@
1
- import { renderHook } from '@testing-library/react'
2
1
  import type { Channel } from 'stream-chat'
3
2
  import { describe, expect, it, vi, beforeEach } from 'vitest'
4
3
 
5
- import { useRestorePendingMessages } from './useRestorePendingMessages'
4
+ import { restorePendingMessages } from './useRestorePendingMessages'
6
5
 
7
6
  const createChannel = (
8
7
  overrides: {
@@ -21,7 +20,7 @@ const createChannel = (
21
20
  },
22
21
  }) as unknown as Channel
23
22
 
24
- describe('useRestorePendingMessages', () => {
23
+ describe('restorePendingMessages', () => {
25
24
  beforeEach(() => {
26
25
  vi.clearAllMocks()
27
26
  })
@@ -35,7 +34,7 @@ describe('useRestorePendingMessages', () => {
35
34
  }
36
35
  const channel = createChannel({ pending_messages: [pendingMsg] })
37
36
 
38
- renderHook(() => useRestorePendingMessages(channel))
37
+ restorePendingMessages(channel)
39
38
 
40
39
  expect(channel.state.addMessageSorted).toHaveBeenCalledOnce()
41
40
  expect(channel.state.addMessageSorted).toHaveBeenCalledWith(
@@ -58,7 +57,7 @@ describe('useRestorePendingMessages', () => {
58
57
  }
59
58
  const channel = createChannel({ pending_messages: [msg1, msg2] })
60
59
 
61
- renderHook(() => useRestorePendingMessages(channel))
60
+ restorePendingMessages(channel)
62
61
 
63
62
  expect(channel.state.addMessageSorted).toHaveBeenCalledTimes(2)
64
63
  expect(channel.state.addMessageSorted).toHaveBeenCalledWith(msg1.message)
@@ -68,54 +67,23 @@ describe('useRestorePendingMessages', () => {
68
67
  it('does nothing when there are no pending messages', () => {
69
68
  const channel = createChannel({ pending_messages: [] })
70
69
 
71
- renderHook(() => useRestorePendingMessages(channel))
70
+ restorePendingMessages(channel)
72
71
 
73
72
  expect(channel.state.addMessageSorted).not.toHaveBeenCalled()
74
73
  })
75
74
 
76
- it('only restores once per channel even if re-rendered', () => {
77
- const pendingMsg = {
78
- message: {
79
- id: 'msg-1',
80
- text: 'Hello',
81
- },
82
- }
83
- const channel = createChannel({ pending_messages: [pendingMsg] })
84
-
85
- const { rerender } = renderHook(() => useRestorePendingMessages(channel))
86
-
87
- rerender()
88
- rerender()
89
-
90
- expect(channel.state.addMessageSorted).toHaveBeenCalledOnce()
91
- })
92
-
93
- it('restores again when switching to a different channel', () => {
94
- const pendingMsg = {
95
- message: {
96
- id: 'msg-1',
97
- text: 'Hello',
98
- },
99
- }
100
- const channel1 = createChannel({
75
+ it('does nothing when pending_messages is undefined', () => {
76
+ const channel = {
101
77
  cid: 'messaging:channel-1',
102
- pending_messages: [pendingMsg],
103
- })
104
- const channel2 = createChannel({
105
- cid: 'messaging:channel-2',
106
- pending_messages: [pendingMsg],
107
- })
108
-
109
- const { rerender } = renderHook(
110
- ({ channel }) => useRestorePendingMessages(channel),
111
- { initialProps: { channel: channel1 } }
112
- )
113
-
114
- expect(channel1.state.addMessageSorted).toHaveBeenCalledOnce()
78
+ state: {
79
+ pending_messages: undefined,
80
+ addMessageSorted: vi.fn(),
81
+ },
82
+ } as unknown as Channel
115
83
 
116
- rerender({ channel: channel2 })
84
+ restorePendingMessages(channel)
117
85
 
118
- expect(channel2.state.addMessageSorted).toHaveBeenCalledOnce()
86
+ expect(channel.state.addMessageSorted).not.toHaveBeenCalled()
119
87
  })
120
88
 
121
89
  it('handles pending messages with no metadata gracefully', () => {
@@ -126,7 +94,7 @@ describe('useRestorePendingMessages', () => {
126
94
  pending_messages: [noMetadataMsg],
127
95
  })
128
96
 
129
- renderHook(() => useRestorePendingMessages(channel))
97
+ restorePendingMessages(channel)
130
98
 
131
99
  expect(channel.state.addMessageSorted).toHaveBeenCalledOnce()
132
100
  expect(channel.state.addMessageSorted).toHaveBeenCalledWith(
@@ -1,4 +1,3 @@
1
- import { useEffect, useRef } from 'react'
2
1
  import type { Channel } from 'stream-chat'
3
2
 
4
3
  /**
@@ -6,31 +5,15 @@ import type { Channel } from 'stream-chat'
6
5
  * appear as if they were already sent.
7
6
  *
8
7
  * Stream's pending-messages feature removes messages from the channel view
9
- * once client state is lost (e.g. page refresh). This hook works around that
10
- * limitation by reading `channel.state.pending_messages` on channel load and
8
+ * once client state is lost (e.g. page refresh). This function works around
9
+ * that limitation by reading `channel.state.pending_messages` and
11
10
  * re-inserting them via `channel.state.addMessageSorted`.
12
- *
13
- * The restoration runs once per channel (tracked by `channel.cid`).
14
11
  */
15
- export function useRestorePendingMessages(channel: Channel) {
16
- const restoredChannelRef = useRef<string | null>(null)
17
-
18
- useEffect(() => {
19
- const cid = channel.cid
20
- if (!cid || restoredChannelRef.current === cid) return
21
-
22
- const pendingMessages = channel.state.pending_messages
23
- if (!pendingMessages?.length) {
24
- // No pending messages — mark as restored so we don't re-check on
25
- // re-renders, but still allow a retry if the channel object changes.
26
- restoredChannelRef.current = cid
27
- return
28
- }
29
-
30
- for (const pending of pendingMessages) {
31
- channel.state.addMessageSorted(pending.message)
32
- }
12
+ export function restorePendingMessages(channel: Channel) {
13
+ const pendingMessages = channel.state.pending_messages
14
+ if (!pendingMessages?.length) return
33
15
 
34
- restoredChannelRef.current = cid
35
- }, [channel])
16
+ for (const pending of pendingMessages) {
17
+ channel.state.addMessageSorted(pending.message)
18
+ }
36
19
  }