From 9d388557c5a8d289dbf3c5f84c9b880cb647b053 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 22 Jul 2026 22:45:44 +0200 Subject: [PATCH] feat: reactive channel preview state --- .../ChannelPreview/ChannelPreview.tsx | 26 ++-- .../__tests__/ChannelPreview.test.tsx | 7 ++ .../hooks/useChannelPreviewData.ts | 119 +++--------------- 3 files changed, 31 insertions(+), 121 deletions(-) diff --git a/package/src/components/ChannelPreview/ChannelPreview.tsx b/package/src/components/ChannelPreview/ChannelPreview.tsx index 7934094bce..4428e8dbe2 100644 --- a/package/src/components/ChannelPreview/ChannelPreview.tsx +++ b/package/src/components/ChannelPreview/ChannelPreview.tsx @@ -5,24 +5,20 @@ import type { Channel } from 'stream-chat'; import { ChannelSwipableWrapper } from './ChannelSwipableWrapper'; import { useChannelPreviewData } from './hooks/useChannelPreviewData'; -import { - ChannelsContextValue, - useChannelsContext, -} from '../../contexts/channelsContext/ChannelsContext'; +import { useChannelsContext } from '../../contexts/channelsContext/ChannelsContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTranslatedMessage } from '../../hooks/useTranslatedMessage'; -export type ChannelPreviewProps = Partial> & - Partial> & { - /** - * Instance of Channel from stream-chat package. - */ - channel: Channel; - }; +export type ChannelPreviewProps = Partial> & { + /** + * Instance of Channel from stream-chat package. + */ + channel: Channel; +}; export const ChannelPreview = (props: ChannelPreviewProps) => { - const { channel, client: propClient, forceUpdate: propForceUpdate } = props; + const { channel, client: propClient } = props; const { client: contextClient } = useChatContext(); const { getChannelActionItems, swipeActionsEnabled } = useChannelsContext(); @@ -30,11 +26,7 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { const client = propClient || contextClient; - const { muted, pinned, unread, lastMessage } = useChannelPreviewData( - channel, - client, - propForceUpdate, - ); + const { muted, pinned, unread, lastMessage } = useChannelPreviewData(channel, client); const translatedLastMessage = useTranslatedMessage(lastMessage); diff --git a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx index 1492676271..ec29e81633 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx @@ -72,6 +72,13 @@ const initChannelFromData = async ( return channel; }; +// TODO(#27/#8): the unread-count assertions below exercise the pre-reactive, mock-driven mechanism +// (mocking `channel.countUnread()` + dispatching `notification.mark_read`/`mark_unread` so the old WS +// listeners refreshed). `useChannelPreviewData` now sources unread reactively from +// `channel.state.readStore` and no longer calls `countUnread()`, so these need reworking to seed +// `readStore` and dispatch the events that actually mutate it (`message.read`, `message.new`). +// Deferred with the rest of the portal-blocked test staleness (jest cannot currently run — the local +// stream-chat-js portal breaks module resolution); fix when the portal is removed. describe('ChannelPreview', () => { const clientUser = generateUser(); let chatClient: StreamChat; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index 34d96a8daa..63c3646211 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -1,24 +1,19 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect } from 'react'; -import throttle from 'lodash/throttle'; import type { Channel, - Event, LocalMessage, MessagePaginatorAggregateState, MessageResponse, + ReadState, StreamChat, } from 'stream-chat'; import { useIsChannelMuted } from './useIsChannelMuted'; import { useIsChannelPinned } from './useIsChannelPinned'; -import { useChannelsContext } from '../../../contexts'; import { useStateStore } from '../../../hooks'; -const refreshUnreadCountThrottleTimeout = 400; -const refreshUnreadCountThrottleOptions = { leading: true, trailing: true }; - export type LastMessageType = LocalMessage | MessageResponse; // The last message is sourced reactively from the paginator's `aggregateState` — a store kept @@ -30,114 +25,30 @@ const lastMessageSelector = (state: MessagePaginatorAggregateState) => ({ lastMessage: state.lastMessage ?? undefined, }); -export const useChannelPreviewData = ( - channel: Channel, - client: StreamChat, - forceUpdateOverride?: number, -) => { - const [, setForceUpdate] = useState(0); +export const useChannelPreviewData = (channel: Channel, client: StreamChat) => { + const userId = client.userID; const { lastMessage } = useStateStore(channel.messagePaginator.aggregateState, lastMessageSelector) ?? {}; - const [unread, setUnread] = useState(channel.countUnread()); const { muted } = useIsChannelMuted(channel); const pinned = useIsChannelPinned(channel); - const { forceUpdate: contextForceUpdate } = useChannelsContext(); - const channelListForceUpdate = forceUpdateOverride ?? contextForceUpdate; - const refreshUnreadCount = useMemo( - () => - throttle( - () => { - if (muted) { - setUnread(0); - } else { - setUnread(channel.countUnread()); - } - }, - refreshUnreadCountThrottleTimeout, - refreshUnreadCountThrottleOptions, - ), - [channel, muted], + // unread + const ownUnreadSelector = useCallback( + (state: ReadState) => ({ + unread: userId ? (state.read[userId]?.unread_messages ?? 0) : 0, + }), + [userId], ); + const { unread: reactiveUnread } = + useStateStore(channel.state.readStore, ownUnreadSelector) ?? {}; + // muted channels always render a zeroed unread count + const unread = muted ? 0 : (reactiveUnread ?? 0); + // drafts useEffect(() => { const unsubscribe = channel.messageComposer.registerDraftEventSubscriptions(); return () => unsubscribe(); }, [channel.messageComposer]); - useEffect(() => { - const { unsubscribe } = client.on('notification.mark_read', () => { - refreshUnreadCount(); - }); - return unsubscribe; - }, [client, refreshUnreadCount]); - - /** - * This effect listens for the `notification.mark_read` event and sets the unread count to 0 - */ - useEffect(() => { - const handleReadEvent = (event: Event) => { - if (!event.cid) { - return; - } - if (channel.cid !== event.cid) { - return; - } - if (event?.user?.id === client.userID) { - setUnread(0); - } else if (event?.user?.id) { - setForceUpdate((prev) => prev + 1); - } - }; - const readSubscription = client.on('message.read', handleReadEvent); - // `message.read_locally` is the client-only equivalent emitted by `channel.markReadLocally()` when - // read events are disabled (e.g. livestreams with `isLocalUnreadCountEnabled`). - const localReadSubscription = client.on('message.read_locally', handleReadEvent); - return () => { - readSubscription.unsubscribe(); - localReadSubscription.unsubscribe(); - }; - }, [client, channel]); - - /** - * This effect listens for the `notification.mark_unread` event and updates the unread count - */ - useEffect(() => { - const handleUnreadEvent = (event: Event) => { - if (!event.cid) { - return; - } - if (channel.cid !== event.cid) { - return; - } - if (event.user?.id !== client.user?.id) { - return; - } - setUnread(channel.countUnread()); - }; - const { unsubscribe } = client.on('notification.mark_unread', handleUnreadEvent); - return unsubscribe; - }, [client, channel]); - - /** - * Keep the unread count in sync with events that can change it. The last message itself is sourced - * reactively from the paginator (see `lastMessageSelector`), so these listeners only refresh unread. - */ - useEffect(() => { - refreshUnreadCount(); - - const handleEvent = () => { - refreshUnreadCount(); - }; - - const listeners = [ - channel.on('message.new', handleEvent), - channel.on('message.undeleted', handleEvent), - channel.on('channel.truncated', handleEvent), - ]; - - return () => listeners.forEach((l) => l.unsubscribe()); - }, [channel, refreshUnreadCount, channelListForceUpdate]); - return { lastMessage, muted, pinned, unread }; };