diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index f9d9921325..f5585d280b 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -56,63 +56,51 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW // `created_at` is a numeric wire timestamp, directly comparable (`new Date(ns)` yields NaN). const createdAtTimestamp = message.created_at; const messageId = message.id; - const nextMessageId = nextMessage?.id; - const nextMessageIsOwn = nextMessage?.user?.id === client.userId; - const nextMessageCreatedAt = nextMessage?.created_at ?? undefined; + const isOwnMessage = message.user?.id === client.userId; + const previousMessageId = previousMessage?.id; + const previousMessageCreatedAt = previousMessage?.created_at ?? undefined; const isThreadList = !!messagePaginator.parentMessageId; - // The unread separator goes on the row above the first unread message from another user. It is - // located per message inside the selector, so the flag stays `false` for every other row and a - // mark-read only re-renders the boundary rows whose flag flips, not the whole list. + // The unread separator sits on the first unread row, between its date separator and the message. + // Resolved per row in the selector, so only the boundary rows re-render when it moves. const showUnreadSeparatorSelector = useCallback( (snapshot: UnreadSnapshotState) => { let showUnreadSeparator: boolean; - if (isThreadList) { + if (isThreadList || !previousMessageId) { showUnreadSeparator = false; } else if (snapshot.firstUnreadMessageId) { - // Frozen boundary (channel open or mark-unread): anchor to the known first unread ID. - showUnreadSeparator = nextMessageId === snapshot.firstUnreadMessageId; + // Channel open and mark-unread freeze the boundary at a known first unread ID. + showUnreadSeparator = messageId === snapshot.firstUnreadMessageId; } else if (snapshot.unreadCount) { - // Live boundary: the separator goes on the last read row (created at or before - // `lastReadAt`) whose newer neighbor is the first unread from another user. Requiring this - // row to be read keeps it to one separator when own messages are interleaved among the - // unreads, as an own message sent after the boundary is not read. + // The live boundary is the first unread from another user whose previous message is read. + // Own messages among the unreads are not read, so this matches only once. const lastReadAt = snapshot.lastReadAt ?? 0; - const nextIsUnreadFromOther = - !!nextMessageId && - !nextMessageIsOwn && - nextMessageCreatedAt !== undefined && - nextMessageCreatedAt > lastReadAt; - const thisIsRead = - typeof createdAtTimestamp === 'number' && createdAtTimestamp <= lastReadAt; - // Own messages sent elsewhere after the boundary leave no read row above the first unread, - // so the rule above never fires. Anchor on the last read message then. - const isLastReadBeforeOwnUnread = - thisIsRead && - messageId === snapshot.lastReadMessageId && - nextMessageIsOwn && - nextMessageCreatedAt !== undefined && - nextMessageCreatedAt > lastReadAt; - showUnreadSeparator = (nextIsUnreadFromOther && thisIsRead) || isLastReadBeforeOwnUnread; + const thisIsUnread = + typeof createdAtTimestamp === 'number' && createdAtTimestamp > lastReadAt; + const previousIsRead = + previousMessageCreatedAt !== undefined && previousMessageCreatedAt <= lastReadAt; + // When own messages follow the last read one, anchor on the last read message instead. + const isOwnUnreadAfterLastRead = + isOwnMessage && previousMessageId === snapshot.lastReadMessageId; + showUnreadSeparator = + thisIsUnread && previousIsRead && (!isOwnMessage || isOwnUnreadAfterLastRead); } else { showUnreadSeparator = false; } return { showUnreadSeparator, - // Only the boundary row needs the unread count (for its inline indicator). Gating it on - // `showUnreadSeparator` keeps it `undefined` elsewhere, so a changing channel-wide count - // does not re-render the whole list. + // Only the boundary row reads the count, so count changes don't re-render the other rows. unreadCount: showUnreadSeparator ? snapshot.unreadCount : undefined, }; }, [ createdAtTimestamp, + isOwnMessage, isThreadList, messageId, - nextMessageCreatedAt, - nextMessageId, - nextMessageIsOwn, + previousMessageCreatedAt, + previousMessageId, ], ); const { showUnreadSeparator, unreadCount } = useStateStore( @@ -135,6 +123,11 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW ) : null; + const renderUnreadIndicator = showUnreadUnderlay ? ( + + + + ) : null; const renderMessage = ( {allowDateSeparatorForSystemMessages ? renderDateSeperator : null} + {renderUnreadIndicator} ) : wrapMessageInTheme ? ( {renderDateSeperator} + {renderUnreadIndicator} {renderMessage} ) : ( <> {renderDateSeperator} + {renderUnreadIndicator} {renderMessage} )} - {showUnreadUnderlay && ( - - - - )} ); }); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 406127e83f..4ff9472703 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -564,7 +564,7 @@ describe('MessageList', () => { ); }; - it('should render the InlineUnreadIndicator after the last read message when own messages sent elsewhere follow it', async () => { + it('should render the InlineUnreadIndicator above the first unread message when own messages sent elsewhere follow the last read one', async () => { const { getAllByLabelText, getByTestId } = await renderWithUnreadAfterOwn([ 'own', 'other', @@ -574,7 +574,7 @@ describe('MessageList', () => { await waitFor(() => { expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); expect( - within(getByTestId('message-list-item-5')).queryByLabelText('Inline unread indicator'), + within(getByTestId('message-list-item-6')).queryByLabelText('Inline unread indicator'), ).toBeTruthy(); }); }); @@ -589,11 +589,64 @@ describe('MessageList', () => { await waitFor(() => { expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); expect( - within(getByTestId('message-list-item-5')).queryByLabelText('Inline unread indicator'), + within(getByTestId('message-list-item-6')).queryByLabelText('Inline unread indicator'), ).toBeTruthy(); }); }); + it('should render the InlineUnreadIndicator below the date separator of the first unread message', async () => { + const user1 = generateUser(); + const user2 = generateUser(); + // Messages 0-5 (read) on one day, 6-9 (unread) on the next, so the first unread row carries a + // date separator. + const dayOne = new Date('2020-01-01T00:00:00.000Z').getTime(); + const dayTwo = new Date('2020-01-02T00:00:00.000Z').getTime(); + const messages = Array.from({ length: 10 }, (_, i) => + generateMessage({ + id: `${i}`, + text: `message-${i}`, + timestamp: new Date((i < 6 ? dayOne : dayTwo) + i * 1000), + user: user2, + }), + ); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 }), generateMember({ user: user2 })], + messages, + read: [ + { + user: user1, + last_read: msToNs(dayOne + 5000), + last_read_message_id: '5', + unread_messages: 4, + }, + ] as unknown as NonNullable[0]>['read'], + }); + + const chatClient = await getTestClientWithUser({ id: user1.id } as UserResponse); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const { getAllByLabelText, getByTestId } = render( + + + + + + + , + ); + + await waitFor(() => { + expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); + expect( + within(getByTestId('message-list-item-6')) + .queryAllByTestId(/^(date-separator|inline-unread-indicator|message-wrapper)$/) + .map((node) => node.props.testID), + ).toEqual(['date-separator', 'inline-unread-indicator', 'message-wrapper']); + }); + }); + it("should not render the UnreadMessagesIndicator when there's no unread messages", async () => { const user1 = generateUser(); const user2 = generateUser();