import { DefaultTheme, ThemeProvider } from '@react-navigation/native' import { InfiniteData, notifyManager, QueryClient, QueryClientProvider, } from '@tanstack/react-query' import { act, fireEvent, render, screen } from '@testing-library/react-native' import React from 'react' import { FlatList } from 'react-native' import { buildTestQueryClient } from '../../../__utils__/query_client' import { ChatProvider } from '../../../contexts/chat_context' import { ApiClient, useApiClient } from '../../../hooks/use_api_client' import { useConversationMessages } from '../../../hooks/use_conversation_messages' import { currentPersonQueryKey } from '../../../hooks/use_current_person' import { useLiveRelativeTime } from '../../../hooks/use_live_relative_time' import type { ApiCollection } from '../../../types' import type { JoltReactionEvent } from '../../../types/jolt_events' import type { ConversationResource } from '../../../types/resources/conversation' import type { MessageResource } from '../../../types/resources/message' import type { PersonResource } from '../../../types/resources/person' import { updateCacheWithMessage, updateCacheWithReaction, } from '../../../utils/cache/messages_cache' import { addRecordInPagesData, deleteRecordInPagesData } from '../../../utils/cache/page_mutations' import { getMessagesQueryKey } from '../../../utils/request/get_messages' import { MessageList } from '../message_list' jest.mock('../../../hooks/use_api_client') const mockFlash = jest.fn() jest.mock('../../../hooks/use_animated_message_background_color', () => ({ useAnimatedMessageBackgroundColor: () => ({ animatedBackgroundColor: {}, flash: mockFlash, handleMessagePressIn: jest.fn(), handleMessagePressOut: jest.fn(), }), })) const mockNavigate = jest.fn() jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ navigate: mockNavigate }), })) const mockToggleReaction = jest.fn() jest.mock('../../../hooks/use_message_reaction_toggle', () => ({ useMessageReactionToggle: () => ({ handleReactionToggle: mockToggleReaction, isPending: false }), })) jest.mock('../../../utils/native_adapters', () => ({ ...jest.requireActual('../../../utils/native_adapters'), Haptic: { impactLight: jest.fn() }, })) // Every row calls this hook on each render, so its calls count row renders. jest.mock('../../../hooks/use_live_relative_time', () => { const actual = jest.requireActual('../../../hooks/use_live_relative_time') return { useLiveRelativeTime: jest.fn(actual.useLiveRelativeTime) } }) const CONVERSATION_ID = 1 const ME = 99 const OTHER = 7 const messagesQueryKey = getMessagesQueryKey({ conversation_id: CONVERSATION_ID }) const renderSpy = useLiveRelativeTime as jest.MockedFunction type MessagesQueryData = InfiniteData> const author = { id: OTHER, type: 'Person', name: 'Ann', avatar: null } as unknown as PersonResource const me = { id: ME, type: 'Person', name: 'Me', avatar: null } as unknown as PersonResource const messageId = (n: number) => `01J${String(n).padStart(23, '0')}` const idByCreatedAt = new Map() // Dated well in the past so useLiveRelativeTime schedules no timers. const buildMessage = (n: number, overrides: Partial = {}): MessageResource => { const message: MessageResource = { type: 'Message', id: messageId(n), text: `message ${n}`, html: '', createdAt: new Date(Date.UTC(2026, 0, 1, 0, n)).toISOString(), deletedAt: null, textEditedAt: null, mine: false, attachments: [], author, reactionCounts: [], replyCount: 0, replyRootId: null, messageType: 'message', ...overrides, } idByCreatedAt.set(message.createdAt, message.id) return message } const messages = Array.from({ length: 8 }, (_, i) => buildMessage(i + 1)) const conversation = { id: CONVERSATION_ID, memberAbility: { canDeleteNonAuthoredMessages: false }, } as unknown as ConversationResource const reactionEvent = (message: MessageResource): JoltReactionEvent => ({ event: 'reaction.created', data: { data: { author_id: OTHER, conversation_id: CONVERSATION_ID, message_sort_key: message.id, message_author_id: OTHER, reply_root_id: null, created_at: '2026-01-02T00:00:00Z', organization_id: 1, value: 'heart', count: 1, }, }, }) as unknown as JoltReactionEvent let queryClient: QueryClient // A top-level rerender would rebuild the wrapper context and redraw every row. let setHarnessConversation: (next: ConversationResource) => void = () => {} function Harness({ anchorMessageId }: { anchorMessageId?: string }) { const [current, setCurrent] = React.useState(conversation) React.useEffect(() => { setHarnessConversation = setCurrent }, []) const { messages: loaded } = useConversationMessages({ conversation_id: CONVERSATION_ID }) return ( ()} conversation={current} conversationId={CONVERSATION_ID} messages={loaded} anchorMessageId={anchorMessageId} hasNextPage onEndReached={() => {}} /> ) } const Wrapper = ({ children }: { children: React.ReactNode }) => ( {}, theme: {}, }} > {children} ) const renderedRowIds = () => { const ids = renderSpy.mock.calls.map(([date]) => idByCreatedAt.get(date as string) ?? date) return Array.from(new Set(ids)).sort() } const renderList = (seed: MessageResource[] = messages, anchorMessageId?: string) => { queryClient.setQueryData(messagesQueryKey, { pages: [{ data: seed, links: {}, meta: { count: seed.length, totalCount: seed.length } }], pageParams: [{}], }) render(, { wrapper: Wrapper }) seed.forEach(m => screen.getByTestId(`message-${m.id}`)) renderSpy.mockClear() } const reportViewable = (items: MessageResource[]) => act(() => screen.UNSAFE_getByType(FlatList).props.onViewableItemsChanged({ viewableItems: items.map(item => ({ item, key: item.id, index: null, isViewable: true })), changed: [], }) ) // TanStack notifies observers on a setTimeout; deliver inline so one write is one act(). beforeAll(() => notifyManager.setScheduler(callback => callback())) afterAll(() => notifyManager.setScheduler(callback => setTimeout(callback, 0))) beforeEach(() => { queryClient = buildTestQueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }) queryClient.setQueryData(currentPersonQueryKey, { data: me, links: {}, meta: {} }) ;(useApiClient as jest.MockedFunction).mockReturnValue({ chat: { get: jest.fn().mockRejectedValue(new Error('unexpected fetch')) }, } as unknown as ApiClient) }) afterEach(() => { queryClient.clear() jest.clearAllMocks() }) describe('MessageList', () => { it('redraws only the reacted row when a reaction arrives', () => { renderList() const target = messages[3] act(() => updateCacheWithReaction(queryClient, messagesQueryKey, reactionEvent(target), ME)) expect(screen.getByTestId(`reaction-${target.id}-heart`)).toBeTruthy() expect(renderedRowIds()).toEqual([target.id]) }) it('redraws only the edited row and shows the new text', () => { renderList() const target = messages[5] act(() => updateCacheWithMessage( queryClient, messagesQueryKey, { ...target, text: 'edited text', textEditedAt: '2026-01-02T00:00:00Z' }, 'message.updated' ) ) expect(screen.getByText('edited text')).toBeTruthy() expect(renderedRowIds()).toEqual([target.id]) }) it('draws a new message plus the neighbor whose grouping changed', () => { renderList() const incoming = buildMessage(9) const previousNewest = messages[7] act(() => updateCacheWithMessage(queryClient, messagesQueryKey, incoming, 'message.created')) expect(screen.getByTestId(`message-${incoming.id}`)).toBeTruthy() expect(renderedRowIds()).toEqual([previousNewest.id, incoming.id].sort()) }) it('swaps a pending message for its echo without touching other rows', () => { renderList() const pending = buildMessage(50, { id: `${messageId(50)}-temp`, text: 'hello there', idempotentKey: 'key-50', mine: true, author: me, pending: true, }) act(() => queryClient.setQueryData(messagesQueryKey, prev => addRecordInPagesData({ data: prev, record: pending }) ) ) expect(screen.getByTestId(`message-${pending.id}`)).toBeTruthy() renderSpy.mockClear() const echo = buildMessage(50, { text: 'hello there', idempotentKey: 'key-50', mine: true, author: me, }) act(() => updateCacheWithMessage(queryClient, messagesQueryKey, echo, 'message.created')) expect(screen.queryByTestId(`message-${pending.id}`)).toBeNull() expect(screen.getByTestId(`message-${echo.id}`)).toBeTruthy() expect(renderedRowIds()).toEqual([echo.id]) }) it('redraws only my messages whose read state changed when someone reads', () => { const mine = (n: number) => buildMessage(n, { mine: true, author: me }) const seed = [mine(1), buildMessage(2), mine(3), buildMessage(4), mine(5), buildMessage(6)] renderList(seed) expect(screen.getByText('Sent')).toBeTruthy() act(() => setHarnessConversation({ ...conversation, latestReadMessageSortKey: messageId(6) })) expect(screen.getByText('Read')).toBeTruthy() expect(renderedRowIds()).toEqual([messageId(1), messageId(3), messageId(5)].sort()) }) it('removes a deleted row and leaves the others alone', () => { renderList() const target = messages[2] act(() => queryClient.setQueryData(messagesQueryKey, prev => deleteRecordInPagesData({ data: prev, record: target }) ) ) expect(screen.queryByTestId(`message-${target.id}`)).toBeNull() expect(renderedRowIds()).toEqual([]) }) describe('in a disabled conversation', () => { const reacted = buildMessage(1, { reactionCounts: [ { type: 'ReactionCount', id: 'heart', messageId: messageId(1), value: 'heart', count: 1, mine: 0, authorIds: [OTHER], }, ], }) const archived = buildMessage(2, { text: 'This conversation was archived by Ann', systemTextFormat: 'actor', }) const disable = () => act(() => setHarnessConversation({ ...conversation, disabled: true })) it('toggles a reaction pill while the conversation is active', () => { renderList([reacted]) fireEvent.press(screen.getByTestId(`reaction-${reacted.id}-heart`)) expect(mockToggleReaction).toHaveBeenCalledWith({ value: 'heart', mine: false }) }) it('keeps reaction pills read-only but still shows who reacted', () => { renderList([reacted]) disable() const pill = screen.getByTestId(`reaction-${reacted.id}-heart`) fireEvent.press(pill) fireEvent(pill, 'longPress') expect(mockToggleReaction).not.toHaveBeenCalled() expect(mockNavigate).toHaveBeenCalledWith('Reactions', expect.anything()) }) it('opens message actions on a system message only while the conversation is active', () => { queryClient.setQueryData(messagesQueryKey, { pages: [{ data: [archived], links: {}, meta: { count: 1, totalCount: 1 } }], pageParams: [{}], }) render(, { wrapper: Wrapper }) fireEvent(screen.getByText(/archived by Ann/), 'longPress') expect(mockNavigate).toHaveBeenCalledWith('MessageActions', expect.anything()) mockNavigate.mockClear() disable() fireEvent(screen.getByText(/archived by Ann/), 'longPress') expect(mockNavigate).not.toHaveBeenCalled() }) }) describe('opened at an anchor message', () => { const anchor = messages[4] it('flashes the anchor row once it comes into view, redrawing only that row', () => { renderList(messages, anchor.id) reportViewable([messages[1], messages[2]]) expect(mockFlash).not.toHaveBeenCalled() expect(renderedRowIds()).toEqual([]) reportViewable([messages[3], anchor]) expect(mockFlash).toHaveBeenCalledTimes(1) expect(renderedRowIds()).toEqual([anchor.id]) }) it('flashes only once, through later viewability changes, redraws, and a remount', () => { renderList(messages, anchor.id) reportViewable([anchor]) expect(mockFlash).toHaveBeenCalledTimes(1) reportViewable([anchor, messages[5]]) act(() => updateCacheWithReaction(queryClient, messagesQueryKey, reactionEvent(anchor), ME)) expect(screen.getByTestId(`reaction-${anchor.id}-heart`)).toBeTruthy() act(() => queryClient.setQueryData(messagesQueryKey, prev => deleteRecordInPagesData({ data: prev, record: anchor }) ) ) expect(screen.queryByTestId(`message-${anchor.id}`)).toBeNull() act(() => queryClient.setQueryData(messagesQueryKey, prev => addRecordInPagesData({ data: prev, record: anchor }) ) ) expect(screen.getByTestId(`message-${anchor.id}`)).toBeTruthy() reportViewable([anchor]) expect(mockFlash).toHaveBeenCalledTimes(1) }) it('never flashes when there is no anchor', () => { renderList() reportViewable([messages[0], messages[1]]) expect(mockFlash).not.toHaveBeenCalled() }) }) })