@linktr.ee/messaging-react 4.1.6-rc-1787171046 → 4.1.6-rc-1787173663

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": "4.1.6-rc-1787171046",
3
+ "version": "4.1.6-rc-1787173663",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,7 +50,7 @@
50
50
  "chromatic:local": "yarn storybook:build && chromatic"
51
51
  },
52
52
  "dependencies": {
53
- "@linktr.ee/messaging-core": "2.5.0-rc-1787171046",
53
+ "@linktr.ee/messaging-core": "2.5.0-rc-1787173663",
54
54
  "@linktr.ee/messaging-taxonomy": "^0.10.0",
55
55
  "@phosphor-icons/react": "^2.1.10"
56
56
  },
@@ -27,130 +27,4 @@ describe('Avatar', () => {
27
27
  it('keeps fixed emoji sizes for larger avatars', () => {
28
28
  expect(emojiFontSize(40)).toHaveStyle({ fontSize: '14px' })
29
29
  })
30
-
31
- it.each([
32
- [32, '14px'],
33
- [55, '14px'],
34
- [56, '18px'],
35
- [119, '18px'],
36
- [120, '36px'],
37
- ])(
38
- 'uses the exact fixed font size at the %spx boundary',
39
- (size, fontSize) => {
40
- expect(emojiFontSize(size)).toHaveStyle({ fontSize })
41
- }
42
- )
43
-
44
- it('does not show a star by default but shows one when starred', () => {
45
- const defaultAvatar = renderWithProviders(
46
- <Avatar id="user-1" name="Alice" />
47
- )
48
- expect(defaultAvatar.container.querySelector('svg')).not.toBeInTheDocument()
49
-
50
- const starredAvatar = renderWithProviders(
51
- <Avatar id="user-1" name="Alice" starred />
52
- )
53
- expect(starredAvatar.container.querySelector('svg')).toBeInTheDocument()
54
- })
55
-
56
- it('uses a squircle by default and a circle when explicitly requested', () => {
57
- const squircleAvatar = renderWithProviders(
58
- <Avatar id="user-1" name="Alice" />
59
- )
60
- const squircleElements = squircleAvatar.container.querySelectorAll(
61
- '[style*="border-radius"]'
62
- )
63
- expect(squircleElements).toHaveLength(3)
64
- squircleElements.forEach((element) =>
65
- expect(element).toHaveStyle({ borderRadius: '1rem' })
66
- )
67
-
68
- const circleAvatar = renderWithProviders(
69
- <Avatar id="user-1" name="Alice" shape="circle" />
70
- )
71
- const circleElements = circleAvatar.container.querySelectorAll(
72
- '[style*="border-radius"]'
73
- )
74
- expect(circleElements).toHaveLength(3)
75
- circleElements.forEach((element) =>
76
- expect(element).toHaveStyle({ borderRadius: '50%' })
77
- )
78
- })
79
-
80
- it('only applies the AI ring when dmAgentEnabled is true', () => {
81
- const defaultAvatar = renderWithProviders(
82
- <Avatar id="user-1" name="Alice" size={40} />
83
- )
84
- expect(
85
- defaultAvatar.container.querySelector('[data-testid="avatar-ring"]')
86
- ).not.toHaveStyle({
87
- borderWidth: '2px',
88
- borderStyle: 'solid',
89
- })
90
-
91
- const enabledAvatar = renderWithProviders(
92
- <Avatar id="user-1" name="Alice" size={40} dmAgentEnabled />
93
- )
94
- expect(
95
- enabledAvatar.container.querySelector('[data-testid="avatar-ring"]')
96
- ).toHaveStyle({
97
- borderWidth: '2px',
98
- borderStyle: 'solid',
99
- borderColor: 'var(--AI-Gradient, #7F22FE)',
100
- boxShadow: 'inset 0 1px 2px 0 rgba(255, 255, 255, 0.5)',
101
- })
102
-
103
- const smallEnabledAvatar = renderWithProviders(
104
- <Avatar id="user-1" name="Alice" size={20} dmAgentEnabled />
105
- )
106
- expect(
107
- smallEnabledAvatar.container.querySelector('[data-testid="avatar-ring"]')
108
- ).toHaveStyle({ borderWidth: '1px' })
109
- })
110
-
111
- it('renders an image instead of the emoji fallback when image is provided', () => {
112
- const fallbackAvatar = renderWithProviders(<Avatar id="" name="Alice" />)
113
- expect(
114
- fallbackAvatar.container.querySelector('img')
115
- ).not.toBeInTheDocument()
116
- expect(fallbackAvatar.container).toHaveTextContent('🍎')
117
- expect(
118
- fallbackAvatar.container.querySelector('.avatar-fallback')
119
- ).toHaveClass(
120
- 'avatar-fallback',
121
- 'flex',
122
- 'h-full',
123
- 'w-full',
124
- 'items-center',
125
- 'justify-center',
126
- 'font-semibold',
127
- 'select-none',
128
- 'transition-colors'
129
- )
130
-
131
- const imageAvatar = renderWithProviders(
132
- <Avatar id="" name="Alice" image="https://example.com/avatar.png" />
133
- )
134
- expect(imageAvatar.container.querySelector('img')).toHaveAttribute(
135
- 'src',
136
- 'https://example.com/avatar.png'
137
- )
138
- expect(imageAvatar.container).not.toHaveTextContent('🍎')
139
- })
140
-
141
- it('applies the fallback background color and forwards custom classes', () => {
142
- const { container } = renderWithProviders(
143
- <Avatar id="" name="Alice" className="custom-avatar" />
144
- )
145
- const root = container.firstElementChild
146
- const ring = container.querySelector('[data-testid="avatar-ring"]')
147
-
148
- expect(root).toHaveClass('relative', 'flex-shrink-0', 'custom-avatar')
149
- expect(ring).toHaveClass('h-full', 'w-full', 'bg-transparent')
150
- expect(root).toHaveStyle({
151
- backgroundColor: '#FFD1DD',
152
- width: '40px',
153
- height: '40px',
154
- })
155
- })
156
30
  })
