diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 21a9fcd884..ddeb86d192 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -131,7 +131,6 @@ const MessageListWithContext = < useMarkRead({ isMessageListScrolledToBottom, messageListIsThread: threadList, - unreadCount: channelUnreadUiState?.unread_messages ?? 0, wasMarkedUnread: !!channelUnreadUiState?.first_unread_message_id, }); diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 897819525d..c87ddeb89f 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -344,7 +344,6 @@ const VirtualizedMessageListWithContext = < useMarkRead({ isMessageListScrolledToBottom, messageListIsThread: !!threadList, - unreadCount: channelUnreadUiState?.unread_messages ?? 0, wasMarkedUnread: !!channelUnreadUiState?.first_unread_message_id, }); diff --git a/src/components/MessageList/__tests__/MessageList.test.js b/src/components/MessageList/__tests__/MessageList.test.js index 8cf6deaa26..c92a33d458 100644 --- a/src/components/MessageList/__tests__/MessageList.test.js +++ b/src/components/MessageList/__tests__/MessageList.test.js @@ -370,94 +370,6 @@ describe('MessageList', () => { afterEach(jest.clearAllMocks); afterAll(jest.restoreAllMocks); - it('should keep displaying the unread messages separator when an unread channel is marked read on mount', async () => { - const user = generateUser(); - const last_read_message_id = 'X'; - const lastReadMessage = generateMessage({ id: last_read_message_id }); - const messages = [lastReadMessage, generateMessage(), generateMessage()]; - const { - channels: [channel], - client: chatClient, - } = await initClientWithChannels({ - channelsData: [ - { - messages, - read: [ - { - last_read: lastReadMessage.created_at.toISOString(), - last_read_message_id, - unread_messages: 2, - user, - }, - ], - }, - ], - customUser: user, - }); - - const markReadMock = jest - .spyOn(channel, 'markRead') - .mockReturnValueOnce(markReadApi(channel)); - - await act(() => { - renderComponent({ - channelProps: { channel }, - chatClient, - msgListProps: { messages }, - }); - }); - - expect(markReadMock).toHaveBeenCalledTimes(1); - expect(screen.queryByText(separatorText)).toBeInTheDocument(); - }); - - it('should display unread messages separator before the first message', async () => { - const user = generateUser(); - const messages = [generateMessage(), generateMessage()]; - const { - channels: [channel], - client: chatClient, - } = await initClientWithChannels({ - channelsData: [ - { - messages, - read: [ - { - last_read: new Date(1).toISOString(), - unread_messages: messages.length, - user, - }, - ], - }, - ], - customUser: user, - }); - - const markReadMock = jest.spyOn(channel, 'markRead').mockReturnValue(markReadApi(channel)); - const Message = () =>
; - let container; - - await act(() => { - const result = renderComponent({ - channelProps: { channel, Message }, - chatClient, - msgListProps: { messages }, - }); - container = result.container; - }); - const listItems = container.querySelectorAll('.str-chat__ul > *'); - expect(listItems).toHaveLength(4); - expect(listItems[1].firstChild).toMatchInlineSnapshot(` - - `); - markReadMock.mockRestore(); - }); - it('should display unread messages separator when a channel is marked unread and remove it when marked read by markRead()', async () => { jest.useFakeTimers(); const markReadBtnTestId = 'test-mark-read'; diff --git a/src/components/MessageList/hooks/__tests__/useMarkRead.test.js b/src/components/MessageList/hooks/__tests__/useMarkRead.test.js index 11fb40889d..913d233065 100644 --- a/src/components/MessageList/hooks/__tests__/useMarkRead.test.js +++ b/src/components/MessageList/hooks/__tests__/useMarkRead.test.js @@ -29,153 +29,210 @@ const render = ({ channel, client, params }) => { return result.current; }; +const unreadLastMessageChannelData = () => { + const user = generateUser(); + const messages = [ + generateMessage({ created_at: new Date(1) }), + generateMessage({ created_at: new Date(2) }), + ]; + return { + messages, + read: [ + { + last_read: new Date(1).toISOString(), + last_read_message_id: messages[0].id, + unread_messages: 1, + user, + }, + ], + }; +}; + +const readLastMessageChannelData = () => { + const user = generateUser(); + const messages = [ + generateMessage({ created_at: new Date(1) }), + generateMessage({ created_at: new Date(2) }), + ]; + return { + messages, + read: [ + { + last_read: new Date(2).toISOString(), + last_read_message_id: messages[1].id, + unread_messages: 0, + user, + }, + ], + }; +}; + +const emptyChannelData = () => { + const user = generateUser(); + return { + messages: [], + read: [ + { + last_read: undefined, + last_read_message_id: undefined, + unread_messages: 0, + user, + }, + ], + }; +}; + describe('useMarkRead', () => { const shouldMarkReadParams = { isMessageListScrolledToBottom: true, markReadOnScrolledToBottom: true, messageListIsThread: false, - unreadCount: 1, wasMarkedUnread: false, }; beforeEach(jest.clearAllMocks); - describe.each([[visibilityChangeScenario], ['render'], ['message.new']])('on %s', (scenario) => { - it('should not mark channel read from thread message list', async () => { + describe.each([[visibilityChangeScenario], ['render']])('on %s', (scenario) => { + it('should mark channel read from non-thread message list scrolled to the bottom not previously marked unread with unread messages', async () => { + const channelData = unreadLastMessageChannelData(); const { channels: [channel], client, - } = await initClientWithChannels(); + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); - render({ + await render({ channel, client, - params: { - ...shouldMarkReadParams, - messageListIsThread: true, - }, + params: shouldMarkReadParams, }); if (scenario === visibilityChangeScenario) { - document.dispatchEvent(new Event('visibilitychange')); - } else if (scenario === 'message.new') { await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); + document.dispatchEvent(new Event('visibilitychange')); }); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); + expect(markRead).toHaveBeenCalledTimes(2); + } else { + expect(markRead).toHaveBeenCalledTimes(1); } - expect(markRead).not.toHaveBeenCalled(); }); - it('should not mark channel read from message list not scrolled to the bottom', async () => { + it('should not mark channel read from non-thread message list scrolled to the bottom previously marked unread with unread messages', async () => { + const channelData = unreadLastMessageChannelData(); const { channels: [channel], client, - } = await initClientWithChannels(); + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); await render({ channel, client, - params: { - ...shouldMarkReadParams, - isMessageListScrolledToBottom: false, - }, + params: { ...shouldMarkReadParams, wasMarkedUnread: true }, + }); + expect(markRead).toHaveBeenCalledTimes(0); + }); + + it('should not mark channel read from non-thread message list scrolled to the bottom not previously marked unread with 0 unread messages', async () => { + const channelData = readLastMessageChannelData(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, }); + await render({ + channel, + client, + params: shouldMarkReadParams, + }); if (scenario === visibilityChangeScenario) { - document.dispatchEvent(new Event('visibilitychange')); - } else if (scenario === 'message.new') { - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementationOnce((cb) => (channelUnreadUiStateCb = cb)); await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); + document.dispatchEvent(new Event('visibilitychange')); }); - expect(setChannelUnreadUiState).toHaveBeenCalledTimes(1); - const channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.unread_messages).toBe(1); } - expect(markRead).not.toHaveBeenCalled(); + expect(markRead).toHaveBeenCalledTimes(0); }); - it('should not mark channel read from message list in channel with 0 unread messages', async () => { + it('should not mark empty channel read', async () => { + const channelData = emptyChannelData(); const { channels: [channel], client, - } = await initClientWithChannels(); + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); - const countUnread = jest.spyOn(channel, 'countUnread').mockReturnValueOnce(0); + await render({ + channel, + client, + params: shouldMarkReadParams, + }); + if (scenario === visibilityChangeScenario) { + await act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + } + expect(markRead).toHaveBeenCalledTimes(0); + }); + + it('should not mark channel read from message list not scrolled to the bottom', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); await render({ channel, client, params: { ...shouldMarkReadParams, - unreadCount: 0, + isMessageListScrolledToBottom: false, }, }); if (scenario === visibilityChangeScenario) { document.dispatchEvent(new Event('visibilitychange')); - } else if (scenario === 'message.new') { - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); } - expect(markRead).not.toHaveBeenCalled(); - countUnread.mockRestore(); }); - it('should not mark channel read from non-thread message list scrolled to the bottom previously marked unread', async () => { + it('should not mark channel read from thread message list', async () => { const { channels: [channel], client, } = await initClientWithChannels(); - await render({ + render({ channel, client, params: { - shouldMarkReadParams, - wasMarkedUnread: true, + ...shouldMarkReadParams, + messageListIsThread: true, }, }); if (scenario === visibilityChangeScenario) { document.dispatchEvent(new Event('visibilitychange')); - } else if (scenario === 'message.new') { - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementationOnce((cb) => (channelUnreadUiStateCb = cb)); - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(setChannelUnreadUiState).toHaveBeenCalledTimes(1); - const channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.unread_messages).toBe(1); } - expect(markRead).not.toHaveBeenCalled(); }); + }); - it('should mark channel read from non-thread message list scrolled to the bottom not previously marked unread', async () => { - const user = generateUser(); - const messages = [generateMessage(), generateMessage()]; + describe('on message.new', () => { + it('should mark channel read from non-thread message list scrolled to the bottom not previously marked unread with unread messages', async () => { + const channelData = unreadLastMessageChannelData(); const { channels: [channel], client, } = await initClientWithChannels({ - channelsData: [ - { - messages, - read: [ - { - last_read: new Date(1).toISOString(), - unread_messages: messages.length, - user, - }, - ], - }, - ], - customUser: user, + channelsData: [channelData], + customUser: channelData.read[0].user, }); await render({ @@ -183,54 +240,126 @@ describe('useMarkRead', () => { client, params: shouldMarkReadParams, }); - if (scenario === visibilityChangeScenario) { - await act(() => { - document.dispatchEvent(new Event('visibilitychange')); - }); - expect(markRead).toHaveBeenCalledTimes(1); - } else if (scenario === 'message.new') { - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(markRead).toHaveBeenCalledTimes(1); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); - } else { - expect(markRead).toHaveBeenCalledTimes(0); - } + + await act(() => { + dispatchMessageNewEvent(client, generateMessage(), channel); + }); + expect(markRead).toHaveBeenCalledTimes(2); + expect(setChannelUnreadUiState).not.toHaveBeenCalled(); }); - }); - describe('on message.new', () => { - it('should not mark channel read for messages incoming to other channels', async () => { + it('should mark channel read for own messages when scrolled to bottom in main message list', async () => { + const channelData = readLastMessageChannelData(); const { - channels: [activeChannel, otherChannel], + channels: [channel], client, - } = await initClientWithChannels({ channelsData: [generateChannel(), generateChannel()] }); + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); await render({ - channel: activeChannel, + channel, + client, + params: shouldMarkReadParams, + }); + + await act(() => { + dispatchMessageNewEvent( + client, + generateMessage({ user: channelData.read[0].user }), + channel, + ); + }); + + expect(markRead).toHaveBeenCalledTimes(1); + expect(setChannelUnreadUiState).not.toHaveBeenCalled(); + }); + + it('should mark channel read from non-thread message list scrolled to the bottom not previously marked unread with originally 0 unread messages', async () => { + const channelData = readLastMessageChannelData(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); + + await render({ + channel, + client, + params: shouldMarkReadParams, + }); + + await act(() => { + dispatchMessageNewEvent(client, generateMessage(), channel); + }); + expect(markRead).toHaveBeenCalledTimes(1); + expect(setChannelUnreadUiState).not.toHaveBeenCalled(); + }); + + it('should mark originally empty channel read', async () => { + const channelData = emptyChannelData(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); + + await render({ + channel, + client, + params: shouldMarkReadParams, + }); + + await act(() => { + dispatchMessageNewEvent(client, generateMessage(), channel); + }); + expect(markRead).toHaveBeenCalledTimes(1); + expect(setChannelUnreadUiState).not.toHaveBeenCalled(); + }); + + it('should not mark channel read from non-thread message list scrolled to the bottom previously marked unread', async () => { + const channelData = unreadLastMessageChannelData(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); + + await render({ + channel, client, params: { ...shouldMarkReadParams, - unreadCount: 0, + wasMarkedUnread: true, }, }); + let channelUnreadUiStateCb; + setChannelUnreadUiState.mockImplementationOnce((cb) => (channelUnreadUiStateCb = cb)); await act(() => { - dispatchMessageNewEvent(client, generateMessage(), otherChannel); + dispatchMessageNewEvent(client, generateMessage(), channel); }); - + expect(setChannelUnreadUiState).toHaveBeenCalledTimes(1); + const channelUnreadUiState = channelUnreadUiStateCb(); + expect(channelUnreadUiState.unread_messages).toBe(1); expect(markRead).not.toHaveBeenCalled(); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); }); - it('should not mark channel read for own messages', async () => { - const user = generateUser(); + it('should mark channel read from message list not scrolled to the bottom', async () => { + const channelData = readLastMessageChannelData(); const { channels: [channel], client, } = await initClientWithChannels({ - customUser: user, + channelsData: [channelData], + customUser: channelData.read[0].user, }); await render({ @@ -238,42 +367,71 @@ describe('useMarkRead', () => { client, params: { ...shouldMarkReadParams, - unreadCount: 0, + isMessageListScrolledToBottom: false, }, }); + let channelUnreadUiStateCb; + setChannelUnreadUiState.mockImplementationOnce((cb) => (channelUnreadUiStateCb = cb)); await act(() => { - dispatchMessageNewEvent(client, generateMessage({ user }), channel); + dispatchMessageNewEvent(client, generateMessage(), channel); }); - + expect(setChannelUnreadUiState).toHaveBeenCalledTimes(1); + const channelUnreadUiState = channelUnreadUiStateCb(); + expect(channelUnreadUiState.unread_messages).toBe(1); expect(markRead).not.toHaveBeenCalled(); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); }); - it('should not mark channel read for thread messages', async () => { + it('should not mark channel read from thread message list', async () => { + const channelData = readLastMessageChannelData(); const { channels: [channel], client, - } = await initClientWithChannels(); + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); - await render({ + render({ channel, client, params: { ...shouldMarkReadParams, - unreadCount: 0, + messageListIsThread: true, }, }); + await act(() => { + dispatchMessageNewEvent(client, generateMessage(), channel); + }); + expect(setChannelUnreadUiState).not.toHaveBeenCalled(); + expect(markRead).not.toHaveBeenCalled(); + }); + + it('should not mark channel read for messages incoming to other channels', async () => { + const channelData = readLastMessageChannelData(); + const { + channels: [activeChannel, otherChannel], + client, + } = await initClientWithChannels({ + channelsData: [channelData, generateChannel()], + customUser: channelData.read[0].user, + }); + + await render({ + channel: activeChannel, + client, + params: shouldMarkReadParams, + }); await act(() => { - dispatchMessageNewEvent(client, generateMessage({ parent_id: 'X' }), channel); + dispatchMessageNewEvent(client, generateMessage(), otherChannel); }); expect(markRead).not.toHaveBeenCalled(); expect(setChannelUnreadUiState).not.toHaveBeenCalled(); }); - it('should mark channel read for thread messages with event.show_in_channel enabled', async () => { + it('should not mark channel read for thread messages', async () => { const { channels: [channel], client, @@ -282,25 +440,18 @@ describe('useMarkRead', () => { await render({ channel, client, - params: { - ...shouldMarkReadParams, - unreadCount: 0, - }, + params: shouldMarkReadParams, }); await act(() => { - dispatchMessageNewEvent( - client, - generateMessage({ parent_id: 'X', show_in_channel: true }), - channel, - ); + dispatchMessageNewEvent(client, generateMessage({ parent_id: 'X' }), channel); }); - expect(markRead).toHaveBeenCalledTimes(1); + expect(markRead).not.toHaveBeenCalled(); expect(setChannelUnreadUiState).not.toHaveBeenCalled(); }); - it('should mark channel read for not-own messages when scrolled to bottom in main message list', async () => { + it('should mark channel read for thread messages with event.show_in_channel enabled', async () => { const { channels: [channel], client, @@ -309,14 +460,15 @@ describe('useMarkRead', () => { await render({ channel, client, - params: { - ...shouldMarkReadParams, - unreadCount: 0, - }, + params: shouldMarkReadParams, }); await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); + dispatchMessageNewEvent( + client, + generateMessage({ parent_id: 'X', show_in_channel: true }), + channel, + ); }); expect(markRead).toHaveBeenCalledTimes(1); @@ -375,6 +527,7 @@ describe('useMarkRead', () => { const channelUnreadUiState = channelUnreadUiStateCb(); expect(channelUnreadUiState.unread_messages).toBe(1); }); + it('should be performed when document is hidden and is scrolled to the bottom', async () => { let channelUnreadUiStateCb; setChannelUnreadUiState.mockImplementationOnce((cb) => (channelUnreadUiStateCb = cb)); diff --git a/src/components/MessageList/hooks/useMarkRead.ts b/src/components/MessageList/hooks/useMarkRead.ts index 06a1bcf18e..b1bd357d36 100644 --- a/src/components/MessageList/hooks/useMarkRead.ts +++ b/src/components/MessageList/hooks/useMarkRead.ts @@ -1,17 +1,27 @@ -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { StreamMessage, useChannelActionContext, useChannelStateContext, useChatContext, } from '../../../context'; -import { Event, MessageResponse } from 'stream-chat'; -import { DefaultStreamChatGenerics } from '../../../types'; +import type { Channel, Event, MessageResponse } from 'stream-chat'; +import type { DefaultStreamChatGenerics } from '../../../types'; + +const hasReadLastMessage = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + channel: Channel