diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 6aa3e0a6e4..dcdfd2e3b8 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.0.2", "react-native-web": "^0.21.0", "react-native-worklets": "0.8.3", - "stream-chat": "^9.48.0", + "stream-chat": "^9.50.0", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 504a724331..0110437fa8 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -64,7 +64,7 @@ "react-native-teleport": "^1.1.7", "react-native-video": "^6.19.2", "react-native-worklets": "^0.8.3", - "stream-chat": "^9.48.0", + "stream-chat": "^9.50.0", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/TypeScriptMessaging/package.json b/examples/TypeScriptMessaging/package.json index 57c2924d91..abdf714598 100644 --- a/examples/TypeScriptMessaging/package.json +++ b/examples/TypeScriptMessaging/package.json @@ -33,7 +33,7 @@ "react-native-svg": "^15.12.0", "react-native-video": "^6.16.1", "react-native-worklets": "^0.4.1", - "stream-chat": "^9.48.0", + "stream-chat": "^9.50.0", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index ef657d8524..b0111dd906 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.48.0", + "stream-chat": "^9.50.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 52070c39b8..f972ba4fb1 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -511,12 +511,12 @@ const ChannelWithContext = (props: PropsWithChildren) = const styles = useStyles(); const [deleted, setDeleted] = useState(false); const [error, setError] = useState(false); - const [lastRead, setLastRead] = useState(); + const lastReadRef = useRef(undefined); const [thread, setThread] = useState(threadProps || null); const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); - const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore()); - const [messageInputHeightStore] = useState(new MessageInputHeightStore()); + const [channelUnreadStateStore] = useState(() => new ChannelUnreadStateStore()); + const [messageInputHeightStore] = useState(() => new MessageInputHeightStore()); // TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere. const setChannelUnreadState = useCallback( (data: ChannelUnreadStateStoreType['channelUnreadState']) => { @@ -690,6 +690,13 @@ const ChannelWithContext = (props: PropsWithChildren) = return; } + if (event.type === 'message.read_locally') { + // When local unread reset happens, the count is already updated in the client state, + // and the preview badge / unread divider are handled elsewhere, so there is nothing + // to copy into channel state here. Thus, we skip it. + return; + } + if (event.type === 'message.read' || event.type === 'notification.mark_read') { setReadThrottled(); return; @@ -703,7 +710,7 @@ const ChannelWithContext = (props: PropsWithChildren) = useEffect(() => { let listener: ReturnType; const initChannel = async () => { - setLastRead(new Date()); + lastReadRef.current = new Date(); const unreadCount = channel.countUnread(); const shouldLoadAtFirstUnread = shouldLoadInitialChannelAtFirstUnreadMessage(unreadCount); if (!channel || !shouldSyncChannel) { @@ -812,7 +819,25 @@ const ChannelWithContext = (props: PropsWithChildren) = const markReadInternal: ChannelContextValue['markRead'] = throttle( async (options?: MarkReadFunctionOptions) => { const { updateChannelUnreadState = true } = options ?? {}; - if (!channel || channel?.disconnected || !clientChannelConfig?.read_events) { + if (!channel || channel?.disconnected) { + return; + } + + // When read events are disabled (e.g. livestreams) we cannot mark read on the backend. If the + // client opted into a local unread count, reset it locally instead so the user's "caught up" + // state is reflected without a server round trip. + if (!clientChannelConfig?.read_events) { + if (client.options.isLocalUnreadCountEnabled) { + const event = channel.markReadLocally(); + if (updateChannelUnreadState && event && lastReadRef.current) { + setChannelUnreadState({ + last_read: lastReadRef.current, + last_read_message_id: event.last_read_message_id, + unread_messages: 0, + }); + lastReadRef.current = new Date(); + } + } return; } @@ -821,13 +846,13 @@ const ChannelWithContext = (props: PropsWithChildren) = } else { try { const response = await channel.markRead(); - if (updateChannelUnreadState && response && lastRead) { + if (updateChannelUnreadState && response && lastReadRef.current) { setChannelUnreadState({ - last_read: lastRead, + last_read: lastReadRef.current, last_read_message_id: response?.event.last_read_message_id, unread_messages: 0, }); - setLastRead(new Date()); + lastReadRef.current = new Date(); } } catch (err) { console.log('Error marking channel as read:', err); @@ -1578,7 +1603,6 @@ const ChannelWithContext = (props: PropsWithChildren) = hideStickyDateHeader, highlightedMessageId, isChannelActive: shouldSyncChannel, - lastRead, loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading: channelMessagesState.loading, @@ -1590,7 +1614,6 @@ const ChannelWithContext = (props: PropsWithChildren) = reloadChannel, scrollToFirstUnreadThreshold, setChannelUnreadState, - setLastRead, setTargetedMessage, hasPendingInitialTargetLoad, targetedMessage, diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 8e8870707c..67a495449b 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -13,7 +13,6 @@ export const useCreateChannelContext = ({ hideStickyDateHeader, highlightedMessageId, isChannelActive, - lastRead, loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading, @@ -25,7 +24,6 @@ export const useCreateChannelContext = ({ reloadChannel, scrollToFirstUnreadThreshold, setChannelUnreadState, - setLastRead, setTargetedMessage, hasPendingInitialTargetLoad, targetedMessage, @@ -35,7 +33,6 @@ export const useCreateChannelContext = ({ watchers, }: ChannelContextValue) => { const channelId = channel?.id; - const lastReadTime = lastRead?.getTime(); const membersLength = Object.keys(members).length; const readUsers = Object.values(read); @@ -56,7 +53,6 @@ export const useCreateChannelContext = ({ hideStickyDateHeader, highlightedMessageId, isChannelActive, - lastRead, loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading, @@ -68,7 +64,6 @@ export const useCreateChannelContext = ({ reloadChannel, scrollToFirstUnreadThreshold, setChannelUnreadState, - setLastRead, setTargetedMessage, hasPendingInitialTargetLoad, targetedMessage, @@ -84,7 +79,6 @@ export const useCreateChannelContext = ({ error, isChannelActive, highlightedMessageId, - lastReadTime, loading, membersLength, readUsersLength, diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index bd81574bbb..da37a0b050 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -109,8 +109,14 @@ export const useChannelPreviewData = ( setForceUpdate((prev) => prev + 1); } }; - const { unsubscribe } = client.on('message.read', handleReadEvent); - return unsubscribe; + 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]); /** diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 8eebd18cb1..2fd8404012 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -718,9 +718,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const lastReadMessageId = channelUnreadState?.last_read_message_id; const lastReadMessageVisible = viewableItems.some((item) => item.item.id === lastReadMessageId); + // Channels with disabled `read-events` (i.e livestreams) still surface the unread + // notification when the client opted into a local unread count, so the gate accepts + // either source. + const unreadNotificationSupported = readEvents || client.options.isLocalUnreadCountEnabled; + if ( !viewableItems.length || - !readEvents || + !unreadNotificationSupported || lastReadMessageVisible || attachmentPickerStore.state.getLatestValue().selectedPicker === 'images' ) { diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index ede0407d0c..2ef08ac23b 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -555,9 +555,14 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { (item) => item.item.message.id === lastReadMessageId, ); + // Channels with disabled `read-events` (i.e livestreams) still surface the unread + // notification when the client opted into a local unread count, so the gate accepts + // either source. + const unreadNotificationSupported = readEvents || client.options.isLocalUnreadCountEnabled; + if ( !viewableItems.length || - !readEvents || + !unreadNotificationSupported || lastReadMessageVisible || attachmentPickerStore.state.getLatestValue().selectedPicker === 'images' ) { diff --git a/package/src/components/Thread/__tests__/Thread.test.tsx b/package/src/components/Thread/__tests__/Thread.test.tsx index 72577b01d1..56aad0705c 100644 --- a/package/src/components/Thread/__tests__/Thread.test.tsx +++ b/package/src/components/Thread/__tests__/Thread.test.tsx @@ -142,7 +142,9 @@ describe('Thread', () => { threadResponses as unknown as Parameters[0], ); - let setLastRead: ((date?: Date) => void) | undefined; + let setChannelUnreadState: + | React.ContextType['setChannelUnreadState'] + | undefined; const { getByText, toJSON } = render( @@ -161,7 +163,7 @@ describe('Thread', () => { {(c) => { - setLastRead = c.setLastRead; + setChannelUnreadState = c.setChannelUnreadState; return ; }} @@ -178,7 +180,7 @@ describe('Thread', () => { expect(getByText('Message6')).toBeTruthy(); }); - act(() => setLastRead!(new Date('2020-08-17T18:08:03.196Z'))); + act(() => setChannelUnreadState!(undefined)); const snapshot = toJSON() as unknown as { children: Array<{ diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index a14119ac0e..e9de901882 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -110,7 +110,6 @@ export type ChannelContextValue = { reloadChannel: () => Promise; scrollToFirstUnreadThreshold: number; setChannelUnreadState: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; - setLastRead: React.Dispatch>; setTargetedMessage: (messageId?: string) => void; /** * Returns true when Channel is about to load an initial targeted message. @@ -131,8 +130,6 @@ export type ChannelContextValue = { */ highlightedMessageId?: string; isChannelActive?: boolean; - - lastRead?: Date; loading?: boolean; /** * Maximum time in milliseconds that should occur between messages diff --git a/yarn.lock b/yarn.lock index 968b62bc64..14410c95fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7442,7 +7442,7 @@ __metadata: react-native-teleport: "npm:^1.0.2" react-native-web: "npm:^0.21.0" react-native-worklets: "npm:0.8.3" - stream-chat: "npm:^9.48.0" + stream-chat: "npm:^9.50.0" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:~5.9.2" @@ -18884,7 +18884,7 @@ __metadata: react-native-teleport: "npm:^1.1.7" react-native-video: "npm:^6.19.2" react-native-worklets: "npm:^0.8.3" - stream-chat: "npm:^9.48.0" + stream-chat: "npm:^9.50.0" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:5.9.3" @@ -19611,7 +19611,7 @@ __metadata: react-native-worklets: "npm:^0.9.1" react-test-renderer: "npm:19.2.3" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.48.0" + stream-chat: "npm:^9.50.0" typescript: "npm:5.9.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -19683,9 +19683,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.48.0": - version: 9.48.0 - resolution: "stream-chat@npm:9.48.0" +"stream-chat@npm:^9.50.0": + version: 9.50.0 + resolution: "stream-chat@npm:9.50.0" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -19701,7 +19701,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/45ecea98e5c566098ac73580ca91e6bcfd980015e0e33a6623e53fcab9a57a700369c9a09908d49dae1fba66cd01460a6b47a4c99b8c56198e8a3c7b9b3ba962 + checksum: 10c0/be5941d74946bd1a1371192dd71933a991fd4d8dd070975ee47dca31f938a9c604b6590af5639932f433735907e76a03a7d3519df23c0807f55ef189b21e0c1d languageName: node linkType: hard @@ -20536,7 +20536,7 @@ __metadata: react-native-svg: "npm:^15.12.0" react-native-video: "npm:^6.16.1" react-native-worklets: "npm:^0.4.1" - stream-chat: "npm:^9.48.0" + stream-chat: "npm:^9.50.0" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:5.9.3"