@@ -29,43 +29,4 @@ describe('getAvatarBgColor', () => {
29
29
  expect(color).toBe(emojiBgColors[emoji])
30
30
  expect(getAvatarBgColor(id)).toBe(color)
31
31
  })
32
-
33
- it.each([
34
- ['', '🍎', '#FFD1DD'],
35
- ['id-2', '🍌', '#FFF1C9'],
36
- ['id-3', '🍇', '#E6D9FF'],
37
- ['id-4', '🍊', '#D6F4FF'],
38
- ['id-5', '🍓', '#FFE0AE'],
39
- ['id-6', '🥥', '#F7E7D8'],
40
- ['id-7', '🍒', '#DFF4D8'],
41
- ['id-8', '🥭', '#FFD8C2'],
42
- ['id-9', '🍉', '#D6EBFF'],
43
- ['id-10', '🍋', '#CAEEB5'],
44
- ['id-11', '🥝', '#FFE7C7'],
45
- ['id-12', '🫒', '#F6E3B4'],
46
- ['id-13', '🍈', '#D9F7E1'],
47
- ])('returns the exact fruit pair for %j', (id, emoji, bgColor) => {
48
- expect(getAvatarEmoji(id)).toBe(emoji)
49
- expect(getAvatarBgColor(id)).toBe(bgColor)
50
- })
51
-
52
- it('uses the absolute hash value for ids with a negative raw hash', () => {
53
- const id = 'id-100'
54
-
55
- expect(getAvatarEmoji(id)).toBe('🥭')
56
- expect(getAvatarBgColor(id)).toBe('#FFD8C2')
57
- })
58
-
59
- it('is deterministic and keeps emoji and background color paired', () => {
60
- const ids = ['', 'id-2', 'id-7', 'id-13', 'id-100']
61
-
62
- ids.forEach((id) => {
63
- const emoji = getAvatarEmoji(id)
64
- const color = getAvatarBgColor(id)
65
-
66
- expect(getAvatarEmoji(id)).toBe(emoji)
67
- expect(getAvatarBgColor(id)).toBe(color)
68
- expect(emojiBgColors[emoji]).toBe(color)
69
- })
70
- })
71
32
  })
