diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 1c7bfe054e..21e30f890e 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -245,6 +245,7 @@ export type ChannelPropsWithContext = Pick & | 'urlPreviewType' | 'FlatList' | 'forceAlignMessages' + | 'getDateSeparators' | 'getMessageGroupStyle' | 'giphyVersion' | 'handleBan' @@ -426,6 +427,7 @@ const ChannelWithContext = (props: PropsWithChildren) = FlatList = NativeHandlers.FlatList, focusInputOnPickerClose = true, forceAlignMessages, + getDateSeparators, getMessageGroupStyle, handleAttachButtonPress, handleBan, @@ -1693,6 +1695,7 @@ const ChannelWithContext = (props: PropsWithChildren) = enableSwipeToReply, FlatList, forceAlignMessages, + getDateSeparators, getMessageGroupStyle, giphyVersion, handleBan, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index a237709a95..12f624be5e 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -14,6 +14,7 @@ export const useCreateMessagesContext = ({ enableSwipeToReply, FlatList, forceAlignMessages, + getDateSeparators, getMessageGroupStyle, giphyVersion, handleBan, @@ -78,6 +79,7 @@ export const useCreateMessagesContext = ({ enableSwipeToReply, FlatList, forceAlignMessages, + getDateSeparators, getMessageGroupStyle, giphyVersion, handleBan, diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index 2a86922a19..d58b7a4ef5 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -28,10 +28,20 @@ export type MessageWrapperProps = { message: LocalMessage; previousMessage?: LocalMessage; nextMessage?: LocalMessage; + /** Set only when a `getDateSeparators` override resolved the separators at the list level. */ + dateSeparatorDate?: Date; + /** The separator above the next message, needed to close a message group. Same condition. */ + nextMessageDateSeparatorDate?: Date; }; export const MessageWrapper = React.memo(function MessageWrapper(props: MessageWrapperProps) { - const { message, previousMessage, nextMessage } = props; + const { + message, + previousMessage, + nextMessage, + dateSeparatorDate: resolvedDateSeparatorDate, + nextMessageDateSeparatorDate: resolvedNextMessageDateSeparatorDate, + } = props; const { client } = useChatContext(); const { channelUnreadStateStore, @@ -43,14 +53,32 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW } = useChannelContext(); const { InlineDateSeparator, InlineUnreadIndicator, Message, MessageSystem } = useComponentsContext(); - const { getMessageGroupStyle, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); + const { getDateSeparators, getMessageGroupStyle, myMessageTheme, shouldShowUnreadUnderlay } = + useMessagesContext(); const { goToMessage, onThreadSelect, noGroupByUser, modifiedTheme } = useMessageListItemContext(); - const dateSeparatorDate = useMessageDateSeparator({ + // With an override the list resolved every separator already; without one the rule is + // neighbour-local, so the row derives its own and the list walks nothing. + const separatorsResolved = !!getDateSeparators; + + // The default rule is neighbour-local, so the row derives it without the list walking anything. + const localDateSeparatorDate = useMessageDateSeparator({ hideDateSeparators, message, previousMessage, + skip: separatorsResolved, }); + // Needed to close a message group when the next row starts a new day. + const localNextMessageDateSeparatorDate = useMessageDateSeparator({ + message: nextMessage, + previousMessage: message, + skip: separatorsResolved, + }); + + const dateSeparatorDate = separatorsResolved ? resolvedDateSeparatorDate : localDateSeparatorDate; + const nextMessageDateSeparatorDate = separatorsResolved + ? resolvedNextMessageDateSeparatorDate + : localNextMessageDateSeparatorDate; const isNewestMessage = nextMessage === undefined; const groupStyles = useMessageGroupStyles({ @@ -60,6 +88,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW message, previousMessage, nextMessage, + nextMessageDateSeparatorDate, noGroupByUser, }); @@ -111,7 +140,10 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW return ( {message.type === 'system' ? ( - + <> + {renderDateSeperator} + + ) : wrapMessageInTheme ? ( {renderDateSeperator} diff --git a/package/src/components/Message/MessageItemView/utils/parseLinks.test.ts b/package/src/components/Message/MessageItemView/utils/parseLinks.test.ts index b038b0fc47..c473dcd86e 100644 --- a/package/src/components/Message/MessageItemView/utils/parseLinks.test.ts +++ b/package/src/components/Message/MessageItemView/utils/parseLinks.test.ts @@ -15,7 +15,7 @@ describe('parseLinksFromText', () => { 'https://localhost/with/path?and=query#fragment', ], ['reactnative.dev', 'http://reactnative.dev'], - ['hinge.health/schedule-with-a-coach', 'http://hinge.health/schedule-with-a-coach'], + ['example.com/some-page', 'http://example.com/some-page'], ['https://zh.wikipedia.org/wiki/挪威牛油危機', 'https://zh.wikipedia.org/wiki/挪威牛油危機'], [ 'https://getstream.io/chat/docs/react-native/?language=javascript', diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index f3658c10b2..727d4a778e 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -6,6 +6,7 @@ import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; +import { useDateSeparatorDates } from './hooks/useDateSeparatorDates'; import { useMessageList } from './hooks/useMessageList'; import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction'; import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; @@ -409,6 +410,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => threadList, }); + // This list is ordered oldest -> newest, unlike the inverted `MessageList`. + const dateSeparatorDates = useDateSeparatorDates(processedMessageList, false); + const renderItem = useCallback( ({ item: message, index }: { item: LocalMessage; index: number }) => { const previousMessage = processedMessageList[index - 1]; @@ -418,10 +422,12 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => message={message} previousMessage={previousMessage} nextMessage={nextMessage} + dateSeparatorDate={dateSeparatorDates?.[index]} + nextMessageDateSeparatorDate={dateSeparatorDates?.[index + 1]} /> ); }, - [processedMessageList], + [processedMessageList, dateSeparatorDates], ); /** diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index baf66fb98c..6fd4e94406 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -16,6 +16,7 @@ import debounce from 'lodash/debounce'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; +import { useDateSeparatorDates } from './hooks/useDateSeparatorDates'; import { useMessageList } from './hooks/useMessageList'; import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction'; import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; @@ -413,6 +414,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const previousDerivedItemsRef = useRef>(undefined); + const dateSeparatorDates = useDateSeparatorDates(processedMessageList, true); + const processedMessageListWithNeighbors = useMemo(() => { if (!previousDerivedItemsRef.current) { previousDerivedItemsRef.current = new Map(); @@ -421,19 +424,28 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const { items, nextDerivedItems } = buildMessageListWithNeighbours( processedMessageList, previousDerivedItemsRef.current, + dateSeparatorDates, ); previousDerivedItemsRef.current = nextDerivedItems; return items; - }, [processedMessageList]); + }, [processedMessageList, dateSeparatorDates]); const renderItem = useStableCallback(({ item }: { item: MessageListItemWithNeighbours }) => { - const { message, previousMessage, nextMessage } = item; + const { + message, + previousMessage, + nextMessage, + dateSeparatorDate, + nextMessageDateSeparatorDate, + } = item; return ( ); }); @@ -531,10 +543,14 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return; } const isMessageTypeDeleted = lastMessage.type === 'deleted'; + // System messages do not anchor date separators in the list, so they must not drive the + // sticky header either - otherwise the header announces a day the list never separates. + const isMessageTypeSystem = lastMessage.type === 'system'; if ( lastMessage?.created_at && !isMessageTypeDeleted && + !isMessageTypeSystem && typeof lastMessage.created_at !== 'string' && lastMessage.created_at.toDateString() !== stickyHeaderDateRef.current?.toDateString() ) { diff --git a/package/src/components/MessageList/__tests__/buildMessageListWithNeighbours.test.ts b/package/src/components/MessageList/__tests__/buildMessageListWithNeighbours.test.ts index 7eec5cd4fb..d7af2283ed 100644 --- a/package/src/components/MessageList/__tests__/buildMessageListWithNeighbours.test.ts +++ b/package/src/components/MessageList/__tests__/buildMessageListWithNeighbours.test.ts @@ -2,15 +2,28 @@ import { LocalMessage } from 'stream-chat'; import { buildMessageListWithNeighbours, + getDefaultDateSeparators, MessageListItemWithNeighbours, + resolveDateSeparatorDates, } from '../utils/buildMessageListWithNeighbours'; -const createMessage = (id: string) => +const at = (day: number, hour = 10) => new Date(Date.UTC(2026, 0, day, hour, 0, 0)); + +const createMessage = (id: string, day = 1) => ({ + created_at: at(day), id, text: id, }) as LocalMessage; +const createSystemMessage = (id: string, day = 1) => + ({ + created_at: at(day), + id, + text: id, + type: 'system', + }) as LocalMessage; + describe('buildMessageListWithNeighbours', () => { it('keeps reference for unaffected rows and updates only affected rows', () => { const m3 = createMessage('m3'); @@ -48,4 +61,168 @@ describe('buildMessageListWithNeighbours', () => { expect(row2.previousMessage).toBeUndefined(); expect(row2.nextMessage?.id).toBe('m2'); }); + + it('leaves separator dates unset when the list did not resolve any', () => { + // the default rule is neighbour-local, so rows derive their own and the list resolves nothing + const { items } = buildMessageListWithNeighbours( + [createMessage('m2', 2), createMessage('m1', 1)], + new Map(), + ); + + expect(items.map((item) => item.dateSeparatorDate)).toEqual([undefined, undefined]); + expect(items.map((item) => item.nextMessageDateSeparatorDate)).toEqual([undefined, undefined]); + }); + + it('carries resolved separator dates through, including the next row own date', () => { + const m2 = createMessage('m2', 2); + const m1 = createMessage('m1', 1); + + const { items } = buildMessageListWithNeighbours([m2, m1], new Map(), [ + m2.created_at, + m1.created_at, + ]); + const [row0, row1] = items as MessageListItemWithNeighbours[]; + + expect(row0.dateSeparatorDate).toBe(m2.created_at); + expect(row1.dateSeparatorDate).toBe(m1.created_at); + // the list is inverted, so row1's next row is row0 + expect(row1.nextMessageDateSeparatorDate).toBe(m2.created_at); + expect(row0.nextMessageDateSeparatorDate).toBeUndefined(); + }); + + it('invalidates a cached row when only its resolved separator changed', () => { + // An override can move a separator without any neighbour changing, so the resolved date has + // to take part in the cache comparison or the row would keep rendering a stale separator. + const m2 = createMessage('m2', 1); + const m1 = createMessage('m1', 1); + + const firstPass = buildMessageListWithNeighbours([m2, m1], new Map(), [ + undefined, + m1.created_at, + ]); + const secondPass = buildMessageListWithNeighbours([m2, m1], firstPass.nextDerivedItems, [ + m2.created_at, + undefined, + ]); + + expect(secondPass.items[0]).not.toBe(firstPass.items[0]); + expect(secondPass.items[0].previousMessage?.id).toBe('m1'); + expect(secondPass.items[0].dateSeparatorDate).toBe(m2.created_at); + expect(secondPass.items[1].dateSeparatorDate).toBeUndefined(); + }); +}); + +describe('getDefaultDateSeparators', () => { + it('dates the first message of each day, system messages included', () => { + // chronological, oldest first + const m1 = createMessage('m1', 1); + const s2 = createSystemMessage('s2', 2); + const m3 = createMessage('m3', 2); + const m4 = createMessage('m4', 3); + + expect(getDefaultDateSeparators({ messages: [m1, s2, m3, m4] })).toEqual({ + m1: m1.created_at, + m4: m4.created_at, + s2: s2.created_at, + }); + }); + + it('does not date the same day twice', () => { + const m1 = createMessage('m1', 1); + const s2 = createSystemMessage('s2', 1); + const m3 = createMessage('m3', 1); + + expect(getDefaultDateSeparators({ messages: [m1, s2, m3] })).toEqual({ m1: m1.created_at }); + }); + + it('dates every day of an all-system list', () => { + const s1 = createSystemMessage('s1', 1); + const s2 = createSystemMessage('s2', 2); + + expect(getDefaultDateSeparators({ messages: [s1, s2] })).toEqual({ + s1: s1.created_at, + s2: s2.created_at, + }); + }); + + it('returns nothing when separators are hidden', () => { + expect( + getDefaultDateSeparators({ hideDateSeparators: true, messages: [createMessage('m1', 1)] }), + ).toEqual({}); + }); + + it('handles an empty list', () => { + expect(getDefaultDateSeparators({ messages: [] })).toEqual({}); + }); +}); + +describe('resolveDateSeparatorDates', () => { + const m3 = createMessage('m3', 2); + const s2 = createSystemMessage('s2', 2); + const m1 = createMessage('m1', 1); + const inverted = [m3, s2, m1]; + + it('hands the override the list oldest first, whichever way the list is ordered', () => { + const seen: string[][] = []; + const getDateSeparators = ({ messages }: { messages: LocalMessage[] }) => { + seen.push(messages.map((message) => message.id)); + return {}; + }; + + resolveDateSeparatorDates(inverted, { getDateSeparators, isInverted: true }); + resolveDateSeparatorDates([m1, s2, m3], { getDateSeparators, isInverted: false }); + + expect(seen).toEqual([ + ['m1', 's2', 'm3'], + ['m1', 's2', 'm3'], + ]); + }); + + it('places separators wherever the override says', () => { + const getDateSeparators = () => ({ [s2.id]: s2.created_at }); + + expect(resolveDateSeparatorDates(inverted, { getDateSeparators, isInverted: true })).toEqual([ + undefined, + s2.created_at, + undefined, + ]); + }); + + it('can suppress every separator', () => { + expect( + resolveDateSeparatorDates(inverted, { getDateSeparators: () => ({}), isInverted: true }), + ).toEqual([undefined, undefined, undefined]); + }); + + it('keeps the message own Date instance when the override returns an equal value', () => { + // an override that builds its own Date would otherwise break row memoization + const getDateSeparators = () => ({ [m3.id]: new Date(m3.created_at.getTime()) }); + + const [first] = resolveDateSeparatorDates(inverted, { getDateSeparators, isInverted: true }); + + expect(first).toBe(m3.created_at); + }); + + it('passes a different Date through untouched', () => { + const other = new Date(Date.UTC(2020, 5, 5, 5, 0, 0)); + const getDateSeparators = () => ({ [m3.id]: other }); + + expect(resolveDateSeparatorDates(inverted, { getDateSeparators, isInverted: true })[0]).toBe( + other, + ); + }); + + it('forwards hideDateSeparators to the override', () => { + const getDateSeparators = jest.fn(() => ({})); + + resolveDateSeparatorDates(inverted, { + getDateSeparators, + hideDateSeparators: true, + isInverted: true, + }); + + expect(getDateSeparators).toHaveBeenCalledWith( + expect.objectContaining({ hideDateSeparators: true }), + ); + }); }); diff --git a/package/src/components/MessageList/__tests__/dateSeparators.test.tsx b/package/src/components/MessageList/__tests__/dateSeparators.test.tsx new file mode 100644 index 0000000000..f9b7294fe6 --- /dev/null +++ b/package/src/components/MessageList/__tests__/dateSeparators.test.tsx @@ -0,0 +1,370 @@ +import React from 'react'; + +import { cleanup, render, waitFor } from '@testing-library/react-native'; + +import { LocalMessage } from 'stream-chat'; + +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; +import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; +import { generateChannelResponse } from '../../../mock-builders/generator/channel'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { MessageFlashList } from '../MessageFlashList'; +import { MessageList } from '../MessageList'; + +const user = generateUser(); + +const at = (day: number, hour: number) => new Date(Date.UTC(2026, 0, day, hour, 0, 0)); + +/** A regular message on `day` at `hour`, labelled so assertions read like the rendered list. */ +const message = (day: number, hour: number) => + generateMessage({ created_at: at(day, hour), text: `message d${day} ${hour}h`, user }); + +/** A system message on `day` at `hour` - e.g. a membership change or a session marker. */ +const systemMessage = (day: number, hour: number) => + generateMessage({ + created_at: at(day, hour), + text: `system d${day} ${hour}h`, + type: 'system', + user, + }); + +type TestMessage = ReturnType; + +const renderMessageList = async ( + messages: TestMessage[], + channelProps: Partial> = {}, + List: typeof MessageList | typeof MessageFlashList = MessageList, +) => { + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user })], + messages, + }); + const chatClient = await getTestClientWithUser({ id: user.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const result = render( + + + + + + + , + ); + + await waitFor(() => { + expect(result.queryAllByTestId(/^message-list-item-/).length).toBe(messages.length); + }); + + return result; +}; + +/** + * Renders a `MessageList` and returns its rows in display order (oldest first), with date + * separators interleaved, so both the number of separators and their placement are asserted. + */ +const readRows = ( + queryAllByTestId: Awaited>['queryAllByTestId'], + messages: TestMessage[], +) => + queryAllByTestId(/^(date-separator|message-list-item-)/) + .map((node) => { + const testID = node.props.testID as string; + if (testID === 'date-separator') { + return `separator: ${node.props.children?.props?.accessibilityLabel}`; + } + const id = testID.replace('message-list-item-', ''); + return messages.find((item) => item.id === id)?.text as string; + }) + // the list is inverted, so tree order is newest first + .reverse(); + +const renderRows = async (messages: TestMessage[], channelProps = {}) => { + const { queryAllByTestId } = await renderMessageList(messages, channelProps); + return readRows(queryAllByTestId, messages); +}; + +const separatorCount = (rows: string[]) => + rows.filter((row) => row.startsWith('separator:')).length; + +describe('MessageList date separators', () => { + afterEach(cleanup); + + describe('a separator goes above the first row of each day, whatever its type', () => { + it('dates a day that is opened by a system message', async () => { + const rows = await renderRows([message(1, 9), systemMessage(2, 8), message(2, 9)]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'message d1 9h', + 'separator: January 2, 2026', + 'system d2 8h', + 'message d2 9h', + ]); + }); + + it('does not date the same day twice when a system message sits mid-day', async () => { + const rows = await renderRows([message(1, 9), systemMessage(1, 12), message(1, 15)]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'message d1 9h', + 'system d1 12h', + 'message d1 15h', + ]); + }); + + it('dates every day when a system message opens each one', async () => { + const rows = await renderRows([ + systemMessage(1, 9), + message(1, 10), + systemMessage(2, 9), + message(2, 10), + systemMessage(3, 9), + message(3, 10), + ]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'system d1 9h', + 'message d1 10h', + 'separator: January 2, 2026', + 'system d2 9h', + 'message d2 10h', + 'separator: January 3, 2026', + 'system d3 9h', + 'message d3 10h', + ]); + }); + + it('dates the oldest loaded row even when it is a system message', async () => { + const rows = await renderRows([systemMessage(1, 8), message(1, 10), message(1, 12)]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'system d1 8h', + 'message d1 10h', + 'message d1 12h', + ]); + }); + + it('dates a day opened by consecutive system messages only once', async () => { + const rows = await renderRows([ + message(1, 9), + systemMessage(2, 7), + systemMessage(2, 8), + message(2, 9), + ]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'message d1 9h', + 'separator: January 2, 2026', + 'system d2 7h', + 'system d2 8h', + 'message d2 9h', + ]); + }); + + it('dates a day whose only content is a system message', async () => { + const rows = await renderRows([message(1, 9), systemMessage(2, 8), message(3, 9)]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'message d1 9h', + 'separator: January 2, 2026', + 'system d2 8h', + 'separator: January 3, 2026', + 'message d3 9h', + ]); + }); + + it('dates every day of an all-system list', async () => { + const rows = await renderRows([systemMessage(1, 9), systemMessage(2, 9)]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'system d1 9h', + 'separator: January 2, 2026', + 'system d2 9h', + ]); + }); + + it('dates a trailing system message that opens a new day', async () => { + const rows = await renderRows([message(1, 9), systemMessage(2, 9)]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'message d1 9h', + 'separator: January 2, 2026', + 'system d2 9h', + ]); + }); + + it('does not date a system message that trails its own day', async () => { + const rows = await renderRows([message(1, 9), systemMessage(1, 20), message(2, 9)]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'message d1 9h', + 'system d1 20h', + 'separator: January 2, 2026', + 'message d2 9h', + ]); + }); + }); + + describe('lists without system messages', () => { + it('dates each day', async () => { + const rows = await renderRows([message(1, 9), message(2, 9), message(3, 9)]); + + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'message d1 9h', + 'separator: January 2, 2026', + 'message d2 9h', + 'separator: January 3, 2026', + 'message d3 9h', + ]); + }); + + it('dates a single day once', async () => { + const rows = await renderRows([message(1, 9), message(1, 12), message(1, 15)]); + + expect(separatorCount(rows)).toBe(1); + expect(rows[0]).toBe('separator: January 1, 2026'); + }); + }); + + describe('a channel that writes a system message at every session boundary', () => { + it('dates every day, including one whose session was abandoned', async () => { + const rows = await renderRows([ + systemMessage(1, 9), + message(1, 10), + message(1, 11), + systemMessage(2, 14), // session opened and abandoned - no messages that day + systemMessage(3, 8), + message(3, 9), + systemMessage(4, 11), + message(4, 12), + ]); + + expect(separatorCount(rows)).toBe(4); + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'system d1 9h', + 'message d1 10h', + 'message d1 11h', + 'separator: January 2, 2026', + 'system d2 14h', + 'separator: January 3, 2026', + 'system d3 8h', + 'message d3 9h', + 'separator: January 4, 2026', + 'system d4 11h', + 'message d4 12h', + ]); + }); + }); + + describe('hideDateSeparators', () => { + it('renders no separators at all', async () => { + const { queryAllByTestId } = await renderMessageList( + [message(1, 9), systemMessage(2, 8), message(2, 9)], + { hideDateSeparators: true }, + ); + + expect(queryAllByTestId('date-separator')).toHaveLength(0); + }); + }); + + describe('getDateSeparators override', () => { + it('lets an integrator ignore system messages when deciding where a day starts', async () => { + const messages = [message(1, 9), systemMessage(2, 8), message(2, 9)]; + const getDateSeparators = ({ messages: loaded }: { messages: LocalMessage[] }) => { + const separators: Record = {}; + let previousDay: string | undefined; + for (const item of loaded) { + if (item.type === 'system') { + continue; + } + const day = item.created_at.toDateString(); + if (day !== previousDay) { + separators[item.id] = item.created_at; + } + previousDay = day; + } + return separators; + }; + + const rows = await renderRows(messages, { getDateSeparators }); + + // the separator now sits BELOW the system message, on the day's first regular message + expect(rows).toEqual([ + 'separator: January 1, 2026', + 'message d1 9h', + 'system d2 8h', + 'separator: January 2, 2026', + 'message d2 9h', + ]); + }); + + it('lets an integrator suppress every separator', async () => { + const { queryAllByTestId } = await renderMessageList( + [message(1, 9), systemMessage(2, 8), message(2, 9)], + { getDateSeparators: () => ({}) }, + ); + + expect(queryAllByTestId('date-separator')).toHaveLength(0); + }); + + it('supports a rule that needs to look across a whole day', async () => { + // only date a day that actually contains a regular message - the rule a channel with + // session markers needs, which the neighbour-local default cannot express + const getDateSeparators = ({ messages: loaded }: { messages: LocalMessage[] }) => { + const separators: Record = {}; + const byDay = new Map(); + for (const item of loaded) { + const day = item.created_at.toDateString(); + if (!byDay.has(day)) { + byDay.set(day, []); + } + byDay.get(day)?.push(item); + } + for (const rows of byDay.values()) { + if (!rows.some((item) => item.type !== 'system')) { + continue; + } + separators[rows[0].id] = rows[0].created_at; + } + return separators; + }; + + const { queryAllByTestId } = await renderMessageList( + [ + systemMessage(1, 9), + message(1, 10), + systemMessage(2, 14), // opened and abandoned - no regular message that day + systemMessage(3, 8), + message(3, 9), + ], + { getDateSeparators }, + ); + + const labels = queryAllByTestId('date-separator').map( + (node) => node.props.children?.props?.accessibilityLabel, + ); + + // day 2 is skipped + expect([...labels].reverse()).toEqual(['January 1, 2026', 'January 3, 2026']); + }); + }); +}); diff --git a/package/src/components/MessageList/__tests__/dateSeparatorsA11y.test.tsx b/package/src/components/MessageList/__tests__/dateSeparatorsA11y.test.tsx new file mode 100644 index 0000000000..fc470dda57 --- /dev/null +++ b/package/src/components/MessageList/__tests__/dateSeparatorsA11y.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; + +import { cleanup, render, waitFor } from '@testing-library/react-native'; + +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; +import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; +import { generateChannelResponse } from '../../../mock-builders/generator/channel'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { MessageList } from '../MessageList'; + +const user = generateUser(); +const at = (day: number, hour: number) => new Date(Date.UTC(2026, 0, day, hour, 0, 0)); +const message = (day: number, hour: number) => + generateMessage({ created_at: at(day, hour), text: `message d${day} ${hour}h`, user }); +const systemMessage = (day: number, hour: number) => + generateMessage({ + created_at: at(day, hour), + text: `system d${day} ${hour}h`, + type: 'system', + user, + }); + +const renderList = async (messages: ReturnType[]) => { + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user })], + messages, + }); + const chatClient = await getTestClientWithUser({ id: user.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const result = render( + + + + + + + , + ); + + await waitFor(() => { + expect(result.queryAllByTestId(/^message-list-item-/).length).toBe(messages.length); + }); + + return result; +}; + +describe('date separator accessibility', () => { + afterEach(cleanup); + + it('announces the full date, not the abbreviated visible label', async () => { + const { queryAllByTestId } = await renderList([message(1, 9), message(2, 9)]); + + const separators = queryAllByTestId('date-separator'); + const labels = separators.map((node) => node.props.children?.props?.accessibilityLabel); + + expect(labels.every(Boolean)).toBe(true); + // the a11y label is the unabbreviated date, which is the point of having a separate one + expect([...labels].reverse()).toEqual(['January 1, 2026', 'January 2, 2026']); + }); + + it('announces a separator that sits on a system message row', async () => { + // new under the uniform rule: a screen reader previously got nothing for these days + const { queryAllByTestId } = await renderList([message(1, 9), systemMessage(2, 8)]); + + const labels = queryAllByTestId('date-separator').map( + (node) => node.props.children?.props?.accessibilityLabel, + ); + + expect([...labels].reverse()).toEqual(['January 1, 2026', 'January 2, 2026']); + }); + + it('announces nothing when separators are hidden', async () => { + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user })], + messages: [message(1, 9), message(2, 9)], + }); + const chatClient = await getTestClientWithUser({ id: user.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const { queryAllByTestId } = render( + + + + + + + , + ); + + await waitFor(() => { + expect(queryAllByTestId(/^message-list-item-/).length).toBe(2); + }); + expect(queryAllByTestId('date-separator')).toHaveLength(0); + }); +}); diff --git a/package/src/components/MessageList/__tests__/dateSeparatorsFlashList.test.tsx b/package/src/components/MessageList/__tests__/dateSeparatorsFlashList.test.tsx new file mode 100644 index 0000000000..4b615705b4 --- /dev/null +++ b/package/src/components/MessageList/__tests__/dateSeparatorsFlashList.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; + +import { cleanup, render, waitFor, within } from '@testing-library/react-native'; + +// The repo-wide mock sets `FlashList: undefined`, which makes the component throw. Swap in a +// FlatList so the FlashList separator wiring - which is separate code from `MessageList` - is +// actually exercised. +jest.mock('@shopify/flash-list', () => ({ + FlashList: require('react-native').FlatList, + useFlashListContext: () => undefined, +})); + +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; +import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; +import { generateChannelResponse } from '../../../mock-builders/generator/channel'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { MessageFlashList } from '../MessageFlashList'; + +const user = generateUser(); +const at = (day: number, hour: number) => new Date(Date.UTC(2026, 0, day, hour, 0, 0)); +const message = (day: number, hour: number) => + generateMessage({ created_at: at(day, hour), text: `message d${day} ${hour}h`, user }); +const systemMessage = (day: number, hour: number) => + generateMessage({ + created_at: at(day, hour), + text: `system d${day} ${hour}h`, + type: 'system', + user, + }); + +type TestMessage = ReturnType; + +const renderFlashList = async ( + messages: TestMessage[], + channelProps: Partial> = {}, +) => { + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user })], + messages, + }); + const chatClient = await getTestClientWithUser({ id: user.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const result = render( + + + + + + + , + ); + + await waitFor(() => { + expect(result.queryAllByTestId(/^message-list-item-/).length).toBe(messages.length); + }); + + return result; +}; + +describe('MessageFlashList date separators', () => { + afterEach(cleanup); + + it('dates the first row of each day, system messages included', async () => { + const messages = [message(1, 9), systemMessage(2, 8), message(2, 9)]; + const { queryAllByTestId } = await renderFlashList(messages); + + // this list is ordered oldest -> newest; assert which row carries the separator + const rows = queryAllByTestId(/^message-list-item-/).map((node) => { + const id = (node.props.testID as string).replace('message-list-item-', ''); + const separator = within(node).queryAllByTestId('date-separator')[0]; + return { + separator: separator?.props.children?.props?.accessibilityLabel, + text: messages.find((item) => item.id === id)?.text, + }; + }); + + // the system message opens Jan 2, so it carries that day's separator - same rule as + // `MessageList`, reached through completely separate wiring + expect(rows).toEqual([ + { separator: 'January 1, 2026', text: 'message d1 9h' }, + { separator: 'January 2, 2026', text: 'system d2 8h' }, + { separator: undefined, text: 'message d2 9h' }, + ]); + }); + + it('honours a getDateSeparators override', async () => { + const { queryAllByTestId } = await renderFlashList( + [message(1, 9), systemMessage(2, 8), message(2, 9)], + { getDateSeparators: () => ({}) }, + ); + + expect(queryAllByTestId('date-separator')).toHaveLength(0); + }); + + it('honours hideDateSeparators', async () => { + const { queryAllByTestId } = await renderFlashList( + [message(1, 9), systemMessage(2, 8), message(2, 9)], + { hideDateSeparators: true }, + ); + + expect(queryAllByTestId('date-separator')).toHaveLength(0); + }); +}); diff --git a/package/src/components/MessageList/__tests__/dateSeparatorsPagination.test.tsx b/package/src/components/MessageList/__tests__/dateSeparatorsPagination.test.tsx new file mode 100644 index 0000000000..9d45340444 --- /dev/null +++ b/package/src/components/MessageList/__tests__/dateSeparatorsPagination.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; + +import { act, cleanup, render, waitFor } from '@testing-library/react-native'; + +import { LocalMessage } from 'stream-chat'; + +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; +import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; +import { generateChannelResponse } from '../../../mock-builders/generator/channel'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Channel } from '../../Channel/Channel'; +import { channelInitialState } from '../../Channel/hooks/useChannelDataState'; +import * as MessageListPaginationHook from '../../Channel/hooks/useMessageListPagination'; +import { Chat } from '../../Chat/Chat'; +import { MessageList } from '../MessageList'; + +const user = generateUser(); +const at = (day: number, hour: number) => new Date(Date.UTC(2026, 0, day, hour, 0, 0)); +const message = (day: number, hour: number) => + generateMessage({ created_at: at(day, hour), text: `message d${day} ${hour}h`, user }); +const systemMessage = (day: number, hour: number) => + generateMessage({ + created_at: at(day, hour), + text: `system d${day} ${hour}h`, + type: 'system', + user, + }); + +type TestMessage = ReturnType; + +/** Renders, then simulates an older page arriving, and reports the separators before and after. */ +const paginate = async (loaded: TestMessage[], olderPage: TestMessage[]) => { + let currentMessages = [...loaded]; + jest.spyOn(MessageListPaginationHook, 'useMessageListPagination').mockImplementation(() => ({ + copyMessagesStateFromChannel: jest.fn(), + loadChannelAroundMessage: jest.fn(), + loadChannelAtFirstUnreadMessage: jest.fn(), + loadInitialMessagesStateFromChannel: jest.fn(), + loadLatestMessages: jest.fn(), + loadMore: jest.fn(), + loadMoreRecent: jest.fn(), + state: { ...channelInitialState, messages: currentMessages as unknown as LocalMessage[] }, + })); + + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user })], + messages: loaded, + }); + const chatClient = await getTestClientWithUser({ id: user.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const tree = () => ( + + + + + + + + ); + + const { queryAllByTestId, rerender } = render(tree()); + await waitFor(() => { + expect(queryAllByTestId(/^message-list-item-/).length).toBe(loaded.length); + }); + + const separatorsBefore = queryAllByTestId('date-separator').map( + (n) => n.props.children?.props?.accessibilityLabel, + ); + + currentMessages = [...olderPage, ...loaded]; + act(() => { + rerender(tree()); + }); + await waitFor(() => { + expect(queryAllByTestId(/^message-list-item-/).length).toBe(loaded.length + olderPage.length); + }); + + const separatorsAfter = queryAllByTestId('date-separator').map( + (n) => n.props.children?.props?.accessibilityLabel, + ); + + return { after: [...separatorsAfter].reverse(), before: [...separatorsBefore].reverse() }; +}; + +describe('date separators across pagination', () => { + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it('moves the start-of-history separator onto the newly loaded same-day message', async () => { + const { after, before } = await paginate( + [systemMessage(2, 8), message(2, 9), message(2, 10)], + [message(2, 5), message(2, 6)], + ); + + // the oldest loaded row carried it, and still does - it is just a different row now + expect(before).toEqual(['January 2, 2026']); + expect(after).toEqual(['January 2, 2026']); + }); + + it('adds a separator for a newly loaded earlier day without disturbing the later one', async () => { + const { after, before } = await paginate( + [systemMessage(2, 8), message(2, 9)], + [message(1, 9), message(1, 10)], + ); + + expect(before).toEqual(['January 2, 2026']); + expect(after).toEqual(['January 1, 2026', 'January 2, 2026']); + }); + + it('dates a newly loaded day whose only content is a system message', async () => { + const { after, before } = await paginate( + [message(3, 9)], + [message(1, 9), systemMessage(2, 14)], + ); + + expect(before).toEqual(['January 3, 2026']); + expect(after).toEqual(['January 1, 2026', 'January 2, 2026', 'January 3, 2026']); + }); +}); diff --git a/package/src/components/MessageList/__tests__/dateSeparatorsSessionMarkers.test.tsx b/package/src/components/MessageList/__tests__/dateSeparatorsSessionMarkers.test.tsx new file mode 100644 index 0000000000..a85024bfbf --- /dev/null +++ b/package/src/components/MessageList/__tests__/dateSeparatorsSessionMarkers.test.tsx @@ -0,0 +1,248 @@ +import React from 'react'; + +import { cleanup, render, waitFor } from '@testing-library/react-native'; + +import { Channel as ChannelType, LocalMessage } from 'stream-chat'; + +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; +import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; +import { generateChannelResponse } from '../../../mock-builders/generator/channel'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { MessageList } from '../MessageList'; + +/** + * Some integrations write a system message at every session boundary and use those markers as + * their own day anchors. They want a day separator only on days that actually contain a regular + * message: a session that is opened and abandoned leaves a marker behind, and dating that day + * produces a divider with nothing meaningful under it. + * + * That rule cannot be answered from a message's neighbours - it needs the whole day, and it needs + * to know whether the day has finished loading - so it is expressed through `getDateSeparators`. + * These tests pin that integration shape end to end. + */ + +const user = generateUser(); +const at = (day: number, hour: number) => new Date(Date.UTC(2026, 0, day, hour, 0, 0)); + +const message = (day: number, hour: number) => + generateMessage({ created_at: at(day, hour), text: `message d${day} ${hour}h`, user }); + +const sessionMarker = (day: number, hour: number) => + generateMessage({ + created_at: at(day, hour), + text: `marker d${day} ${hour}h`, + type: 'system', + user, + }); + +type TestMessage = ReturnType; + +/** The integration's rule, verbatim: date a day only once it is known to hold a real message. */ +const sessionMarkerRule = + (channel: ChannelType) => + ({ messages }: { messages: LocalMessage[] }) => { + const separators: Record = {}; + const oldestLoadedDay = messages[0]?.created_at.toDateString(); + const hasOlderPages = channel.state.messagePagination.hasPrev; + + const byDay = new Map(); + for (const item of messages) { + const day = item.created_at.toDateString(); + if (!byDay.has(day)) { + byDay.set(day, []); + } + byDay.get(day)?.push(item); + } + + for (const [day, rows] of byDay) { + const hasRealMessage = rows.some((item) => item.type !== 'system'); + // a day we have not finished loading may still gain a real message - do not suppress it yet + const partiallyLoaded = day === oldestLoadedDay && hasOlderPages; + if (!hasRealMessage && !partiallyLoaded) { + continue; + } + separators[rows[0].id] = rows[0].created_at; + } + + return separators; + }; + +const setup = async (messages: TestMessage[], { hasPrev = false } = {}) => { + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user })], + messages, + }); + const chatClient = await getTestClientWithUser({ id: user.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const currentSet = channel.state.messageSets.find((set) => set.isCurrent); + if (currentSet) { + currentSet.pagination = { hasNext: false, hasPrev }; + } + + return { channel, chatClient }; +}; + +const renderWithRule = async ( + messages: TestMessage[], + { hasPrev = false, hideMarkers = false } = {}, +) => { + const { channel, chatClient } = await setup(messages, { hasPrev }); + + const tree = ( + + + + + + + + ); + + const result = render( + hideMarkers ? ( + null }}>{tree} + ) : ( + tree + ), + ); + + await waitFor(() => { + expect(result.queryAllByTestId(/^message-list-item-/).length).toBe(messages.length); + }); + + return result; +}; + +const readRows = ( + queryAllByTestId: Awaited>['queryAllByTestId'], + messages: TestMessage[], +) => + queryAllByTestId(/^(date-separator|message-list-item-)/) + .map((node) => { + const testID = node.props.testID as string; + if (testID === 'date-separator') { + return `--- ${node.props.children?.props?.accessibilityLabel} ---`; + } + const id = testID.replace('message-list-item-', ''); + return messages.find((item) => item.id === id)?.text as string; + }) + // the list is inverted, so tree order is newest first + .reverse(); + +const separatorLabels = ( + queryAllByTestId: Awaited>['queryAllByTestId'], +) => + queryAllByTestId('date-separator') + .map((node) => node.props.children?.props?.accessibilityLabel) + .reverse(); + +describe('a channel that writes a system message at every session boundary', () => { + afterEach(cleanup); + + it('dates every day with a real message and leaves the abandoned session undated', async () => { + const messages = [ + sessionMarker(1, 9), + message(1, 10), + message(1, 11), + sessionMarker(2, 14), // opened and abandoned - no messages that day + sessionMarker(3, 8), + message(3, 9), + sessionMarker(4, 11), + message(4, 12), + ]; + + const { queryAllByTestId } = await renderWithRule(messages); + + expect(readRows(queryAllByTestId, messages)).toEqual([ + '--- January 1, 2026 ---', + 'marker d1 9h', + 'message d1 10h', + 'message d1 11h', + 'marker d2 14h', + '--- January 3, 2026 ---', + 'marker d3 8h', + 'message d3 9h', + '--- January 4, 2026 ---', + 'marker d4 11h', + 'message d4 12h', + ]); + }); + + it('anchors the separator to the session marker that opens the day', async () => { + const messages = [message(1, 9), sessionMarker(2, 8), message(2, 9)]; + + const { queryAllByTestId } = await renderWithRule(messages); + + expect(readRows(queryAllByTestId, messages)).toEqual([ + '--- January 1, 2026 ---', + 'message d1 9h', + '--- January 2, 2026 ---', + 'marker d2 8h', + 'message d2 9h', + ]); + }); + + it('does not suppress the oldest loaded day while older history is still unloaded', async () => { + // day 1 holds only a marker so far, but there are older pages - suppressing it now would mean + // inserting the separator mid-scroll once loadMore brings that day's messages in + const messages = [sessionMarker(1, 9), sessionMarker(2, 8), message(2, 9)]; + + const { queryAllByTestId } = await renderWithRule(messages, { hasPrev: true }); + + expect(separatorLabels(queryAllByTestId)).toEqual(['January 1, 2026', 'January 2, 2026']); + }); + + it('suppresses that same day once everything is loaded', async () => { + const messages = [sessionMarker(1, 9), sessionMarker(2, 8), message(2, 9)]; + + const { queryAllByTestId } = await renderWithRule(messages, { hasPrev: false }); + + expect(separatorLabels(queryAllByTestId)).toEqual(['January 2, 2026']); + }); + + it('does not date a day of nothing but abandoned sessions', async () => { + const messages = [ + message(1, 9), + sessionMarker(2, 8), + sessionMarker(2, 15), + sessionMarker(2, 20), + message(3, 9), + ]; + + const { queryAllByTestId } = await renderWithRule(messages); + + expect(separatorLabels(queryAllByTestId)).toEqual(['January 1, 2026', 'January 3, 2026']); + }); + + it('dates a day once when sessions restart during it', async () => { + const messages = [ + sessionMarker(1, 9), + message(1, 10), + sessionMarker(1, 14), // second session, same day + message(1, 15), + ]; + + const { queryAllByTestId } = await renderWithRule(messages); + + expect(separatorLabels(queryAllByTestId)).toEqual(['January 1, 2026']); + }); + + it('holds when the markers themselves render nothing', async () => { + // these integrations hide the markers, which is what makes an empty dated day so obvious + const messages = [message(1, 9), sessionMarker(2, 14), message(3, 9)]; + + const { queryAllByTestId } = await renderWithRule(messages, { hideMarkers: true }); + + expect(separatorLabels(queryAllByTestId)).toEqual(['January 1, 2026', 'January 3, 2026']); + }); +}); diff --git a/package/src/components/MessageList/__tests__/dateSeparatorsThread.test.tsx b/package/src/components/MessageList/__tests__/dateSeparatorsThread.test.tsx new file mode 100644 index 0000000000..43ab6d43ec --- /dev/null +++ b/package/src/components/MessageList/__tests__/dateSeparatorsThread.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import { cleanup, render, waitFor } from '@testing-library/react-native'; + +import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat'; + +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { Thread } from '../../Thread/Thread'; + +const at = (day: number, hour: number) => new Date(Date.UTC(2026, 0, day, hour, 0, 0)); + +describe('Thread date separators', () => { + let chatClient: StreamChat; + let channel: ChannelType; + + beforeEach(async () => { + const { channels, client } = await initiateClientWithChannels(); + chatClient = client; + channel = channels[0]; + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + const renderThread = async ( + replies: LocalMessage[], + thread: LocalMessage, + channelProps: Partial> = {}, + ) => { + channel.state.addMessagesSorted([thread, ...replies] as unknown as Parameters< + typeof channel.state.addMessagesSorted + >[0]); + + const result = render( + + + + + + + , + ); + + await waitFor(() => { + expect(result.queryAllByTestId(/^message-list-item-/).length).toBeGreaterThan(0); + }); + + return result; + }; + + it('dates each day of a thread, including a day opened by a system message', async () => { + const cid = 'messaging:test-channel'; + const thread = generateMessage({ cid, created_at: at(1, 8), text: 'parent' }); + const parent_id = thread.id; + const replies = [ + generateMessage({ cid, created_at: at(1, 9), parent_id, text: 'reply d1' }), + generateMessage({ cid, created_at: at(2, 8), parent_id, text: 'system d2', type: 'system' }), + generateMessage({ cid, created_at: at(2, 9), parent_id, text: 'reply d2' }), + ] as unknown as LocalMessage[]; + + const { queryAllByTestId } = await renderThread(replies, thread as unknown as LocalMessage); + + const labels = queryAllByTestId('date-separator').map( + (node) => node.props.children?.props?.accessibilityLabel, + ); + + // the thread list is inverted like the main list, so tree order is newest first + expect([...labels].reverse()).toEqual(['January 1, 2026', 'January 2, 2026']); + }); + + it('honours hideDateSeparators in a thread', async () => { + const cid = 'messaging:test-channel'; + const thread = generateMessage({ cid, created_at: at(1, 8), text: 'parent2' }); + const parent_id = thread.id; + const replies = [ + generateMessage({ cid, created_at: at(1, 9), parent_id, text: 'reply a' }), + generateMessage({ cid, created_at: at(2, 9), parent_id, text: 'reply b' }), + ] as unknown as LocalMessage[]; + + const { queryAllByTestId } = await renderThread(replies, thread as unknown as LocalMessage, { + hideDateSeparators: true, + }); + + expect(queryAllByTestId('date-separator')).toHaveLength(0); + }); + + it('honours a getDateSeparators override in a thread', async () => { + const cid = 'messaging:test-channel'; + const thread = generateMessage({ cid, created_at: at(1, 8), text: 'parent3' }); + const parent_id = thread.id; + const replies = [ + generateMessage({ cid, created_at: at(1, 9), parent_id, text: 'reply c' }), + generateMessage({ cid, created_at: at(2, 9), parent_id, text: 'reply d' }), + ] as unknown as LocalMessage[]; + + const { queryAllByTestId } = await renderThread(replies, thread as unknown as LocalMessage, { + getDateSeparators: () => ({}), + }); + + expect(queryAllByTestId('date-separator')).toHaveLength(0); + }); +}); diff --git a/package/src/components/MessageList/__tests__/useDateSeparatorDates.test.tsx b/package/src/components/MessageList/__tests__/useDateSeparatorDates.test.tsx new file mode 100644 index 0000000000..87d0960e98 --- /dev/null +++ b/package/src/components/MessageList/__tests__/useDateSeparatorDates.test.tsx @@ -0,0 +1,67 @@ +import React, { PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; + +import { LocalMessage } from 'stream-chat'; + +import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; +import { MessagesProvider } from '../../../contexts/messagesContext/MessagesContext'; +import { useDateSeparatorDates } from '../hooks/useDateSeparatorDates'; + +const at = (day: number) => new Date(Date.UTC(2026, 0, day, 10, 0, 0)); +const message = (id: string, day: number) => ({ created_at: at(day), id }) as LocalMessage; + +// newest first, as `MessageList` renders it +const MESSAGES = [message('m2', 2), message('m1', 1)]; + +const wrapper = + (channelValue: object, messagesValue: object) => + ({ children }: PropsWithChildren) => ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {children} + + ); + +describe('useDateSeparatorDates', () => { + it('resolves nothing without an override, so the list never walks the messages', () => { + const { result } = renderHook(() => useDateSeparatorDates(MESSAGES, true), { + wrapper: wrapper({}, {}), + }); + + // undefined is the signal that rows derive their own separator from previousMessage + expect(result.current).toBeUndefined(); + }); + + it('resolves every row when an override is supplied', () => { + const getDateSeparators = jest.fn(({ messages }: { messages: LocalMessage[] }) => ({ + [messages[0].id]: messages[0].created_at, + })); + + const { result } = renderHook(() => useDateSeparatorDates(MESSAGES, true), { + wrapper: wrapper({}, { getDateSeparators }), + }); + + expect(getDateSeparators).toHaveBeenCalledTimes(1); + // the override is handed the list oldest first + expect(getDateSeparators.mock.calls[0][0].messages.map((m: LocalMessage) => m.id)).toEqual([ + 'm1', + 'm2', + ]); + // ...and the result is mapped back into render order + expect(result.current).toEqual([undefined, MESSAGES[1].created_at]); + }); + + it('forwards hideDateSeparators to the override', () => { + const getDateSeparators = jest.fn(() => ({})); + + renderHook(() => useDateSeparatorDates(MESSAGES, true), { + wrapper: wrapper({ hideDateSeparators: true }, { getDateSeparators }), + }); + + expect(getDateSeparators).toHaveBeenCalledWith( + expect.objectContaining({ hideDateSeparators: true }), + ); + }); +}); diff --git a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts index 901ea76a8c..30c9cdc2f5 100644 --- a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts +++ b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts @@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react-native'; import { LocalMessage } from 'stream-chat'; import { useMessageDateSeparator } from '../hooks/useMessageDateSeparator'; +import { useMessageGroupStyles } from '../hooks/useMessageGroupStyles'; describe('useMessageDateSeparator', () => { let messages: LocalMessage[]; @@ -72,4 +73,127 @@ describe('useMessageDateSeparator', () => { ); expect(resultOfSecondMessage.current).toBeUndefined(); }); + + it('returns the date separator when there is no previous regular message', () => { + // A system message at the top of the loaded history is skipped by the caller, so the first + // regular message is handed `undefined` and must be treated as the start of history. + const { result } = renderHook(() => + useMessageDateSeparator({ message: messages[0], previousMessage: undefined }), + ); + expect(result.current).toBe(messages[0].created_at); + }); + + it('returns the date separator when the previous regular message is on an earlier day', () => { + // Two system messages sit between these in the list; the caller skips them, so the hook sees + // the regular message from the day before and still separates the day. + const { result } = renderHook(() => + useMessageDateSeparator({ message: messages[2], previousMessage: messages[0] }), + ); + expect(result.current).toBe(messages[2].created_at); + }); + + it('returns undefined when the previous regular message is on the same day', () => { + // The mid-day case: a system message between two regular messages of the same day must not + // produce a second separator for that day. + const sameDayMessages = [ + { + created_at: new Date('2020-01-01T09:00:00.000Z'), + id: '1', + text: 'morning', + }, + { + created_at: new Date('2020-01-01T15:00:00.000Z'), + id: '3', + text: 'afternoon', + }, + ] as LocalMessage[]; + + const { result } = renderHook(() => + useMessageDateSeparator({ + message: sameDayMessages[1], + previousMessage: sameDayMessages[0], + }), + ); + expect(result.current).toBeUndefined(); + }); + + describe('calendar day comparison', () => { + const separatorFor = (message: Date, previousMessage: Date) => + renderHook(() => + useMessageDateSeparator({ + message: { created_at: message } as LocalMessage, + previousMessage: { created_at: previousMessage } as LocalMessage, + }), + ).result.current; + + it('does not separate two times on the same day', () => { + expect( + separatorFor(new Date(2026, 0, 1, 23, 59), new Date(2026, 0, 1, 0, 1)), + ).toBeUndefined(); + }); + + it('separates across midnight', () => { + const message = new Date(2026, 0, 2, 0, 1); + expect(separatorFor(message, new Date(2026, 0, 1, 23, 59))).toBe(message); + }); + + it('separates the same day number in a different month', () => { + const message = new Date(2026, 1, 1, 10, 0); + expect(separatorFor(message, new Date(2026, 0, 1, 10, 0))).toBe(message); + }); + + it('separates the same day and month in a different year', () => { + const message = new Date(2027, 0, 1, 10, 0); + expect(separatorFor(message, new Date(2026, 0, 1, 10, 0))).toBe(message); + }); + + it('separates across a month boundary', () => { + const message = new Date(2026, 1, 1, 0, 0); + expect(separatorFor(message, new Date(2026, 0, 31, 23, 0))).toBe(message); + }); + }); +}); + +describe('useMessageGroupStyles public contract', () => { + const user = { id: 'u1' }; + const at = (day: number, hour: number) => new Date(Date.UTC(2026, 0, day, hour, 0, 0)); + const msg = (day: number, hour: number, id: string) => + ({ created_at: at(day, hour), id, user }) as LocalMessage; + + it('derives the next message separator itself when the caller omits it', () => { + // an external caller passing only messages must keep getting develop's behaviour: the group + // closes because the next message starts a new day + const message = msg(1, 10, 'a'); + const nextMessage = msg(2, 10, 'b'); + + const { result } = renderHook(() => + useMessageGroupStyles({ + getMessageGroupStyle: undefined, + message, + nextMessage, + previousMessage: undefined, + }), + ); + + expect(result.current).toEqual(['single']); + }); + + it('uses the supplied value when the key is present, even if undefined', () => { + // the message list passes a resolved answer; `undefined` means "no separator there" + const message = msg(1, 10, 'a'); + const nextMessage = msg(1, 11, 'b'); + + const { result } = renderHook(() => + useMessageGroupStyles({ + getMessageGroupStyle: undefined, + message, + nextMessage, + nextMessageDateSeparatorDate: undefined, + previousMessage: undefined, + }), + ); + + // same user, same day, no separator either side -> the group stays open at the bottom + expect(result.current).toEqual(['top']); + }); }); diff --git a/package/src/components/MessageList/hooks/useDateSeparatorDates.ts b/package/src/components/MessageList/hooks/useDateSeparatorDates.ts new file mode 100644 index 0000000000..e0c2de6d33 --- /dev/null +++ b/package/src/components/MessageList/hooks/useDateSeparatorDates.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; + +import { LocalMessage } from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useMessagesContext } from '../../../contexts/messagesContext/MessagesContext'; +import { resolveDateSeparatorDates } from '../utils/buildMessageListWithNeighbours'; + +/** + * Resolves the date separator for every row of the message list, but only when a + * `getDateSeparators` override is supplied - a whole-list rule can only be answered by walking + * the whole list. + * + * Returns `undefined` for the default rule, which is neighbour-local: each row derives its own + * separator from the row above it, so the list does no per-change work at all. + * + * @param messages the list in render order + * @param isInverted true when the list is ordered newest -> oldest (`MessageList`), false when it + * is ordered oldest -> newest (`MessageFlashList`) + */ +export const useDateSeparatorDates = (messages: LocalMessage[], isInverted: boolean) => { + const { hideDateSeparators } = useChannelContext(); + const { getDateSeparators } = useMessagesContext(); + + return useMemo( + () => + getDateSeparators + ? resolveDateSeparatorDates(messages, { + getDateSeparators, + hideDateSeparators, + isInverted, + }) + : undefined, + [messages, getDateSeparators, hideDateSeparators, isInverted], + ); +}; diff --git a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts index 32433fa936..3ac5f32429 100644 --- a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts +++ b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts @@ -2,6 +2,14 @@ import { useMemo } from 'react'; import { LocalMessage } from 'stream-chat'; +/** + * A comparable key for the calendar day a date falls on. Equivalent to comparing + * `toDateString()` values, but without formatting a string for every comparison - the message + * list derives a separator for every loaded message, so this runs once per message per update. + */ +export const getDayKey = (date?: Date) => + date ? date.getFullYear() * 10000 + date.getMonth() * 100 + date.getDate() : undefined; + export const getDateSeparatorValue = ({ hideDateSeparators, message, @@ -15,10 +23,7 @@ export const getDateSeparatorValue = ({ return undefined; } - const previousMessageDate = previousMessage?.created_at.toDateString(); - const messageDate = message?.created_at.toDateString(); - - if (previousMessageDate !== messageDate) { + if (getDayKey(previousMessage?.created_at) !== getDayKey(message?.created_at)) { return message?.created_at; } @@ -32,13 +37,16 @@ export const useMessageDateSeparator = ({ hideDateSeparators, message, previousMessage, + skip, }: { hideDateSeparators?: boolean; message?: LocalMessage; previousMessage?: LocalMessage; + /** Set when the message list already resolved the separator, so no work is done here. */ + skip?: boolean; }) => { const dateSeparatorDate = useMemo(() => { - if (!message && !previousMessage) { + if (skip || (!message && !previousMessage)) { return undefined; } return getDateSeparatorValue({ @@ -46,7 +54,7 @@ export const useMessageDateSeparator = ({ message, previousMessage, }); - }, [hideDateSeparators, message, previousMessage]); + }, [skip, hideDateSeparators, message, previousMessage]); return dateSeparatorDate; }; diff --git a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts index 43378ff6420..94b9b2c63d 100644 --- a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts +++ b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts @@ -10,15 +10,7 @@ import { getGroupStyle } from '../utils/getGroupStyles'; /** * Hook to get the group styles for a message */ -export const useMessageGroupStyles = ({ - noGroupByUser, - dateSeparatorDate, - maxTimeBetweenGroupedMessages, - message, - previousMessage, - nextMessage, - getMessageGroupStyle = getGroupStyle, -}: { +export const useMessageGroupStyles = (params: { noGroupByUser?: boolean; getMessageGroupStyle: MessagesContextValue['getMessageGroupStyle']; dateSeparatorDate?: Date; @@ -26,12 +18,33 @@ export const useMessageGroupStyles = ({ message: LocalMessage; previousMessage?: LocalMessage; nextMessage?: LocalMessage; + /** + * The separator rendered above the next message - it closes the current group. Supplied by the + * message list when a `getDateSeparators` override resolved it. Omit the key entirely and the + * hook derives it from `nextMessage`, which is what a standalone caller wants. + */ + nextMessageDateSeparatorDate?: Date; }) => { - // This is needed to calculate the group styles for the next message - const nextMessageDateSeparatorDate = useMessageDateSeparator({ + const { + noGroupByUser, + dateSeparatorDate, + maxTimeBetweenGroupedMessages, + message, + previousMessage, + nextMessage, + getMessageGroupStyle = getGroupStyle, + } = params; + + // presence of the key, not its value, `undefined` is a meaningful resolved answer. + const isResolvedByCaller = 'nextMessageDateSeparatorDate' in params; + const derivedNextMessageDateSeparatorDate = useMessageDateSeparator({ message: nextMessage, previousMessage: message, + skip: isResolvedByCaller, }); + const nextMessageDateSeparatorDate = isResolvedByCaller + ? params.nextMessageDateSeparatorDate + : derivedNextMessageDateSeparatorDate; const groupStyles = useMemo(() => { if (noGroupByUser) { diff --git a/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts b/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts index ed5fb7180c..1d34d4a4a6 100644 --- a/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts +++ b/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts @@ -1,9 +1,85 @@ import { LocalMessage } from 'stream-chat'; +import { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; +import { getDateSeparatorValue } from '../hooks/useMessageDateSeparator'; + export type MessageListItemWithNeighbours = { nextMessage?: LocalMessage; previousMessage?: LocalMessage; message: LocalMessage; + /** The date separator to render above this row, if any. */ + dateSeparatorDate?: Date; + /** The date separator rendered above the next row, needed to close a message group. */ + nextMessageDateSeparatorDate?: Date; +}; + +/** + * The SDK's date separator rule, in the shape the `getDateSeparators` override uses: a map of + * message id to the separator rendered above that message. + * + * Exported so integrators can build on the default rule instead of reimplementing it. + * + * @param messages the loaded messages, oldest first + */ +export const getDefaultDateSeparators = ({ + hideDateSeparators, + messages, +}: { + messages: LocalMessage[]; + hideDateSeparators?: boolean; +}) => { + const separators: Record = {}; + + if (hideDateSeparators) { + return separators; + } + + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + const date = getDateSeparatorValue({ message, previousMessage: messages[index - 1] }); + if (date) { + separators[message.id] = date; + } + } + + return separators; +}; + +/** + * Resolves the date separator for every row, using the `getDateSeparators` override when one is + * supplied and the SDK's own rule otherwise. + * + * The override always receives the list oldest first, whichever list component is rendering, so + * the same override works under `MessageList` and `MessageFlashList`. + */ +export const resolveDateSeparatorDates = ( + messages: LocalMessage[], + { + getDateSeparators, + hideDateSeparators, + isInverted, + }: { + isInverted: boolean; + getDateSeparators: NonNullable; + hideDateSeparators?: boolean; + }, +) => { + const separators = getDateSeparators({ + hideDateSeparators, + messages: isInverted ? [...messages].reverse() : messages, + }); + + const dateSeparatorDates: (Date | undefined)[] = new Array(messages.length).fill(undefined); + + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + const date = separators[message.id]; + // Keep the message's own Date instance whenever the value matches, so that rows whose + // separator has not actually changed stay memoized across list updates. + dateSeparatorDates[index] = date && +date === +message.created_at ? message.created_at : date; + } + + return dateSeparatorDates; }; export const getMessageListItemCacheKey = (item: LocalMessage, index: number) => { @@ -19,6 +95,7 @@ export const getMessageListItemCacheKey = (item: LocalMessage, index: number) => export const buildMessageListWithNeighbours = ( processedMessageList: LocalMessage[], previousDerivedItems: Map, + dateSeparatorDates?: (Date | undefined)[], ) => { const nextDerivedItems = new Map(); @@ -28,18 +105,46 @@ export const buildMessageListWithNeighbours = ( const nextMessage = processedMessageList[index - 1]; const previousDerived = previousDerivedItems.get(cacheKey); + // Without a `getDateSeparators` override there are no resolved dates to carry, and rows + // derive their own - so the default path does not pay for the two extra fields at all. + if (!dateSeparatorDates) { + if ( + previousDerived && + previousDerived.message === message && + previousDerived.previousMessage === previousMessage && + previousDerived.nextMessage === nextMessage && + previousDerived.dateSeparatorDate === undefined + ) { + nextDerivedItems.set(cacheKey, previousDerived); + return previousDerived; + } + + const derivedItem: MessageListItemWithNeighbours = { nextMessage, previousMessage, message }; + + nextDerivedItems.set(cacheKey, derivedItem); + return derivedItem; + } + + const dateSeparatorDate = dateSeparatorDates[index]; + // the list is inverted, so the next (newer) row sits at index - 1 + const nextMessageDateSeparatorDate = dateSeparatorDates[index - 1]; + if ( previousDerived && previousDerived.message === message && previousDerived.previousMessage === previousMessage && - previousDerived.nextMessage === nextMessage + previousDerived.nextMessage === nextMessage && + previousDerived.dateSeparatorDate === dateSeparatorDate && + previousDerived.nextMessageDateSeparatorDate === nextMessageDateSeparatorDate ) { nextDerivedItems.set(cacheKey, previousDerived); return previousDerived; } const derivedItem: MessageListItemWithNeighbours = { + dateSeparatorDate, nextMessage, + nextMessageDateSeparatorDate, previousMessage, message, }; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 2da1e185ae..fab7ca9764 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -128,6 +128,23 @@ export type MessagesContextValue = Pick Record; + getMessageGroupStyle?: (params: MessageGroupStylesParams) => GroupStyle[]; /** * Handler to access when a ban user action is invoked.