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
34 changes: 27 additions & 7 deletions src/components/ChannelListItem/ChannelListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
159 changes: 140 additions & 19 deletions src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
dispatchChannelTruncatedEvent,
dispatchMessageDeletedEvent,
dispatchMessageNewEvent,
dispatchMessageReadEvent,
dispatchMessageUpdatedEvent,
dispatchNotificationMarkRead,
dispatchNotificationMarkUnread,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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);
});
});

Expand All @@ -587,7 +706,7 @@ describe('ChannelPreview', () => {
},
render,
);
expectUnreadCountToBe(screen.getByTestId, unreadCount);
await expectUnreadCountToBe(screen.getByTestId, unreadCount);
await act(() => {
dispatchNotificationMarkUnread({
channel: channelInPreview,
Expand All @@ -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 () => {
Expand All @@ -611,7 +730,7 @@ describe('ChannelPreview', () => {
},
render,
);
expectUnreadCountToBe(screen.getByTestId, unreadCount);
await expectUnreadCountToBe(screen.getByTestId, unreadCount);
await act(() => {
dispatchNotificationMarkUnread({
channel: activeChannel,
Expand All @@ -620,21 +739,22 @@ 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,
channel: activeChannel,
},
render,
);
expectUnreadCountToBe(screen.getByTestId, unreadCount);
await expectUnreadCountToBe(screen.getByTestId, unreadCount);

const eventPayload = { unread_channels: 2, unread_messages: 5 };
await act(() => {
Expand All @@ -645,22 +765,23 @@ 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,
channel: channelInPreview,
},
render,
);
expectUnreadCountToBe(screen.getByTestId, unreadCount);
await expectUnreadCountToBe(screen.getByTestId, unreadCount);

const eventPayload = { unread_channels: 2, unread_messages: 5 };
await act(() => {
Expand All @@ -671,7 +792,7 @@ describe('ChannelPreview', () => {
user,
});
});
expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages);
await expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages);
});
});

Expand Down
Loading