@@ -0,0 +1,87 @@
1
+ import type { Channel } from 'stream-chat'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { fireEvent, renderWithProviders, screen } from '../../test/utils'
5
+
6
+ import {
7
+ ChannelListProvider,
8
+ useChannelListContext,
9
+ } from './ChannelListContext'
10
+
11
+ const channel = { id: 'channel-1' } as Channel
12
+
13
+ function ContextConsumer() {
14
+ const value = useChannelListContext()
15
+
16
+ return (
17
+ <div>
18
+ <span data-testid="selected-channel">
19
+ {value.selectedChannel?.id ?? 'undefined'}
20
+ </span>
21
+ <span data-testid="debug">{String(value.debug)}</span>
22
+ <span data-testid="render-message-preview">
23
+ {value.renderMessagePreview ? 'provided' : 'undefined'}
24
+ </span>
25
+ <span data-testid="channel-preview">
26
+ {value.channelPreview ? 'provided' : 'undefined'}
27
+ </span>
28
+ <span data-testid="viewer-language">
29
+ {value.viewerLanguage ?? 'undefined'}
30
+ </span>
31
+ <button type="button" onClick={() => value.onChannelSelect(channel)}>
32
+ Select channel
33
+ </button>
34
+ </div>
35
+ )
36
+ }
37
+
38
+ describe('ChannelListContext', () => {
39
+ it('provides safe documented defaults without a provider', () => {
40
+ renderWithProviders(<ContextConsumer />)
41
+
42
+ expect(screen.getByTestId('selected-channel')).toHaveTextContent(
43
+ 'undefined'
44
+ )
45
+ expect(screen.getByTestId('debug')).toHaveTextContent('false')
46
+ expect(screen.getByTestId('render-message-preview')).toHaveTextContent(
47
+ 'undefined'
48
+ )
49
+ expect(screen.getByTestId('channel-preview')).toHaveTextContent('undefined')
50
+ expect(screen.getByTestId('viewer-language')).toHaveTextContent('undefined')
51
+ expect(() => fireEvent.click(screen.getByRole('button'))).not.toThrow()
52
+ })
53
+
54
+ it('exposes the value supplied by a provider', () => {
55
+ const onChannelSelect = vi.fn()
56
+ const ChannelPreview = () => null
57
+ const renderMessagePreview = () => 'preview'
58
+
59
+ renderWithProviders(
60
+ <ChannelListProvider
61
+ value={{
62
+ selectedChannel: channel,
63
+ onChannelSelect,
64
+ debug: true,
65
+ renderMessagePreview,
66
+ channelPreview: ChannelPreview,
67
+ viewerLanguage: 'es',
68
+ }}
69
+ >
70
+ <ContextConsumer />
71
+ </ChannelListProvider>
72
+ )
73
+
74
+ expect(screen.getByTestId('selected-channel')).toHaveTextContent(
75
+ 'channel-1'
76
+ )
77
+ expect(screen.getByTestId('debug')).toHaveTextContent('true')
78
+ expect(screen.getByTestId('render-message-preview')).toHaveTextContent(
79
+ 'provided'
80
+ )
81
+ expect(screen.getByTestId('channel-preview')).toHaveTextContent('provided')
82
+ expect(screen.getByTestId('viewer-language')).toHaveTextContent('es')
83
+
84
+ fireEvent.click(screen.getByRole('button'))
85
+ expect(onChannelSelect).toHaveBeenCalledWith(channel)
86
+ })
87
+ })
@@ -1,6 +1,7 @@
1
- import { act, fireEvent } from '@testing-library/react'
1
+ import { MESSAGE_CUSTOM_TYPE } from '@linktr.ee/messaging-taxonomy'
2
+ import { act, createEvent, fireEvent } from '@testing-library/react'
2
3
  import { Channel, LocalMessage, StreamChat } from 'stream-chat'
