Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 31 additions & 40 deletions package/src/components/Message/MessageItemView/MessageWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -135,6 +123,11 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW
<InlineDateSeparator date={dateSeparatorDate} />
</View>
) : null;
const renderUnreadIndicator = showUnreadUnderlay ? (
<View style={styles.unreadUnderlayContainer}>
<InlineUnreadIndicator unreadCount={unreadCount} />
</View>
) : null;

const renderMessage = (
<Message
Expand All @@ -154,24 +147,22 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW
{message.type === 'system' ? (
<>
{allowDateSeparatorForSystemMessages ? renderDateSeperator : null}
{renderUnreadIndicator}
<MessageSystem message={message} style={messageContainer} />
</>
) : wrapMessageInTheme ? (
<ThemeProvider mergedStyle={modifiedTheme}>
{renderDateSeperator}
{renderUnreadIndicator}
{renderMessage}
</ThemeProvider>
) : (
<>
{renderDateSeperator}
{renderUnreadIndicator}
{renderMessage}
</>
)}
{showUnreadUnderlay && (
<View style={styles.unreadUnderlayContainer}>
<InlineUnreadIndicator unreadCount={unreadCount} />
</View>
)}
</View>
);
});
Expand Down
59 changes: 56 additions & 3 deletions package/src/components/MessageList/__tests__/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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();
});
});
Expand All @@ -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<Parameters<typeof generateChannelResponse>[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(
<OverlayProvider>
<Chat client={chatClient}>
<Channel channel={channel}>
<MessageList />
</Channel>
</Chat>
</OverlayProvider>,
);

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();
Expand Down
Loading