diff --git a/examples/SampleApp/metro.config.js b/examples/SampleApp/metro.config.js index c96816d4be..2c7e5c5410 100644 --- a/examples/SampleApp/metro.config.js +++ b/examples/SampleApp/metro.config.js @@ -69,6 +69,11 @@ const extraNodeModules = uniqueModules.reduce((acc, item) => { acc[item.packageName] = item.modulePath; return acc; }, {}); +// Point `stream-chat` at the local checkout that is portal-linked via the root package.json +// `resolutions`. We set it after the reduce (rather than seeding the reduce's initial value) +// because stream-chat is a direct dependency of this app, so `uniqueModules` would otherwise +// overwrite the seed. +extraNodeModules['stream-chat'] = '/Users/isekovanic/Projects/stream-chat-js'; config.resolver.blockList = exclusionList(blockList); config.resolver.extraNodeModules = extraNodeModules; @@ -76,6 +81,6 @@ config.resolver.extraNodeModules = extraNodeModules; config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; // add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath]; +config.watchFolders = [packageDirPath, '/Users/isekovanic/Projects/stream-chat-js']; module.exports = config; diff --git a/examples/SampleApp/src/hooks/useChatClient.ts b/examples/SampleApp/src/hooks/useChatClient.ts index 1ced3e1d4e..c33bef5088 100644 --- a/examples/SampleApp/src/hooks/useChatClient.ts +++ b/examples/SampleApp/src/hooks/useChatClient.ts @@ -74,6 +74,7 @@ export const useChatClient = () => { unsubscribePushListenersRef.current?.(); const client = StreamChat.getInstance(config.apiKey, { timeout: 6000, + isLocalUnreadCountEnabled: true, // TEST: localized unread count — revert before committing // logger: (type, msg) => console.log(type, msg) }); setChatClient(client); diff --git a/package.json b/package.json index a3e8cef113..a0b48341e0 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "examples/TypeScriptMessaging" ], "resolutions": { + "stream-chat": "portal:/Users/isekovanic/Projects/stream-chat-js", "@types/react": "^19.2.0", "@shopify/flash-list": "patch:@shopify/flash-list@npm%3A2.3.1#~/.yarn/patches/@shopify-flash-list-npm-2.3.1-8b5fd40241.patch" }, diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 52070c39b8..7b9941bf14 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,7 +690,15 @@ const ChannelWithContext = (props: PropsWithChildren) = return; } + if (event.type === 'message.read_locally') { + // Local unread reset (read events disabled, e.g. livestreams): 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. + return; + } + if (event.type === 'message.read' || event.type === 'notification.mark_read') { + console.log('READ EVENT MENTION ?!', event); setReadThrottled(); return; } @@ -703,7 +711,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 +820,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 +847,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 +1604,6 @@ const ChannelWithContext = (props: PropsWithChildren) = hideStickyDateHeader, highlightedMessageId, isChannelActive: shouldSyncChannel, - lastRead, loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading: channelMessagesState.loading, @@ -1590,7 +1615,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 55dd181241..cbc26f09d9 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -660,9 +660,13 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const lastReadMessageId = channelUnreadState?.last_read_message_id; const lastReadMessageVisible = viewableItems.some((item) => item.item.id === lastReadMessageId); + // Read-events-disabled channels (e.g. 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 ffb77698ef..5ee1382dbf 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -550,9 +550,13 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { (item) => item.item.message.id === lastReadMessageId, ); + // Read-events-disabled channels (e.g. 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 81e7be7a08..6473c2e4b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19683,9 +19683,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.47.1": - version: 9.47.1 - resolution: "stream-chat@npm:9.47.1" +"stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js::locator=root%40workspace%3A.": + version: 0.0.0-use.local + resolution: "stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js::locator=root%40workspace%3A." dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -19701,9 +19701,8 @@ __metadata: built: true husky: built: true - checksum: 10c0/32578f59b8b439454b4a23038417477a16b74ba5677245346d5b0f1cf61139e9b4067e83a77196e1dd661ae2207842df30b345bf4f570872ce9b0cc4463f8eb7 languageName: node - linkType: hard + linkType: soft "stream-combiner2@npm:~1.1.1": version: 1.1.1