diff --git a/src/components/ChannelListItem/ChannelListItem.tsx b/src/components/ChannelListItem/ChannelListItem.tsx index dd94eb392..7857594ff 100644 --- a/src/components/ChannelListItem/ChannelListItem.tsx +++ b/src/components/ChannelListItem/ChannelListItem.tsx @@ -118,19 +118,39 @@ export const ChannelListItem = (props: ChannelListItemProps) => { const { muted } = useIsChannelMuted(channel); useEffect(() => { - const handleEvent = (event: Event) => { + // Channel-scoped read-state events. These are delivered after + // `Channel._handleChannelEvent` has already updated `channel.state.unreadCount`, + // so recomputing from `countUnread()` here always sees fresh state. + const handleChannelReadStateChange = (event: Event) => { if (channel.cid !== event.cid) return; if (event.user?.id !== client.user?.id) return; setUnread(channel.countUnread()); }; - client.on('notification.mark_read', handleEvent); - channel.on('notification.mark_unread', handleEvent); - channel.on('message.read_locally', handleEvent); + // `notification.mark_read` is a personal event and needs separate handling: + // `cid` and `user` are both optional on it, and — unlike `message.read` — it has no + // case in `Channel._handleChannelEvent`, so `channel.state.unreadCount` may still be + // stale when it arrives. Zero the badge directly instead of recomputing, which keeps + // the result independent of the `message.read` / `notification.mark_read` arrival order. + const handleMarkRead = (event: Event) => { + // a thread was marked read, which does not clear the channel's unread count + if (event.thread_id) return; + if (event.user && client.user && event.user.id !== client.user.id) return; + // a missing `cid` means every channel was marked read + if (!event.cid) return setUnread(0); + if (channel.cid !== event.cid) return; + setUnread(0); + }; + + client.on('notification.mark_read', handleMarkRead); + channel.on('notification.mark_unread', handleChannelReadStateChange); + channel.on('message.read_locally', handleChannelReadStateChange); + channel.on('message.read', handleChannelReadStateChange); return () => { - client.off('notification.mark_read', handleEvent); - channel.off('notification.mark_unread', handleEvent); - channel.off('message.read_locally', handleEvent); + client.off('notification.mark_read', handleMarkRead); + channel.off('notification.mark_unread', handleChannelReadStateChange); + channel.off('message.read_locally', handleChannelReadStateChange); + channel.off('message.read', handleChannelReadStateChange); }; }, [channel, client]); diff --git a/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx b/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx index 98b196aa7..b064c624f 100644 --- a/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx +++ b/src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx @@ -19,6 +19,7 @@ import { dispatchChannelTruncatedEvent, dispatchMessageDeletedEvent, dispatchMessageNewEvent, + dispatchMessageReadEvent, dispatchMessageUpdatedEvent, dispatchNotificationMarkRead, dispatchNotificationMarkUnread, @@ -529,11 +530,11 @@ describe('ChannelPreview', () => { }, render, ); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); await act(() => { - dispatchNotificationMarkRead({ client }); + dispatchNotificationMarkRead({ client, user }); }); - expectUnreadCountToBe(screen.getByTestId, 0); + await expectUnreadCountToBe(screen.getByTestId, 0); }); it('should set unread count to 0 for current channel', async () => { @@ -547,11 +548,11 @@ describe('ChannelPreview', () => { }, render, ); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); await act(() => { - dispatchNotificationMarkRead({ channel: channelInPreview, client }); + dispatchNotificationMarkRead({ channel: channelInPreview, client, user }); }); - expectUnreadCountToBe(screen.getByTestId, 0); + await expectUnreadCountToBe(screen.getByTestId, 0); }); it('should be ignored if not targeted for the current channel', async () => { @@ -566,11 +567,129 @@ describe('ChannelPreview', () => { }, render, ); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); await act(() => { - dispatchNotificationMarkRead({ channel: activeChannel, client }); + dispatchNotificationMarkRead({ channel: activeChannel, client, user }); }); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); + }); + + it('should be ignored if originated from another user', async () => { + const unreadCount = getRandomInt(1, 10); + c0.countUnread = () => unreadCount; + renderComponent( + { + activeChannel: c1, + channel: c0, + }, + render, + ); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); + await act(() => { + dispatchNotificationMarkRead({ channel: c0, client, user: otherUser }); + }); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); + }); + + it('should be ignored if only a thread was marked read', async () => { + const unreadCount = getRandomInt(1, 10); + c0.countUnread = () => unreadCount; + renderComponent( + { + activeChannel: c1, + channel: c0, + }, + render, + ); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); + await act(() => { + dispatchNotificationMarkRead({ + channel: c0, + client, + payload: { thread_id: 'thread-id' }, + user, + }); + }); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); + }); + }); + + // https://github.com/GetStream/stream-chat-react/issues/3264 + // Marking a channel read emits two independent events for the reading user: + // `message.read` (channel) and `notification.mark_read` (personal). Only + // `message.read` resets `channel.state.unreadCount`, so the badge must end up at 0 + // no matter which of the two is delivered first. + describe('marking a channel read with another channel still unread', () => { + const renderUnreadChannelPreview = async () => { + // a real unread count, so the badge does not depend on a countUnread() stub + c0.state.unreadCount = 5; + renderComponent( + { + activeChannel: c1, + channel: c0, + }, + render, + ); + await expectUnreadCountToBe(screen.getByTestId, 5); + }; + + // `unread_channels > 0` keeps StreamChat._handleClientEvent from zeroing + // every active channel, which would mask the problem + const markRead = () => + dispatchNotificationMarkRead({ + channel: c0, + client, + payload: { unread_channels: 1 }, + user, + }); + + it('clears the badge when message.read arrives first', async () => { + await renderUnreadChannelPreview(); + + await act(() => { + dispatchMessageReadEvent(client, user, c0); + }); + await act(() => { + markRead(); + }); + + expect(c0.countUnread()).toBe(0); + await expectUnreadCountToBe(screen.getByTestId, 0); + }); + + it('clears the badge when notification.mark_read arrives first', async () => { + await renderUnreadChannelPreview(); + + await act(() => { + markRead(); + }); + await act(() => { + dispatchMessageReadEvent(client, user, c0); + }); + + expect(c0.countUnread()).toBe(0); + await expectUnreadCountToBe(screen.getByTestId, 0); + }); + + it('clears the badge on message.read alone', async () => { + await renderUnreadChannelPreview(); + + await act(() => { + dispatchMessageReadEvent(client, user, c0); + }); + + expect(c0.countUnread()).toBe(0); + await expectUnreadCountToBe(screen.getByTestId, 0); + }); + + it('ignores message.read from another user', async () => { + await renderUnreadChannelPreview(); + + await act(() => { + dispatchMessageReadEvent(client, otherUser, c0); + }); + + await expectUnreadCountToBe(screen.getByTestId, 5); }); }); @@ -587,7 +706,7 @@ describe('ChannelPreview', () => { }, render, ); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); await act(() => { dispatchNotificationMarkUnread({ channel: channelInPreview, @@ -596,7 +715,7 @@ describe('ChannelPreview', () => { user: otherUser, }); }); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); }); it('should be ignored if not targeted for the current channel', async () => { @@ -611,7 +730,7 @@ describe('ChannelPreview', () => { }, render, ); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); await act(() => { dispatchNotificationMarkUnread({ channel: activeChannel, @@ -620,13 +739,14 @@ describe('ChannelPreview', () => { user, }); }); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); }); it("should set unread count from client's unread count state for active channel", async () => { const unreadCount = 0; const activeChannel = c1; - activeChannel.countUnread = () => unreadCount; + // countUnread() is intentionally NOT stubbed here: the event updates + // channel.state.unreadCount, and reading it back is what this test asserts renderComponent( { activeChannel, @@ -634,7 +754,7 @@ describe('ChannelPreview', () => { }, render, ); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); const eventPayload = { unread_channels: 2, unread_messages: 5 }; await act(() => { @@ -645,14 +765,15 @@ describe('ChannelPreview', () => { user, }); }); - expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages); + await expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages); }); it("should set unread count from client's unread count state for non-active channel", async () => { const unreadCount = 0; const channelInPreview = c0; const activeChannel = c1; - channelInPreview.countUnread = () => unreadCount; + // countUnread() is intentionally NOT stubbed here: the event updates + // channel.state.unreadCount, and reading it back is what this test asserts renderComponent( { activeChannel, @@ -660,7 +781,7 @@ describe('ChannelPreview', () => { }, render, ); - expectUnreadCountToBe(screen.getByTestId, unreadCount); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); const eventPayload = { unread_channels: 2, unread_messages: 5 }; await act(() => { @@ -671,7 +792,7 @@ describe('ChannelPreview', () => { user, }); }); - expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages); + await expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages); }); });