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();