3
- import { describe, expect, it, vi } from 'vitest'
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
5
 
5
6
  import { renderWithProviders, screen } from '../../test/utils'
6
7
  import type { ChannelPreviewProps } from '../../types'
@@ -13,11 +14,28 @@ const mockUser = {
13
14
  name: 'Current User',
14
15
  }
15
16
 
17
+ const mockChatContext = vi.hoisted(() => ({
18
+ current: {
19
+ client: { userID: 'current-user' },
20
+ } as { client?: { userID?: string } },
21
+ }))
22
+
16
23
  vi.mock('stream-chat-react', async (importOriginal) => ({
17
24
  ...(await importOriginal<typeof import('stream-chat-react')>()),
18
- useChatContext: () => ({ client: { userID: 'current-user' } }),
25
+ useChatContext: () => mockChatContext.current,
19
26
  }))
20
27
 
28
+ vi.mock('../../hooks/useChannelStar', async (importOriginal) => {
29
+ const actual =
30
+ await importOriginal<typeof import('../../hooks/useChannelStar')>()
31
+
32
+ return {
33
+ ...actual,
34
+ useChannelStar: (channel?: Channel) =>
35
+ channel?.state ? actual.useChannelStar(channel) : false,
36
+ }
37
+ })
38
+
21
39
  type MockChannel = Channel & {
22
40
  emitMemberUpdated: (member?: { pinned_at?: string | null }) => void
23
41
  }
