From 9b9069ca1748ced02f4448475c2abd781794960a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 12 Nov 2026 11:19:38 +0100 Subject: [PATCH 1/4] fix: unread and date separator order --- .../MessageItemView/MessageWrapper.tsx | 70 +++++++++---------- .../__tests__/MessageList.test.tsx | 59 +++++++++++++++- 2 files changed, 90 insertions(+), 39 deletions(-) diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index f9d9921325..f11fbb81de 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -56,44 +56,39 @@ 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 goes on the first unread row, below its date separator and above the + // message, with the last read message as its older neighbor. 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. 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; + 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. + // Live boundary: the separator goes on the first unread from another user whose older + // neighbor is read (created at or before `lastReadAt`). Requiring that neighbor 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. 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; + // Own messages sent elsewhere after the boundary leave no read row above the first unread + // from another user, so the rule above never fires. Anchor on the last read message then. + const isOwnUnreadAfterLastRead = + isOwnMessage && previousMessageId === snapshot.lastReadMessageId; + showUnreadSeparator = + thisIsUnread && previousIsRead && (!isOwnMessage || isOwnUnreadAfterLastRead); } else { showUnreadSeparator = false; } @@ -108,11 +103,11 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW }, [ createdAtTimestamp, + isOwnMessage, isThreadList, messageId, - nextMessageCreatedAt, - nextMessageId, - nextMessageIsOwn, + previousMessageCreatedAt, + previousMessageId, ], ); const { showUnreadSeparator, unreadCount } = useStateStore( @@ -135,6 +130,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(); From 994ce43d6f616f4405511e11734370d8a1da5b70 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 12 Nov 2026 11:22:19 +0100 Subject: [PATCH 2/4] fix: simplify comments --- .../MessageItemView/MessageWrapper.tsx | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index f11fbb81de..f5585d280b 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -61,30 +61,25 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW const previousMessageCreatedAt = previousMessage?.created_at ?? undefined; const isThreadList = !!messagePaginator.parentMessageId; - // The unread separator goes on the first unread row, below its date separator and above the - // message, with the last read message as its older neighbor. 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 || !previousMessageId) { showUnreadSeparator = false; } else if (snapshot.firstUnreadMessageId) { - // Frozen boundary (channel open or mark-unread): anchor to the known first unread ID. + // 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 first unread from another user whose older - // neighbor is read (created at or before `lastReadAt`). Requiring that neighbor 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 thisIsUnread = typeof createdAtTimestamp === 'number' && createdAtTimestamp > lastReadAt; const previousIsRead = previousMessageCreatedAt !== undefined && previousMessageCreatedAt <= lastReadAt; - // Own messages sent elsewhere after the boundary leave no read row above the first unread - // from another user, so the rule above never fires. Anchor on the last read message then. + // When own messages follow the last read one, anchor on the last read message instead. const isOwnUnreadAfterLastRead = isOwnMessage && previousMessageId === snapshot.lastReadMessageId; showUnreadSeparator = @@ -95,9 +90,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW 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, }; }, From 6755fc1c7bef3ee5041bf07d539a7d3603bd0b7c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 12 Nov 2026 12:34:18 +0100 Subject: [PATCH 3/4] fix: button moving on stepper for 2 digit numbers --- .../components/Poll/components/MultipleVotesSettings.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/package/src/components/Poll/components/MultipleVotesSettings.tsx b/package/src/components/Poll/components/MultipleVotesSettings.tsx index dab00f2030..d86851dcfc 100644 --- a/package/src/components/Poll/components/MultipleVotesSettings.tsx +++ b/package/src/components/Poll/components/MultipleVotesSettings.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { I18nManager, Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; import Animated, { LinearTransition, StretchInY, StretchOutY } from 'react-native-reanimated'; @@ -238,14 +238,15 @@ const useStyles = () => { row: { alignItems: 'center', flexDirection: 'row', - gap: primitives.spacingXxs, + gap: primitives.spacingXs, }, input: { color: semantics.textPrimary, fontSize: primitives.typographyFontSizeMd, - paddingHorizontal: primitives.spacingSm, + minWidth: 32, + paddingHorizontal: 0, paddingVertical: primitives.spacingMd, - textAlign: I18nManager.isRTL ? 'right' : 'left', + textAlign: 'center', }, buttonEnabled: { borderColor: semantics.borderCoreOnSurface, From 01d4d69fae509c9391caead9caa2ac31b190fe2f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 12 Nov 2026 12:35:33 +0100 Subject: [PATCH 4/4] Revert "fix: button moving on stepper for 2 digit numbers" This reverts commit 6755fc1c7bef3ee5041bf07d539a7d3603bd0b7c. --- .../components/Poll/components/MultipleVotesSettings.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/package/src/components/Poll/components/MultipleVotesSettings.tsx b/package/src/components/Poll/components/MultipleVotesSettings.tsx index d86851dcfc..dab00f2030 100644 --- a/package/src/components/Poll/components/MultipleVotesSettings.tsx +++ b/package/src/components/Poll/components/MultipleVotesSettings.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import { I18nManager, Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; import Animated, { LinearTransition, StretchInY, StretchOutY } from 'react-native-reanimated'; @@ -238,15 +238,14 @@ const useStyles = () => { row: { alignItems: 'center', flexDirection: 'row', - gap: primitives.spacingXs, + gap: primitives.spacingXxs, }, input: { color: semantics.textPrimary, fontSize: primitives.typographyFontSizeMd, - minWidth: 32, - paddingHorizontal: 0, + paddingHorizontal: primitives.spacingSm, paddingVertical: primitives.spacingMd, - textAlign: 'center', + textAlign: I18nManager.isRTL ? 'right' : 'left', }, buttonEnabled: { borderColor: semantics.borderCoreOnSurface,