import React from 'react'
import type { Channel } from 'stream-chat'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { renderWithProviders } from '../../test/utils'
import { ChannelList } from '.'
const streamChannelListMock = vi.fn()
const {
ChannelListProviderMock,
CustomChannelPreviewMock,
InfiniteScrollMock,
restorePendingMessagesMock,
streamMountMock,
messagingContextMock,
} = vi.hoisted(() => ({
ChannelListProviderMock: vi.fn(
({ children }: { children: React.ReactNode; value: unknown }) => children
),
CustomChannelPreviewMock: () => null,
InfiniteScrollMock: () => null,
restorePendingMessagesMock: vi.fn(),
streamMountMock: vi.fn(),
messagingContextMock: { current: { debug: false } },
}))
vi.mock('stream-chat-react', () => ({
ChannelList: (props: unknown) => {
React.useEffect(() => {
streamMountMock()
}, [])
streamChannelListMock(props)
return
},
InfiniteScroll: InfiniteScrollMock,
}))
vi.mock('../../providers/MessagingProvider', () => ({
useMessagingContext: () => messagingContextMock.current,
}))
vi.mock('../../hooks/useRestorePendingMessages', () => ({
restorePendingMessages: restorePendingMessagesMock,
}))
vi.mock('./ChannelListContext', () => ({
ChannelListProvider: ChannelListProviderMock,
}))
vi.mock('./CustomChannelPreview', () => ({
default: CustomChannelPreviewMock,
}))
describe('ChannelList', () => {
const defaultProps = {
filters: { type: 'messaging' },
onChannelSelect: vi.fn(),
}
beforeEach(() => {
ChannelListProviderMock.mockClear()
streamChannelListMock.mockClear()
restorePendingMessagesMock.mockClear()
streamMountMock.mockClear()
messagingContextMock.current = { debug: false }
})
it('defaults unseen-channel promotion to false', () => {
renderWithProviders()
expect(streamChannelListMock).toHaveBeenCalledOnce()
expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
allowNewMessagesFromUnfilteredChannels: false,
})
})
it('uses InfiniteScroll as the channel list paginator', () => {
renderWithProviders()
expect(streamChannelListMock).toHaveBeenCalledOnce()
expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
Paginator: InfiniteScrollMock,
})
})
it('uses a custom channel list paginator when provided', () => {
const ChannelListPaginator = () => null
renderWithProviders(
)
expect(streamChannelListMock).toHaveBeenCalledOnce()
expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
Paginator: ChannelListPaginator,
})
})
it('uses the built-in channel preview by default', () => {
renderWithProviders()
expect(streamChannelListMock).toHaveBeenCalledOnce()
expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
Preview: CustomChannelPreviewMock,
})
})
it('keeps the built-in channel preview bridge when a custom preview is provided', () => {
const ChannelPreview = () => null
renderWithProviders(
)
expect(streamChannelListMock).toHaveBeenCalledOnce()
const contextValue = ChannelListProviderMock.mock.calls[0][0].value as {
channelPreview?: unknown
}
expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
Preview: CustomChannelPreviewMock,
})
expect(contextValue.channelPreview).toBe(ChannelPreview)
})
it('passes allowNewMessagesFromUnfilteredChannels through to Stream ChannelList', () => {
const props: React.ComponentProps = {
...defaultProps,
allowNewMessagesFromUnfilteredChannels: true,
}
renderWithProviders(React.createElement(ChannelList, props))
expect(streamChannelListMock).toHaveBeenCalledOnce()
expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
allowNewMessagesFromUnfilteredChannels: true,
})
})
it('leaves live-reorder controls undefined by default so Stream keeps its default ordering', () => {
renderWithProviders()
expect(streamChannelListMock).toHaveBeenCalledOnce()
const streamProps = streamChannelListMock.mock.calls[0][0] as Record<
string,
unknown
>
expect(streamProps.lockChannelOrder).toBeUndefined()
expect(streamProps.onMessageNewHandler).toBeUndefined()
expect(streamProps.onMessageNew).toBeUndefined()
expect(streamProps.onChannelVisible).toBeUndefined()
expect(streamProps.onAddedToChannel).toBeUndefined()
expect(streamProps.onChannelUpdated).toBeUndefined()
})
it('forwards live-reorder controls to Stream ChannelList when provided', () => {
const onMessageNewHandler = vi.fn()
const onMessageNew = vi.fn()
const onChannelVisible = vi.fn()
const onAddedToChannel = vi.fn()
const onChannelUpdated = vi.fn()
renderWithProviders(
)
expect(streamChannelListMock).toHaveBeenCalledOnce()
expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
lockChannelOrder: true,
onMessageNewHandler,
onMessageNew,
onChannelVisible,
onAddedToChannel,
onChannelUpdated,
})
})
it('wraps channelRenderFilterFn to restore pending messages and delegates to consumer filter', () => {
const filterFn = vi.fn((channels: Channel[]) => channels)
renderWithProviders(
React.createElement(ChannelList, {
...defaultProps,
channelRenderFilterFn: filterFn,
})
)
expect(streamChannelListMock).toHaveBeenCalledOnce()
const streamProps = streamChannelListMock.mock.calls[0][0] as {
channelRenderFilterFn?: (channels: unknown[]) => unknown[]
}
// The wrapper should not be the same reference as the original filter
expect(streamProps.channelRenderFilterFn).not.toBe(filterFn)
expect(typeof streamProps.channelRenderFilterFn).toBe('function')
// When the wrapper is called, it should delegate to the consumer's filter
const mockChannels = [
{
cid: 'ch-1',
state: { pending_messages: [], addMessageSorted: vi.fn() },
},
]
streamProps.channelRenderFilterFn!(mockChannels)
expect(filterFn).toHaveBeenCalledWith(mockChannels)
})
it('restores pending messages for every channel and preserves channels without a filter', () => {
const channels = [
{ cid: 'ch-1', state: { pending_messages: [] } },
{ cid: 'ch-2', state: { pending_messages: [{ id: 'pending-2' }] } },
] as unknown as Channel[]
renderWithProviders()
const streamProps = streamChannelListMock.mock.calls[0][0] as {
channelRenderFilterFn: (channels: Channel[]) => Channel[]
}
expect(streamProps.channelRenderFilterFn(channels)).toBe(channels)
expect(restorePendingMessagesMock).toHaveBeenCalledTimes(2)
expect(restorePendingMessagesMock).toHaveBeenNthCalledWith(1, channels[0])
expect(restorePendingMessagesMock).toHaveBeenNthCalledWith(2, channels[1])
})
it('remounts Stream ChannelList when filters change but not when they stay the same', () => {
const { rerender } = renderWithProviders()
expect(streamMountMock).toHaveBeenCalledOnce()
rerender()
expect(streamMountMock).toHaveBeenCalledOnce()
rerender(
)
expect(streamMountMock).toHaveBeenCalledTimes(2)
})
it('forwards the default and explicit sort values and the channel limit', () => {
renderWithProviders()
expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
sort: { last_message_at: -1 },
options: { limit: 30 },
})
const explicitSort = { updated_at: 1 as const }
renderWithProviders()
expect(streamChannelListMock.mock.calls[1][0]).toMatchObject({
sort: explicitSort,
options: { limit: 30 },
})
})
it('provides channel list context values from props and messaging context', () => {
const selectedChannel = { id: 'selected-channel' } as Channel
const onChannelSelect = vi.fn()
const renderMessagePreview = vi.fn()
messagingContextMock.current = { debug: true }
renderWithProviders(
)
const contextValue = ChannelListProviderMock.mock.calls[0][0].value
expect(contextValue).toMatchObject({
selectedChannel,
onChannelSelect,
debug: true,
renderMessagePreview,
viewerLanguage: 'fr',
})
})
it('logs the channel list render when messaging debug is enabled', () => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
messagingContextMock.current = { debug: true }
renderWithProviders()
expect(consoleLog).toHaveBeenCalledWith(
'📺 [ChannelList] 🔄 RENDER START',
expect.objectContaining({
renderCount: 1,
selectedChannelId: undefined,
filters: defaultProps.filters,
})
)
})
it('passes wrapper classes and the custom empty state indicator through', () => {
const EmptyStateIndicator = () => Empty state
const { container } = renderWithProviders(
)
expect(container.firstElementChild).toHaveClass(
'messaging-channel-list',
'h-full',
'flex',
'flex-col',
'min-w-0',
'overflow-hidden',
'custom-channel-list'
)
expect(streamChannelListMock.mock.calls[0][0]).toMatchObject({
EmptyStateIndicator,
})
})
})