@@ -73,6 +91,396 @@ describe('CustomChannelPreview', () => {
73
91
  onChannelSelect: vi.fn(),
74
92
  }
75
93
 
94
+ beforeEach(() => {
95
+ mockChatContext.current = {
96
+ client: { userID: 'current-user' },
97
+ }
98
+ defaultProps.onChannelSelect.mockClear()
99
+ })
100
+
101
+ it('applies selected and unselected row styling from context', () => {
102
+ const channel = createMockChannel([])
103
+ const otherChannel = createMockChannel([])
104
+ otherChannel.id = 'channel-2'
105
+
106
+ const { rerender } = renderWithProviders(
107
+ <ChannelListProvider
108
+ value={{
109
+ selectedChannel: channel,
110
+ onChannelSelect: defaultProps.onChannelSelect,
111
+ debug: false,
112
+ }}
113
+ >
114
+ <CustomChannelPreview {...defaultProps} channel={channel} />
115
+ </ChannelListProvider>
116
+ )
117
+
118
+ let row = screen.getByRole('button')
119
+ expect(row).toHaveClass('bg-black/[0.04]')
120
+ expect(row).not.toHaveClass('hover:bg-black/[0.02]')
121
+
122
+ rerender(
123
+ <ChannelListProvider
124
+ value={{
125
+ selectedChannel: otherChannel,
126
+ onChannelSelect: defaultProps.onChannelSelect,
127
+ debug: false,
128
+ }}
129
+ >
130
+ <CustomChannelPreview {...defaultProps} channel={channel} />
131
+ </ChannelListProvider>
132
+ )
133
+
134
+ row = screen.getByRole('button')
135
+ expect(row).not.toHaveClass('bg-black/[0.04]')
136
+ expect(row).toHaveClass('hover:bg-black/[0.02]')
137
+
138
+ rerender(
139
+ <CustomChannelPreview {...defaultProps} channel={channel} unread={1} />
140
+ )
141
+ expect(screen.getByRole('button')).toHaveClass('hover:bg-black/[0.02]')
142
+ })
143
+
144
+ it('selects the channel by click and activation keys only', () => {
145
+ const channel = createMockChannel([])
146
+ const onChannelSelect = vi.fn()
147
+ const { container } = renderWithProviders(
148
+ <ChannelListProvider value={{ onChannelSelect, debug: false }}>
149
+ <CustomChannelPreview channel={channel} />
150
+ </ChannelListProvider>
151
+ )
152
+ const row = container.querySelector('[role="button"]') as HTMLElement
153
+
154
+ fireEvent.click(row)
155
+ fireEvent.keyDown(row, { key: 'Enter' })
156
+ const spaceEvent = createEvent.keyDown(row, { key: ' ' })
157
+ fireEvent(row, spaceEvent)
158
+
159
+ expect(spaceEvent.defaultPrevented).toBe(true)
160
+ expect(onChannelSelect).toHaveBeenCalledTimes(3)
161
+ expect(onChannelSelect).toHaveBeenNthCalledWith(1, channel)
162
+ expect(onChannelSelect).toHaveBeenNthCalledWith(2, channel)
163
+ expect(onChannelSelect).toHaveBeenNthCalledWith(3, channel)
164
+
165
+ fireEvent.keyDown(row, { key: 'a' })
166
+ fireEvent.keyDown(row, { key: 'Enter', repeat: true })
167
+ expect(onChannelSelect).toHaveBeenCalledTimes(3)
168
+ })
169
+
170
+ it('renders with an unhydrated client and resolves the fallback member', () => {
171
+ mockChatContext.current = { client: undefined }
172
+ const channel = createMockChannel([])
173
+
174
+ expect(() =>
175
+ renderWithProviders(
176
+ <CustomChannelPreview {...defaultProps} channel={channel} />
177
+ )
178
+ ).not.toThrow()
179
+ expect(screen.getByText('Current User')).toBeInTheDocument()
180
+ })
181
+
182
+ it('resolves the other participant using the current client user id', () => {
183
+ const channel = createMockChannel([])
184
+
185
+ renderWithProviders(
186
+ <CustomChannelPreview {...defaultProps} channel={channel} />
187
+ )
188
+ expect(screen.getByText('Alice')).toBeInTheDocument()
189
+
190
+ mockChatContext.current = { client: { userID: 'participant-1' } }
191
+ renderWithProviders(
192
+ <CustomChannelPreview {...defaultProps} channel={channel} />
193
+ )
194
+ expect(screen.getByText('Current User')).toBeInTheDocument()
195
+ })
196
+
197
+ it('renders safe fallbacks when channel state is unavailable', () => {
198
+ const channel = createMockChannel([])
199
+ Object.assign(channel, { state: undefined })
200
+
201
+ expect(() =>
202
+ renderWithProviders(
203
+ <CustomChannelPreview {...defaultProps} channel={channel} />
204
+ )
205
+ ).not.toThrow()
206
+ expect(screen.getByText('Unknown member')).toBeInTheDocument()
207
+ expect(screen.getByText('No messages yet')).toBeInTheDocument()
208
+ })
209
+
210
+ it('renders a participant image only when one is supplied', () => {
211
+ const channel = createMockChannel([])
212
+ channel.state.members['participant-1'].user = {
213
+ id: 'participant-1',
214
+ name: 'Alice',
215
+ image: 'https://example.com/alice.png',
216
+ }
217
+
218
+ const { container, rerender } = renderWithProviders(
219
+ <CustomChannelPreview {...defaultProps} channel={channel} />
220
+ )
221
+ expect(container.querySelectorAll('img')).toHaveLength(1)
222
+ expect(container.querySelector('img')).toHaveAttribute(
223
+ 'src',
224
+ 'https://example.com/alice.png'
225
+ )
226
+
227
+ channel.state.members['participant-1'].user = {
228
+ id: 'participant-1',
229
+ name: 'Alice',
230
+ }
231
+ rerender(
232
+ <CustomChannelPreview {...defaultProps} channel={channel} unread={1} />
233
+ )
234
+ expect(container.querySelectorAll('img')).toHaveLength(0)
235
+ })
236
+
237
+ it.each([
238
+ [undefined, undefined],
239
+ [3, '3'],
240
+ [99, '99'],
241
+ [100, '99+'],
242
+ ])('renders the unread badge for unread=%s', (unread, expectedBadge) => {
243
+ const channel = createMockChannel([])
244
+ const { container } = renderWithProviders(
245
+ <CustomChannelPreview
246
+ {...defaultProps}
247
+ channel={channel}
248
+ unread={unread}
249
+ />
250
+ )
251
+
252
+ if (expectedBadge) {
253
+ expect(screen.getByText(expectedBadge)).toBeInTheDocument()
254
+ } else {
255
+ expect(container.textContent).not.toContain('99+')
256
+ expect(container.textContent).not.toMatch(/(?:^|\D)3(?:\D|$)/)
257
+ }
258
+ })
259
+
260
+ it('renders a timestamp only when the last message has created_at', () => {
261
+ const channel = createMockChannel([
262
+ {
263
+ id: 'msg-1',
264
+ text: 'Recent message',
265
+ type: 'regular',
266
+ created_at: new Date(),
267
+ user: { id: 'participant-1', name: 'Alice' },
268
+ },
269
+ ])
270
+
271
+ const { container, rerender } = renderWithProviders(
272
+ <CustomChannelPreview {...defaultProps} channel={channel} />
273
+ )
274
+ const timestamp = () =>
275
+ container.querySelector('h3')?.parentElement?.querySelector('span')
276
+
277
+ expect(timestamp()).toHaveTextContent('Just now')
278
+ expect(screen.getByText('Just now')).toBeInTheDocument()
279
+
280
+ Object.assign(channel.state.messages[0], { created_at: undefined })
281
+ rerender(
282
+ <CustomChannelPreview {...defaultProps} channel={channel} unread={1} />
283
+ )
284
+ expect(timestamp()).toBeNull()
285
+ expect(screen.queryByText('Just now')).not.toBeInTheDocument()
286
+ })
287
+
288
+ it.each([
289
+ [
290
+ 'tip with text',
291
+ {
292
+ metadata: { custom_type: MESSAGE_CUSTOM_TYPE.TIP },
293
+ text: 'A generous tip',
294
+ },
295
+ 'A generous tip',
296
+ ],
297
+ [
298
+ 'tip without text',
299
+ { metadata: { custom_type: MESSAGE_CUSTOM_TYPE.TIP } },
300
+ 'Sent a tip',
301
+ ],
302
+ [
303
+ 'paid with text',
304
+ {
305
+ metadata: { custom_type: MESSAGE_CUSTOM_TYPE.PAID },
306
+ text: 'Paid message',
307
+ },
308
+ 'Paid message',
309
+ ],
310
+ [
311
+ 'paid without text',
312
+ { metadata: { custom_type: MESSAGE_CUSTOM_TYPE.PAID } },
313
+ 'Sent a message',
314
+ ],
315
+ [
316
+ 'link attachment',
317
+ { attachments: [{ og_scrape_url: 'https://example.com' }] },
318
+ 'https://example.com',
319
+ ],
320
+ [
321
+ 'image attachment',
322
+ { attachments: [{ type: 'image' }] },
323
+ '📷 Sent an image',
324
+ ],
325
+ [
326
+ 'video attachment',
327
+ { attachments: [{ type: 'video' }] },
328
+ '🎥 Sent a video',
329
+ ],
330
+ ['audio attachment', { attachments: [{ type: 'audio' }] }, '🎵 Sent audio'],
331
+ ['file attachment', { attachments: [{ type: 'file' }] }, '📎 Sent a file'],
332
+ [
333
+ 'unknown attachment',
334
+ { attachments: [{ type: 'unknown' }] },
335
+ '📎 Sent an attachment',
336
+ ],
337
+ ])('renders the %s preview text', (_name, message, expectedText) => {
338
+ const channel = createMockChannel([
339
+ {
340
+ id: 'msg-1',
341
+ type: 'regular',
342
+ ...message,
343
+ user: { id: 'participant-1', name: 'Alice' },
344
+ },
345
+ ])
346
+
347
+ renderWithProviders(
348
+ <CustomChannelPreview {...defaultProps} channel={channel} />
349
+ )
350
+ expect(screen.getByText(expectedText)).toBeInTheDocument()
351
+ })
352
+
353
+ it('prefixes chatbot previews but not normal message previews', () => {
354
+ const channel = createMockChannel([
355
+ {
356
+ id: 'msg-1',
357
+ text: 'Automated response',
358
+ type: 'regular',
359
+ metadata: { custom_type: MESSAGE_CUSTOM_TYPE.CHATBOT },
360
+ },
361
+ ])
362
+ const { rerender } = renderWithProviders(
363
+ <CustomChannelPreview {...defaultProps} channel={channel} />
364
+ )
365
+ expect(screen.getByText('✨ Automated response')).toBeInTheDocument()
366
+
367
+ channel.state.messages[0].metadata = {}
368
+ channel.state.messages[0].text = 'Human response'
369
+ rerender(
370
+ <CustomChannelPreview {...defaultProps} channel={channel} unread={1} />
371
+ )
372
+ expect(screen.getByText('Human response')).toBeInTheDocument()
373
+ expect(screen.queryByText('✨ Human response')).not.toBeInTheDocument()
374
+ })
375
+
376
+ it('uses renderMessagePreview from context with the resolved default text', () => {
377
+ const channel = createMockChannel([
378
+ {
379
+ id: 'msg-1',
380
+ text: 'Default preview',
381
+ type: 'regular',
382
+ },
383
+ ])
384
+ const renderMessagePreview = vi.fn(
385
+ (_message: LocalMessage | undefined, _defaultPreview?: string) =>
386
+ 'Custom preview'
387
+ )
388
+
389
+ renderWithProviders(
390
+ <ChannelListProvider
391
+ value={{ onChannelSelect: vi.fn(), debug: false, renderMessagePreview }}
392
+ >
393
+ <CustomChannelPreview channel={channel} />
394
+ </ChannelListProvider>
395
+ )
396
+
397
+ expect(screen.getByText('Custom preview')).toBeInTheDocument()
398
+ expect(renderMessagePreview).toHaveBeenCalledWith(
399
+ channel.state.messages[0],
400
+ 'Default preview'
401
+ )
402
+ })
403
+
404
+ it('renders the default preview when renderMessagePreview is not supplied', () => {
405
+ const channel = createMockChannel([
406
+ {
407
+ id: 'msg-1',
408
+ text: 'Default preview',
409
+ type: 'regular',
410
+ },
411
+ ])
412
+
413
+ renderWithProviders(
414
+ <CustomChannelPreview {...defaultProps} channel={channel} />
415
+ )
416
+ expect(screen.getByText('Default preview')).toBeInTheDocument()
417
+ })
418
+
419
+ it('uses the viewer language translation when available', () => {
420
+ const channel = createMockChannel([
421
+ {
422
+ id: 'msg-1',
423
+ text: 'Raw text',
424
+ type: 'regular',
425
+ i18n: { es_text: 'Texto traducido', language: 'es' },
426
+ },
427
+ ])
428
+ const { rerender } = renderWithProviders(
429
+ <ChannelListProvider
430
+ value={{
431
+ onChannelSelect: vi.fn(),
432
+ debug: false,
433
+ viewerLanguage: 'es-MX',
434
+ }}
435
+ >
436
+ <CustomChannelPreview channel={channel} />
437
+ </ChannelListProvider>
438
+ )
439
+ expect(screen.getByText('Texto traducido')).toBeInTheDocument()
440
+
441
+ rerender(
442
+ <ChannelListProvider value={{ onChannelSelect: vi.fn(), debug: false }}>
443
+ <CustomChannelPreview channel={channel} />
444
+ </ChannelListProvider>
445
+ )
446
+ expect(screen.getByText('Raw text')).toBeInTheDocument()
447
+ })
448
+
449
+ it('logs the preview payload only when debug is enabled', () => {
450
+ const channel = createMockChannel([])
451
+ const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
452
+
453
+ renderWithProviders(
454
+ <ChannelListProvider value={{ onChannelSelect: vi.fn(), debug: true }}>
455
+ <CustomChannelPreview channel={channel} />
456
+ </ChannelListProvider>
457
+ )
458
+ expect(consoleLog).toHaveBeenCalledWith(
459
+ '📺 [ChannelList] 📋 CHANNEL PREVIEW RENDER',
460
+ expect.objectContaining({
461
+ channelId: channel.id,
462
+ isSelected: false,
463
+ participantName: 'Alice',
464
+ unreadCount: 0,
465
+ hasTimestamp: false,
466
+ })
467
+ )
468
+
469
+ consoleLog.mockClear()
470
+ const { rerender } = renderWithProviders(
471
+ <ChannelListProvider value={{ onChannelSelect: vi.fn(), debug: false }}>
472
+ <CustomChannelPreview channel={channel} />
473
+ </ChannelListProvider>
474
+ )
475
+ expect(consoleLog).not.toHaveBeenCalled()
476
+ rerender(
477
+ <ChannelListProvider value={{ onChannelSelect: vi.fn(), debug: false }}>
478
+ <CustomChannelPreview channel={channel} />
479
+ </ChannelListProvider>
480
+ )
481
+ expect(consoleLog).not.toHaveBeenCalled()
482
+ })
483
+
76
484
  it('uses username when participant name is UUID-like', () => {
77
485
  const channel = createMockChannel([])
78
486
  channel.state.members['participant-1'] = {
@@ -11,16 +11,25 @@ const {
11
11
  ChannelListProviderMock,
12
12
  CustomChannelPreviewMock,
13
13
  InfiniteScrollMock,
14
+ restorePendingMessagesMock,
15
+ streamMountMock,
16
+ messagingContextMock,
14
17
  } = vi.hoisted(() => ({
15
18
  ChannelListProviderMock: vi.fn(
16
19
  ({ children }: { children: React.ReactNode; value: unknown }) => children
17
20
  ),
18
21
  CustomChannelPreviewMock: () => null,
19
22
  InfiniteScrollMock: () => null,
23
+ restorePendingMessagesMock: vi.fn(),
24
+ streamMountMock: vi.fn(),
25
+ messagingContextMock: { current: { debug: false } },
20
26
  }))
21
27
 
22
28
  vi.mock('stream-chat-react', () => ({
23
29
  ChannelList: (props: unknown) => {
30
+ React.useEffect(() => {
31
+ streamMountMock()
32
+ }, [])
24
33
  streamChannelListMock(props)
25
34
  return <div data-testid="stream-channel-list" />
26
35
  },
@@ -28,7 +37,11 @@ vi.mock('stream-chat-react', () => ({
28
37
  }))
29
38
 
30
39
  vi.mock('../../providers/MessagingProvider', () => ({
31
- useMessagingContext: () => ({ debug: false }),
40
+ useMessagingContext: () => messagingContextMock.current,
41
+ }))
42
+
43
+ vi.mock('../../hooks/useRestorePendingMessages', () => ({
44
+ restorePendingMessages: restorePendingMessagesMock,
32
45
  }))
33
46
 
34
47
  vi.mock('./ChannelListContext', () => ({
@@ -48,6 +61,9 @@ describe('ChannelList', () => {
48
61
  beforeEach(() => {
49
62
  ChannelListProviderMock.mockClear()
50
63
  streamChannelListMock.mockClear()
64
+ restorePendingMessagesMock.mockClear()
65
+ streamMountMock.mockClear()
66
+ messagingContextMock.current = { debug: false }
51
67
  })
52
68
 
53
69
  it('defaults unseen-channel promotion to false', () => {
@@ -200,4 +216,119 @@ describe('ChannelList', () => {
200
216
  streamProps.channelRenderFilterFn!(mockChannels)
201
217
  expect(filterFn).toHaveBeenCalledWith(mockChannels)
202
218
  })
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
+ })
203
334
  })