import { MESSAGE_CUSTOM_TYPE } from '@linktr.ee/messaging-taxonomy' import { act, createEvent, fireEvent } from '@testing-library/react' import { Channel, LocalMessage, StreamChat } from 'stream-chat' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithProviders, screen } from '../../test/utils' import type { ChannelPreviewProps } from '../../types' import { ChannelListProvider } from './ChannelListContext' import CustomChannelPreview from './CustomChannelPreview' const mockUser = { id: 'current-user', name: 'Current User', } const mockChatContext = vi.hoisted(() => ({ current: { client: { userID: 'current-user' }, } as { client?: { userID?: string } }, })) vi.mock('stream-chat-react', async (importOriginal) => ({ ...(await importOriginal()), useChatContext: () => mockChatContext.current, })) type MockChannel = Channel & { emitMemberUpdated: (member?: { pinned_at?: string | null }) => void } const createMockChannel = (messages: Partial[]): MockChannel => { const memberUpdatedListeners = new Set< (event: { member?: { pinned_at?: string | null } }) => void >() return { id: 'channel-1', cid: 'messaging:channel-1', _client: { userID: mockUser.id } as unknown as StreamChat, state: { members: { [mockUser.id]: { user: mockUser, user_id: mockUser.id }, 'participant-1': { user: { id: 'participant-1', name: 'Alice' }, user_id: 'participant-1', }, }, messages: messages as LocalMessage[], membership: {}, }, on: vi.fn( ( eventName: string, listener: (event: { member?: { pinned_at?: string | null } }) => void ) => { if (eventName === 'member.updated') { memberUpdatedListeners.add(listener) } } ), off: vi.fn( ( eventName: string, listener: (event: { member?: { pinned_at?: string | null } }) => void ) => { if (eventName === 'member.updated') { memberUpdatedListeners.delete(listener) } } ), emitMemberUpdated: (member?: { pinned_at?: string | null }) => { memberUpdatedListeners.forEach((listener) => listener({ member })) }, } as unknown as MockChannel } describe('CustomChannelPreview', () => { const defaultProps = { onChannelSelect: vi.fn(), } beforeEach(() => { mockChatContext.current = { client: { userID: 'current-user' }, } defaultProps.onChannelSelect.mockClear() }) it('applies selected and unselected row styling from context', () => { const channel = createMockChannel([]) const otherChannel = createMockChannel([]) otherChannel.id = 'channel-2' const { rerender } = renderWithProviders( ) let row = screen.getByRole('button') expect(row).toHaveClass('bg-black/[0.04]') expect(row).not.toHaveClass('hover:bg-black/[0.02]') rerender( ) row = screen.getByRole('button') expect(row).not.toHaveClass('bg-black/[0.04]') expect(row).toHaveClass('hover:bg-black/[0.02]') rerender( ) expect(screen.getByRole('button')).toHaveClass('hover:bg-black/[0.02]') }) it('selects the channel by click and activation keys only', () => { const channel = createMockChannel([]) const onChannelSelect = vi.fn() const { container } = renderWithProviders( ) const row = container.querySelector('[role="button"]') as HTMLElement fireEvent.click(row) fireEvent.keyDown(row, { key: 'Enter' }) const spaceEvent = createEvent.keyDown(row, { key: ' ' }) fireEvent(row, spaceEvent) expect(spaceEvent.defaultPrevented).toBe(true) expect(onChannelSelect).toHaveBeenCalledTimes(3) expect(onChannelSelect).toHaveBeenNthCalledWith(1, channel) expect(onChannelSelect).toHaveBeenNthCalledWith(2, channel) expect(onChannelSelect).toHaveBeenNthCalledWith(3, channel) fireEvent.keyDown(row, { key: 'a' }) fireEvent.keyDown(row, { key: 'Enter', repeat: true }) expect(onChannelSelect).toHaveBeenCalledTimes(3) }) it('renders with an unhydrated client and resolves the fallback member', () => { mockChatContext.current = { client: undefined } const channel = createMockChannel([]) expect(() => renderWithProviders( ) ).not.toThrow() expect(screen.getByText('Current User')).toBeInTheDocument() }) it('resolves the other participant using the current client user id', () => { const channel = createMockChannel([]) renderWithProviders( ) expect(screen.getByText('Alice')).toBeInTheDocument() mockChatContext.current = { client: { userID: 'participant-1' } } renderWithProviders( ) expect(screen.getByText('Current User')).toBeInTheDocument() }) it('renders safe fallbacks when channel state is empty', () => { const channel = createMockChannel([]) Object.assign(channel, { state: {} }) expect(() => renderWithProviders( ) ).not.toThrow() expect(screen.getByText('Unknown member')).toBeInTheDocument() expect(screen.getByText('No messages yet')).toBeInTheDocument() expect(screen.queryByText('Starred conversation.')).not.toBeInTheDocument() }) it('renders a participant image only when one is supplied', () => { const channel = createMockChannel([]) channel.state.members['participant-1'].user = { id: 'participant-1', name: 'Alice', image: 'https://example.com/alice.png', } const { container, rerender } = renderWithProviders( ) expect(container.querySelectorAll('img')).toHaveLength(1) expect(container.querySelector('img')).toHaveAttribute( 'src', 'https://example.com/alice.png' ) channel.state.members['participant-1'].user = { id: 'participant-1', name: 'Alice', } rerender( ) expect(container.querySelectorAll('img')).toHaveLength(0) }) it.each([ [undefined, undefined], [3, '3'], [99, '99'], [100, '99+'], ])('renders the unread badge for unread=%s', (unread, expectedBadge) => { const channel = createMockChannel([]) const { container } = renderWithProviders( ) if (expectedBadge) { expect(screen.getByText(expectedBadge)).toBeInTheDocument() } else { expect( container.querySelector('span[class*="bg-[#7f22fe]"]') ).not.toBeInTheDocument() } }) it('renders a timestamp only when the last message has created_at', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'Recent message', type: 'regular', created_at: new Date(), user: { id: 'participant-1', name: 'Alice' }, }, ]) const { container, rerender } = renderWithProviders( ) const timestamp = () => container.querySelector('h3')?.parentElement?.querySelector('span') expect(timestamp()).toHaveTextContent('Just now') expect(screen.getByText('Just now')).toBeInTheDocument() Object.assign(channel.state.messages[0], { created_at: undefined }) rerender( ) expect(timestamp()).toBeNull() expect(screen.queryByText('Just now')).not.toBeInTheDocument() }) it.each([ [ 'tip with text', { metadata: { custom_type: MESSAGE_CUSTOM_TYPE.TIP }, text: 'A generous tip', }, 'A generous tip', ], [ 'tip without text', { metadata: { custom_type: MESSAGE_CUSTOM_TYPE.TIP } }, 'Sent a tip', ], [ 'paid with text', { metadata: { custom_type: MESSAGE_CUSTOM_TYPE.PAID }, text: 'Paid message', }, 'Paid message', ], [ 'paid without text', { metadata: { custom_type: MESSAGE_CUSTOM_TYPE.PAID } }, 'Sent a message', ], [ 'link attachment', { attachments: [{ og_scrape_url: 'https://example.com' }] }, 'https://example.com', ], [ 'image attachment', { attachments: [{ type: 'image' }] }, 'πŸ“· Sent an image', ], [ 'video attachment', { attachments: [{ type: 'video' }] }, 'πŸŽ₯ Sent a video', ], ['audio attachment', { attachments: [{ type: 'audio' }] }, '🎡 Sent audio'], ['file attachment', { attachments: [{ type: 'file' }] }, 'πŸ“Ž Sent a file'], [ 'unknown attachment', { attachments: [{ type: 'unknown' }] }, 'πŸ“Ž Sent an attachment', ], ])('renders the %s preview text', (_name, message, expectedText) => { const channel = createMockChannel([ { id: 'msg-1', type: 'regular', ...message, user: { id: 'participant-1', name: 'Alice' }, }, ]) renderWithProviders( ) expect(screen.getByText(expectedText)).toBeInTheDocument() }) it('prefixes chatbot previews but not normal message previews', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'Automated response', type: 'regular', metadata: { custom_type: MESSAGE_CUSTOM_TYPE.CHATBOT }, }, ]) const { rerender } = renderWithProviders( ) expect(screen.getByText('✨ Automated response')).toBeInTheDocument() channel.state.messages[0].metadata = {} channel.state.messages[0].text = 'Human response' rerender( ) expect(screen.getByText('Human response')).toBeInTheDocument() expect(screen.queryByText('✨ Human response')).not.toBeInTheDocument() }) it('uses renderMessagePreview from context with the resolved default text', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'Default preview', type: 'regular', }, ]) const renderMessagePreview = vi.fn( (_message: LocalMessage | undefined, _defaultPreview?: string) => 'Custom preview' ) renderWithProviders( ) expect(screen.getByText('Custom preview')).toBeInTheDocument() expect(renderMessagePreview).toHaveBeenCalledWith( channel.state.messages[0], 'Default preview' ) }) it('renders the default preview when renderMessagePreview is not supplied', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'Default preview', type: 'regular', }, ]) renderWithProviders( ) expect(screen.getByText('Default preview')).toBeInTheDocument() }) it('uses the viewer language translation when available', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'Raw text', type: 'regular', i18n: { es_text: 'Texto traducido', language: 'es' }, }, ]) const { rerender } = renderWithProviders( ) expect(screen.getByText('Texto traducido')).toBeInTheDocument() rerender( ) expect(screen.getByText('Raw text')).toBeInTheDocument() }) it('logs the preview payload only when debug is enabled', () => { const channel = createMockChannel([]) const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}) renderWithProviders( ) expect(consoleLog).toHaveBeenCalledWith( 'πŸ“Ί [ChannelList] πŸ“‹ CHANNEL PREVIEW RENDER', expect.objectContaining({ channelId: channel.id, isSelected: false, participantName: 'Alice', unreadCount: 0, hasTimestamp: false, }) ) consoleLog.mockClear() const { rerender } = renderWithProviders( ) expect(consoleLog).not.toHaveBeenCalled() rerender( ) expect(consoleLog).not.toHaveBeenCalled() }) it('uses username when participant name is UUID-like', () => { const channel = createMockChannel([]) channel.state.members['participant-1'] = { user: { id: 'participant-1', name: 'a1b2c3d4-e5f6-4789-a012-3456789abcde', username: 'alice', }, user_id: 'participant-1', } renderWithProviders( ) expect(screen.getByText('alice')).toBeInTheDocument() }) it('shows Unknown member when participant has no usable name or username', () => { const channel = createMockChannel([]) channel.state.members['participant-1'] = { user: { id: 'participant-1', name: 'participant-1', }, user_id: 'participant-1', } renderWithProviders( ) expect(screen.getByText('Unknown member')).toBeInTheDocument() }) it('shows the latest non-system message when the last message is a system message', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'Hello there!', type: 'regular', created_at: new Date('2026-01-01T00:00:00.000Z'), user: { id: 'participant-1', name: 'Alice' }, }, { id: 'msg-2', text: 'DM Agent has left the conversation', type: 'system', created_at: new Date('2026-01-01T00:01:00.000Z'), }, ]) renderWithProviders( ) expect(screen.getByText('Hello there!')).toBeInTheDocument() expect( screen.queryByText('DM Agent has left the conversation') ).not.toBeInTheDocument() }) it('skips multiple consecutive system messages to find the latest non-system message', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'Nice to meet you!', type: 'regular', created_at: new Date('2026-01-01T00:00:00.000Z'), user: { id: 'participant-1', name: 'Alice' }, }, { id: 'msg-2', text: 'DM Agent has left the conversation', type: 'system', created_at: new Date('2026-01-01T00:01:00.000Z'), }, { id: 'msg-3', text: 'DM Agent has rejoined the conversation', type: 'system', created_at: new Date('2026-01-01T00:02:00.000Z'), }, ]) renderWithProviders( ) expect(screen.getByText('Nice to meet you!')).toBeInTheDocument() expect( screen.queryByText('DM Agent has left the conversation') ).not.toBeInTheDocument() expect( screen.queryByText('DM Agent has rejoined the conversation') ).not.toBeInTheDocument() }) it('ignores age safety system messages when resolving the channel preview text', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'Still available to chat', type: 'regular', created_at: new Date('2026-01-01T00:00:00.000Z'), user: { id: 'participant-1', name: 'Alice' }, }, { id: 'msg-2', type: 'system', created_at: new Date('2026-01-01T00:01:00.000Z'), metadata: { custom_type: 'SYSTEM_AGE_SAFETY_BLOCKED', }, }, ]) renderWithProviders( ) expect(screen.getByText('Still available to chat')).toBeInTheDocument() expect( screen.queryByText( 'This user isn’t able to reply because they don’t meet our age safety guidelines.' ) ).not.toBeInTheDocument() }) it('shows fallback text when all messages are system messages', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'DM Agent has left the conversation', type: 'system', created_at: new Date('2026-01-01T00:00:00.000Z'), }, { id: 'msg-2', text: 'DM Agent has rejoined the conversation', type: 'system', created_at: new Date('2026-01-01T00:01:00.000Z'), }, ]) renderWithProviders( ) expect(screen.getByText('No messages yet')).toBeInTheDocument() }) it('shows the latest message when it is not a system message', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'First message', type: 'regular', created_at: new Date('2026-01-01T00:00:00.000Z'), user: { id: 'participant-1', name: 'Alice' }, }, { id: 'msg-2', text: 'Latest message', type: 'regular', created_at: new Date('2026-01-01T00:01:00.000Z'), user: { id: 'participant-1', name: 'Alice' }, }, ]) renderWithProviders( ) expect(screen.getByText('Latest message')).toBeInTheDocument() }) it('shows fallback text when there are no messages', () => { const channel = createMockChannel([]) renderWithProviders( ) expect(screen.getByText('No messages yet')).toBeInTheDocument() }) it('shows a locked indicator for a text-only attachment message, even when paid', () => { // The channel-list preview always shows the locked wording for an // attachment-type message and never echoes `message.text` β€” the // revealed content is only ever shown inside the full conversation. const channel = createMockChannel([ { id: 'msg-1', text: 'REDACTED', type: 'regular', created_at: new Date('2026-01-01T00:00:00.000Z'), user: { id: 'participant-1', name: 'Alice' }, metadata: { custom_type: 'MESSAGE_ATTACHMENT', attachment_content_type: 'text', payment_status: 'paid', }, }, ]) renderWithProviders( ) expect(screen.getByText('πŸ”’ Sent a locked message')).toBeInTheDocument() expect(screen.queryByText(/REDACTED/)).not.toBeInTheDocument() }) it('shows a locked indicator for an unpaid text-only attachment message', () => { const channel = createMockChannel([ { id: 'msg-1', text: 'REDACTED', type: 'regular', created_at: new Date('2026-01-01T00:00:00.000Z'), user: { id: 'participant-1', name: 'Alice' }, metadata: { custom_type: 'MESSAGE_ATTACHMENT', attachment_content_type: 'text', payment_status: 'pending', }, }, ]) renderWithProviders( ) expect(screen.getByText('πŸ”’ Sent a locked message')).toBeInTheDocument() expect(screen.queryByText(/REDACTED/)).not.toBeInTheDocument() }) it('shows a locked indicator for a media attachment message, even when paid', () => { const channel = createMockChannel([ { id: 'msg-1', type: 'regular', created_at: new Date('2026-01-01T00:00:00.000Z'), user: { id: 'participant-1', name: 'Alice' }, metadata: { custom_type: 'MESSAGE_ATTACHMENT', attachment_content_type: 'media', payment_status: 'paid', }, }, ]) renderWithProviders( ) expect(screen.getByText('πŸ”’ Sent a locked attachment')).toBeInTheDocument() }) it('shows a locked indicator for an unpaid media attachment message', () => { const channel = createMockChannel([ { id: 'msg-1', type: 'regular', created_at: new Date('2026-01-01T00:00:00.000Z'), user: { id: 'participant-1', name: 'Alice' }, metadata: { custom_type: 'MESSAGE_ATTACHMENT', attachment_content_type: 'media', payment_status: 'pending', }, }, ]) renderWithProviders( ) expect(screen.getByText('πŸ”’ Sent a locked attachment')).toBeInTheDocument() }) it('updates starred state when the channel membership changes', () => { const channel = createMockChannel([]) renderWithProviders( ) expect( screen.queryByRole('heading', { name: /starred conversation/i }) ).not.toBeInTheDocument() act(() => { channel.emitMemberUpdated({ pinned_at: new Date().toISOString() }) }) expect( screen.getByRole('heading', { name: /starred conversation/i }) ).toBeInTheDocument() }) it('renders a custom channel preview with selection props from context', () => { const channel = createMockChannel([]) const onChannelSelect = vi.fn() const ChannelPreview = vi.fn( ({ active, channel, onChannelSelect, selectedChannel, viewerLanguage, }: ChannelPreviewProps) => { const isSelected = selectedChannel?.id === channel.id return ( ) } ) renderWithProviders( ) fireEvent.click( screen.getByRole('button', { name: 'Selected channel-1 es active' }) ) expect(ChannelPreview).toHaveBeenCalledWith( expect.objectContaining({ channel, selectedChannel: channel, onChannelSelect, viewerLanguage: 'es', active: true, }), expect.anything() ) expect(onChannelSelect).toHaveBeenCalledWith(channel) }) })