diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 7bfbde2add..b5c27b33cf 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.1.12", "react-native-web": "^0.21.2", "react-native-worklets": "0.11.1", - "stream-chat": "^10.0.0-rc.19", + "stream-chat": "^10.0.0-rc.20", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index e3a320f498..0398f2b762 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -65,7 +65,7 @@ "react-native-teleport": "^1.1.12", "react-native-video": "^6.19.2", "react-native-worklets": "^0.12.1", - "stream-chat": "^10.0.0-rc.19", + "stream-chat": "^10.0.0-rc.20", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/src/components/UnreadCountBadge.tsx b/examples/SampleApp/src/components/UnreadCountBadge.tsx index af653cb490..8d5e640404 100644 --- a/examples/SampleApp/src/components/UnreadCountBadge.tsx +++ b/examples/SampleApp/src/components/UnreadCountBadge.tsx @@ -26,7 +26,7 @@ export const ChannelsUnreadCountBadge: React.FC = () => { * Listen to changes in unread counts and update the badge count. * * We deliberately derive the total from each channel's local `countUnread()` (a sum over the - * loaded/active channels) rather than the server-sent `event.total_unread_count`. The local count + * loaded channels) rather than the server-sent `event.total_unread_count`. The local count * respects the "viewing live" gate — the channel the user is currently reading at the bottom stays * at 0 — so the badge does NOT flicker when a message arrives in the active channel. The server * total, by contrast, briefly counts that message until the mark-read round-trips. @@ -36,10 +36,9 @@ export const ChannelsUnreadCountBadge: React.FC = () => { return; } const computeUnreadCount = () => - Object.values(chatClient.activeChannels).reduce( - (count, channel) => count + (channel?.countUnread() ?? 0), - 0, - ); + chatClient.channelManager + .values() + .reduce((count, channel) => count + channel.countUnread(), 0); setUnreadCount(computeUnreadCount()); const listener = chatClient.on(() => { diff --git a/package/package.json b/package/package.json index 8fb18960f2..3cbb28275a 100644 --- a/package/package.json +++ b/package/package.json @@ -80,7 +80,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^10.0.0-rc.19", + "stream-chat": "^10.0.0-rc.20", "use-sync-external-store": "^1.7.0" }, "peerDependencies": { diff --git a/package/src/__tests__/offline-support/offline-feature.tsx b/package/src/__tests__/offline-support/offline-feature.tsx index 5ecf01c869..46eedcd990 100644 --- a/package/src/__tests__/offline-support/offline-feature.tsx +++ b/package/src/__tests__/offline-support/offline-feature.tsx @@ -30,8 +30,8 @@ const getSyncManager = (client: StreamChat): TestSyncManager => (client.offlineDb as unknown as { syncManager: TestSyncManager }).syncManager; const asHydrateChannelsMock = ( client: StreamChat, -): StreamChat['hydrateActiveChannels'] & { mock: { calls: unknown[][] } } => - client.hydrateActiveChannels as StreamChat['hydrateActiveChannels'] & { +): StreamChat['hydrateChannels'] & { mock: { calls: unknown[][] } } => + client.hydrateChannels as StreamChat['hydrateChannels'] & { mock: { calls: unknown[][] }; }; @@ -220,7 +220,11 @@ export const Generic = () => { // `cid` is not part of `GeneratedChannelResponseCustomValues`, but tests rely on reading it // back as a top-level field on the generated channel response — keep the runtime shape and // widen the input type. + // The list below filters on the custom field `foo`, so every channel the server returns for it + // carries `foo: 'bar'`. Client-side filter matching (channel.updated / channel.truncated) relies on it, + // reading custom fields from `data.custom`. return generateChannelResponse({ + channel: { custom: { foo: 'bar' } }, cid, id, members, @@ -409,10 +413,31 @@ export const Generic = () => { ); }); + // `disconnectUser()` is not always a logout (e.g. `useCreateChatClient` unmounting), so it must not + // touch what the offline DB cached for the user, or the next offline cold start shows nothing. + it('keeps the cached channel list when the user disconnects', async () => { + useMockedApis(chatClient, [queryChannelsApi(channels)]); + await renderComponent(); + act(() => dispatchConnectionChangedEvent(chatClient)); + await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true)); + + const cachedCids = async () => { + const rows = await BetterSqlite.selectFromTable('channelQueries'); + return JSON.parse(rows[0].cids as string) as string[]; + }; + await waitFor(async () => expect(await cachedCids()).toHaveLength(channels.length)); + + await act(async () => { + await chatClient.disconnectUser(); + }); + + expect(await cachedCids()).toHaveLength(channels.length); + }); + it('should fetch channels from the db correctly even if they are empty', async () => { const emptyChannel = createChannel([]); useMockedApis(chatClient, [queryChannelsApi([emptyChannel])]); - jest.spyOn(chatClient, 'hydrateActiveChannels'); + jest.spyOn(chatClient, 'hydrateChannels'); await renderComponent(); @@ -421,7 +446,7 @@ export const Generic = () => { await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true)); expect(screen.getByTestId('channel-list-view')).toBeTruthy(); expect(screen.getByTestId(emptyChannel.cid)).toBeTruthy(); - expect(chatClient.hydrateActiveChannels).toHaveBeenCalled(); + expect(chatClient.hydrateChannels).toHaveBeenCalled(); expect(asHydrateChannelsMock(chatClient).mock.calls[0][0]).toStrictEqual([emptyChannel]); }); }); @@ -767,7 +792,7 @@ export const Generic = () => { expect(matchingRows.length).toBe(1); expect(matchingRows[0].hidden).toBeTruthy(); expect(matchingMessagesRows.length).toBe( - chatClient.activeChannels[hiddenChannel.cid].messagePaginator.headItems.length, + chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.headItems.length, ); }); }); @@ -802,11 +827,12 @@ export const Generic = () => { expect(matchingRows.length).toBe(1); expect(matchingRows[0].hidden).toBeTruthy(); expect(matchingMessagesRows.length).toBe( - chatClient.activeChannels[hiddenChannel.cid].messagePaginator.headItems.length, + chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.headItems.length, ); }); - // then, we make it visible after waiting for everything to finish + // then, we make it visible after waiting for everything to finish. It still matches the list's + // filter, so the list takes it back (v9 promoted it on `channel.visible` by default too). act(() => dispatchChannelVisibleEvent(chatClient, hiddenChannel)); await waitFor(async () => { const channelIdsOnUI = screen @@ -816,7 +842,7 @@ export const Generic = () => { (node as unknown as { _fiber: { pendingProps: { testID: string } } })._fiber .pendingProps.testID, ); - expect(channelIdsOnUI.includes(hiddenChannel.cid)).toBeFalsy(); + expect(channelIdsOnUI.includes(hiddenChannel.cid)).toBeTruthy(); await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); const channelsRows = await BetterSqlite.selectFromTable('channels'); @@ -828,7 +854,7 @@ export const Generic = () => { expect(matchingRows.length).toBe(1); expect(matchingRows[0].hidden).toBeFalsy(); expect(matchingMessagesRows.length).toBe( - chatClient.activeChannels[hiddenChannel.cid].messagePaginator.headItems.length, + chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.headItems.length, ); }); }); @@ -908,7 +934,6 @@ export const Generic = () => { .pendingProps.testID, ); expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy(); - expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); const messagesRows = await BetterSqlite.selectFromTable('messages'); const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid); @@ -922,6 +947,7 @@ export const Generic = () => { expect(matchingReadRows.length).toBe(1); expect(matchingReadRows[0].unreadMessages).toBe(0); }); + await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); }); it('should truncate the correct messages if channel.truncated arrives with truncated_at', async () => { @@ -959,7 +985,6 @@ export const Generic = () => { .pendingProps.testID, ); expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy(); - expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); const messagesRows = await BetterSqlite.selectFromTable('messages'); const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid); @@ -975,6 +1000,7 @@ export const Generic = () => { expect(matchingReadRows.length).toBe(1); expect(matchingReadRows[0].unreadMessages).toBe(messagesLeft); }); + await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); }); it('should gracefully handle a truncated_at date before each message', async () => { @@ -1004,13 +1030,13 @@ export const Generic = () => { .pendingProps.testID, ); expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy(); - expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); const messagesRows = await BetterSqlite.selectFromTable('messages'); const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid); expect(matchingMessagesRows.length).toBe(channelResponse.messages.length); }); + await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); }); it('should gracefully handle a truncated_at date after each message', async () => { @@ -1045,13 +1071,13 @@ export const Generic = () => { .pendingProps.testID, ); expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy(); - expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); const messagesRows = await BetterSqlite.selectFromTable('messages'); const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid); expect(matchingMessagesRows.length).toBe(0); }); + await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText); }); it('should add a reaction to DB when a new reaction is added', async () => { diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 06531c5096..809f4e2f50 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -23,6 +23,8 @@ import { useCreateThreadContext } from './hooks/useCreateThreadContext'; import { DEFAULT_HIGHLIGHT_DURATION } from './hooks/useMessageListPagination'; +import { useSupersededChannelSwap } from './hooks/useSupersededChannelSwap'; + import { AttachmentPickerContextValue, AttachmentPickerProvider, @@ -541,14 +543,14 @@ const ChannelWithContext = (props: PropsWithChildren) = } // Seed the paginator for a cold open (deep link / push). Channels reached via the channel - // list are already seeded by client.hydrateActiveChannels, so guard on an empty paginator + // list are already seeded by client.hydrateChannels, so guard on an empty paginator // to avoid a redundant fetch. if (!channel.messagePaginator.state.getLatestValue().items?.length) { await channel.messagePaginator.reload(); } // Re-seed the unread snapshot from the CURRENT read state on every open. The paginator is - // usually reused from cache (the reload above is skipped), and hydrateActiveChannels merges + // usually reused from cache (the reload above is skipped), and hydrateChannels merges // rather than re-seeds, so without this the snapshot's boundary/count/first-unread stay frozen // at the very first open — making the separator, the "N new" banner and the jump-to-first-unread // target all go stale on reopen. Mirrors stream-chat-react, which re-seeds by re-querying on open. @@ -581,15 +583,10 @@ const ChannelWithContext = (props: PropsWithChildren) = // Mark the channel active while this is mounted. The LLC refcounts `active`, so a // Channel instance shared with the channel-list preview or a thread stays active until the last // mount unmounts. Being active enables auto-mark-read-on-focus and suppresses destructive - // channel-list re-seeding of the open channel's message list on reconnect. Keyed on `channel` and - // balanced (the cleanup deactivates the exact instance the effect activated), so swapping the - // `channel` prop deactivates the previous instance before activating the new one. - useEffect(() => { - channel?.activate?.(); - return () => { - channel?.deactivate?.(); - }; - }, [channel]); + // channel-list re-seeding of the open channel's message list on reconnect. `activate()` returns + // the release for this exact activation, so swapping the `channel` prop releases the previous + // instance before activating the new one. + useEffect(() => channel?.activate(), [channel]); // subscribe to channel.deleted event useEffect(() => { @@ -945,9 +942,10 @@ export const Channel = (props: PropsWithChildren) => { const { client, isMessageAIGenerated } = useChatContext(); const isOnline = useSettledWSConnectionHealth(); const { t } = useTranslationContext(); + const channel = useSupersededChannelSwap(props.channel); const notificationHostId = props.notificationHostId ?? - (props.channel?.cid ? getChannelNotificationHostId(props.channel.cid) : undefined); + (channel?.cid ? getChannelNotificationHostId(channel.cid) : undefined); const threadFromProps = props?.thread; const threadInstance = (threadFromProps as ThreadType)?.threadInstance as Thread; @@ -969,6 +967,7 @@ export const Channel = (props: PropsWithChildren) => { t, }} {...props} + channel={channel} shouldSyncChannel={shouldSyncChannel} {...{ isMessageAIGenerated, diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 74ab490679..06aac55a07 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -364,6 +364,89 @@ describe('Channel', () => { }); }); + // `activate()` returns the release for that one activation. A mount that never runs it leaves the + // channel active for the rest of the session, which nothing else would surface. + it('releases its activation when the channel prop changes and on unmount', async () => { + const otherChannel = chatClient.channel('messaging', 'other-channel'); + const renderChannel = (target: ChannelType) => ( + + + + ); + + const { rerender, unmount } = render(renderChannel(channel)); + await waitFor(() => expect(channel.active).toBe(true)); + + rerender(renderChannel(otherChannel)); + await waitFor(() => expect(otherChannel.active).toBe(true)); + expect(channel.active).toBe(false); + + unmount(); + expect(otherChannel.active).toBe(false); + }); + + // A channel created from members whose first query finds its real cid already stored (e.g. by + // `notification.added_to_channel`) gets no events from then on, so moves to the stored one. + describe('when the channel is superseded by the stored instance', () => { + const setup = () => { + const otherUser = generateUser({ id: 'other' }); + const draft = chatClient.channel('messaging', { + members: [{ user_id: user.id }, { user_id: otherUser.id }], + }); + const stored = chatClient.channel('messaging', '!members-xyz'); + // the mount's first-page load would query (and so supersede) the draft before the test does + jest.spyOn(draft.messagePaginator, 'reload').mockResolvedValue(undefined); + useMockedApis(chatClient, [ + getOrCreateChannelApi( + generateChannelResponse({ + id: '!members-xyz', + members: [generateMember({ user }), generateMember({ user: otherUser })], + type: channelType, + }), + ), + ]); + let contextChannel: ChannelType | undefined; + renderComponent({ channel: draft, initializeOnMount: false }, (ctx) => { + contextChannel = (ctx as ChannelContextValue).channel; + }); + return { draft, getContextChannel: () => contextChannel, stored }; + }; + + it('renders the stored instance, with what was typed', async () => { + const { draft, getContextChannel, stored } = setup(); + await waitFor(() => expect(draft.active).toBe(true)); + draft.messageComposer.textComposer.setText('hello'); + + await act(async () => { + await draft.watch(); + }); + + await waitFor(() => expect(getContextChannel()).toBe(stored)); + expect(draft.supersededBy).toBe(stored); + expect(stored.active).toBe(true); + expect(draft.active).toBe(false); + expect(stored.messageComposer.textComposer.text).toBe('hello'); + }); + + it('waits while the stored instance is open elsewhere and the composer holds a draft', async () => { + const { draft, getContextChannel, stored } = setup(); + const releaseElsewhere = stored.activate(); + await waitFor(() => expect(draft.active).toBe(true)); + draft.messageComposer.textComposer.setText('hello'); + + await act(async () => { + await draft.watch(); + }); + + expect(draft.supersededBy).toBe(stored); + expect(getContextChannel()).toBe(draft); + + act(() => draft.messageComposer.clear()); + await waitFor(() => expect(getContextChannel()).toBe(stored)); + releaseElsewhere(); + }); + }); + it('does not re-render while the message list queries', async () => { // `Channel` subscribes to the paginator for `hasMessages` (what to show when a query errors). // That selector must not carry `isLoading` with it: nothing here reads it, but `useStateStore` diff --git a/package/src/components/Channel/__tests__/useCreateChannelContext.test.ts b/package/src/components/Channel/__tests__/useCreateChannelContext.test.ts index 1acb5ee6db..a9d653e011 100644 --- a/package/src/components/Channel/__tests__/useCreateChannelContext.test.ts +++ b/package/src/components/Channel/__tests__/useCreateChannelContext.test.ts @@ -5,9 +5,12 @@ import type { Channel } from 'stream-chat'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { useCreateChannelContext } from '../hooks/useCreateChannelContext'; +// one instance, as the channel store keeps one per cid and channel traffic never replaces it +const channel = { id: 'channel-1' } as unknown as Channel; + const baseInput = (overrides: Partial = {}) => ({ - channel: { id: 'channel-1' } as unknown as Channel, + channel, disabled: false, enableMessageGroupingByUser: true, enforceUniqueReaction: false, @@ -39,6 +42,8 @@ describe('useCreateChannelContext', () => { it.each([ ['channel id', { channel: { id: 'channel-2' } as unknown as Channel }], + // a superseded instance shares its id with the instance that replaced it + ['channel instance', { channel: { id: 'channel-1' } as unknown as Channel }], ['disabled', { disabled: true }], ['isChannelActive', { isChannelActive: false }], ['threadList', { threadList: true }], diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index df5dd7a08e..a65c6b1563 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -33,8 +33,9 @@ export const useCreateChannelContext = ({ hasPendingInitialTargetLoad, threadList, }), + // Keyed on the instance as well, since a superseded instance shares its id with its successor // eslint-disable-next-line react-hooks/exhaustive-deps - [channelId, disabled, isChannelActive, threadList], + [channel, channelId, disabled, isChannelActive, threadList], ); return channelContext; diff --git a/package/src/components/Channel/hooks/useSupersededChannelSwap.ts b/package/src/components/Channel/hooks/useSupersededChannelSwap.ts new file mode 100644 index 0000000000..851fe27c30 --- /dev/null +++ b/package/src/components/Channel/hooks/useSupersededChannelSwap.ts @@ -0,0 +1,25 @@ +import type { Channel, ChannelStateData, EditingAuditState } from 'stream-chat'; + +import { useStateStore } from '../../../hooks/useStateStore'; + +const supersededBySelector = ({ supersededBy }: ChannelStateData) => ({ supersededBy }); +const activeSelector = ({ active }: ChannelStateData) => ({ active }); +const editingAuditStateSelector = (state: EditingAuditState) => state; + +/** + * The instance `` renders: the one that superseded `channel` (it gets the events from then + * on), as stream-chat-react's `SupersededChannelSwap` does. It waits while the successor is open + * elsewhere and this composer isn't empty, as stream-chat only moves a composer into an unused one. + */ +export const useSupersededChannelSwap = ( + channel: T, +): T | Channel => { + const { supersededBy } = useStateStore(channel?.state, supersededBySelector) ?? {}; + const successorActive = useStateStore(supersededBy?.state, activeSelector)?.active ?? false; + // re-rendered on every composer change, so the emptiness read below stays current + useStateStore(channel?.messageComposer.editingAuditState, editingAuditStateSelector); + const composerIsEmpty = channel?.messageComposer.compositionIsEmpty ?? true; + + if (!supersededBy || (successorActive && !composerIsEmpty)) return channel; + return supersededBy; +}; diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index 27b41acbba..9bcbf33e35 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -133,7 +133,7 @@ export const usePaginatedChannels = ({ // Do NOT skip state initialization on the (re)query. `activeChannels.current` is // `Object.keys(channelsState)` — every channel ever MOUNTED, and it is never cleared on - // navigate-back — so passing it as `skipInitialization` made `hydrateActiveChannels` skip + // navigate-back — so passing it as `skipInitialization` made `hydrateChannels` skip // `seedFirstPageSync`/`_initializeState` for every previously-opened channel on each reconnect. // Those channels' `messagePaginator.aggregateState` then never re-seeds on the fresh socket, so // their list-row preview (last message / unread, sourced from that aggregate) freezes while the diff --git a/package/src/components/Chat/hooks/useCreateChatContext.ts b/package/src/components/Chat/hooks/useCreateChatContext.ts index 53ba662015..d56727e162 100644 --- a/package/src/components/Chat/hooks/useCreateChatContext.ts +++ b/package/src/components/Chat/hooks/useCreateChatContext.ts @@ -13,9 +13,7 @@ export const useCreateChatContext = ({ }: ChatContextValue) => { const channelId = channel?.id; const clientValues = client - ? `${client.clientID}${Object.keys(client.activeChannels).length}${ - Object.keys(client.listeners).length - }${client.mutedChannels.length}` + ? `${client.clientID}${Object.keys(client.listeners).length}${client.mutedChannels.length}` : 'Offline'; const mutedUsersLength = mutedUsers.length; diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index b86aa1651f..55a8ac5ab1 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -137,8 +137,8 @@ type MessageComposerPropsWithContext = { isOnline: boolean } & Pick< ChannelContextValue, 'channel' > & { - members: MembersState['members']; - watchers: ChannelWatchState['watchers']; + members?: MembersState['members']; + watchers?: ChannelWatchState['watchers']; } & Pick< MessageInputContextValue, | 'audioRecorderManager' @@ -175,16 +175,12 @@ const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ height: state.height, }); -const membersAndWatchersSelector = (state: ChannelWatchState & MembersState) => ({ - members: state.members, - watchers: state.watchers, -}); - const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { const { additionalTextInputProps, asyncMessagesLockDistance, asyncMessagesSlideToCancelDistance, + channel, closeAttachmentPicker, closePollCreationDialog, createPollOptionGap, @@ -259,8 +255,9 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { const getMembers = () => { const result: UserResponse[] = []; - if (members && Object.values(members).length) { - Object.values(members).forEach((member) => { + const channelMembers = members ?? channel.state.getLatestValue().members; + if (channelMembers && Object.values(channelMembers).length) { + Object.values(channelMembers).forEach((member) => { if (member.user) { result.push(member.user); } @@ -287,8 +284,9 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { const getWatchers = () => { const result: UserResponse[] = []; - if (watchers && Object.values(watchers).length) { - result.push(...Object.values(watchers)); + const channelWatchers = watchers ?? channel.state.getLatestValue().watchers; + if (channelWatchers && Object.values(channelWatchers).length) { + result.push(...Object.values(channelWatchers)); } return result; @@ -618,10 +616,6 @@ export const MessageComposer = (props: MessageComposerProps) => { const ownCapabilities = useOwnCapabilitiesContext(); const { channel } = useChannelContext(); - const { members, watchers } = useStateStore(channel.state, membersAndWatchersSelector) ?? { - members: {}, - watchers: {}, - }; const { audioRecorderManager, @@ -692,7 +686,6 @@ export const MessageComposer = (props: MessageComposerProps) => { inputBoxRef, isKeyboardVisible, isOnline, - members, messageInputFloating, messageInputHeightStore, openPollCreationDialog, @@ -700,7 +693,6 @@ export const MessageComposer = (props: MessageComposerProps) => { showPollCreationDialog, t, uploadNewFile, - watchers, }} {...props} /> diff --git a/package/src/components/MessageInput/__tests__/MessageComposer.test.tsx b/package/src/components/MessageInput/__tests__/MessageComposer.test.tsx index 2b08d012b7..f74062e077 100644 --- a/package/src/components/MessageInput/__tests__/MessageComposer.test.tsx +++ b/package/src/components/MessageInput/__tests__/MessageComposer.test.tsx @@ -3,12 +3,15 @@ import React from 'react'; import { Alert } from 'react-native'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; -import type { Channel as ChannelType, StreamChat } from 'stream-chat'; +import type { Channel as ChannelType, StreamChat, UserResponse } from 'stream-chat'; import * as AttachmentPickerUtils from '../../../contexts/attachmentPickerContext/AttachmentPickerContext'; +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateUser } from '../../../mock-builders/generator/user'; import { AttachmentPickerStore } from '../../../state-store/attachment-picker-store'; import { AttachmentPickerContent } from '../../AttachmentPicker/components/AttachmentPickerContent'; @@ -71,6 +74,41 @@ describe('MessageComposer', () => { }); }); + // Mention candidates are read from the channel when `Input` asks for them. A member added after mount + // (or a presence / user update, which republishes members) must not leave them stale. + it('gives Input the current members as mention candidates', async () => { + let getUsers: () => UserResponse[] = () => []; + const CapturingInput = (props: { getUsers: () => UserResponse[] }) => { + getUsers = props.getUsers; + return null; + }; + + render( + + + + + + + + + , + ); + await waitFor(() => expect(getUsers().length).toBeGreaterThan(0)); + + const newcomer = generateUser({ id: 'newcomer' }); + act(() => + channel.state.partialNext({ + members: { + ...channel.state.getLatestValue().members, + [newcomer.id]: generateMember({ user: newcomer }), + }, + }), + ); + + expect(getUsers().map((user) => user.id)).toContain(newcomer.id); + }); + it('should render MessageComposer', async () => { const channelProps = { channel }; diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index ef32a5165a..57861a1d20 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -110,12 +110,8 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { // Activating registers the thread with `client.threads` for the session, which keeps it subscribed // (incoming replies, read state, thread.updated) whether or not the thread list holds it. Keyed on - // the instance, which can arrive after mount; the cleanup deactivates the previous one. - useEffect(() => { - if (!threadInstance) return; - threadInstance.activate?.(); - return () => threadInstance.deactivate?.(); - }, [threadInstance]); + // the instance, which can arrive after mount; `activate()` returns the release of this activation. + useEffect(() => threadInstance?.activate(), [threadInstance]); // Load the first reply page, but only when the paginator hasn't already been seeded from the // thread's `latest_replies` (managed/queried threads seed on construction) or loaded/loading. A diff --git a/package/src/components/Thread/__tests__/Thread.test.tsx b/package/src/components/Thread/__tests__/Thread.test.tsx index 04024bb719..8aef0c197c 100644 --- a/package/src/components/Thread/__tests__/Thread.test.tsx +++ b/package/src/components/Thread/__tests__/Thread.test.tsx @@ -205,8 +205,27 @@ describe('Thread', () => { }); }); - // Metadata (parent, read state, participants) comes with a queried page. A thread `ensure` builds has only - // its parent message, so it starts stale and reloads once on open; one that already has its data doesn't. + // Metadata (parent, read state, participants) comes with a queried page. A thread `ensure` builds for a + // parent that has replies has only that parent, so it starts stale and reloads once on open; one that + // already has its data doesn't. + // An activation that is never released keeps the thread active for the session, so recovery + // keeps reloading it and it keeps being read automatically. + it('releases its activation of the thread on unmount', async () => { + const parentMessage = generateMessage({ cid: 'messaging:test-channel', text: 'Parent' }); + const threadInstance = chatClient.threads.ensure({ channel, parentMessage }); + jest.spyOn(threadInstance, 'reload').mockResolvedValue(undefined); + jest.spyOn(threadInstance.messagePaginator, 'toTail').mockResolvedValue(undefined); + const { unmount } = renderComponent({ + channel, + chatClient, + thread: { thread: parentMessage, threadInstance }, + }); + await waitFor(() => expect(threadInstance.state.getLatestValue().active).toBe(true)); + + unmount(); + expect(threadInstance.state.getLatestValue().active).toBe(false); + }); + describe('metadata reload on open', () => { const openThread = ( threadInstance: ThreadClass, @@ -221,7 +240,11 @@ describe('Thread', () => { }; it('reloads a thread ensure built from its parent message', async () => { - const parentMessage = generateMessage({ cid: 'messaging:test-channel', text: 'Parent' }); + const parentMessage = generateMessage({ + cid: 'messaging:test-channel', + reply_count: 1, + text: 'Parent', + }); const threadInstance = chatClient.threads.ensure({ channel, parentMessage }); const { reload } = openThread(threadInstance, parentMessage); @@ -229,7 +252,11 @@ describe('Thread', () => { }); it('does not fetch the first reply page while that reload is in flight, only if it fails', async () => { - const parentMessage = generateMessage({ cid: 'messaging:test-channel', text: 'Parent' }); + const parentMessage = generateMessage({ + cid: 'messaging:test-channel', + reply_count: 1, + text: 'Parent', + }); const threadInstance = chatClient.threads.ensure({ channel, parentMessage }); let failReload: () => void = () => undefined; jest.spyOn(threadInstance, 'reload').mockImplementation(async () => { @@ -254,8 +281,14 @@ describe('Thread', () => { }); it('does not reload a thread that already has its data', async () => { - const parentMessage = generateMessage({ cid: 'messaging:test-channel', text: 'Parent' }); + const parentMessage = generateMessage({ + cid: 'messaging:test-channel', + reply_count: 1, + text: 'Parent', + }); const threadInstance = new ThreadClass({ channel, client: chatClient, parentMessage }); + // the thread list's query hydrates what it lists, so a listed thread isn't stale + threadInstance.state.partialNext({ isStateStale: false }); act(() => { chatClient.threads.paginator.setItems({ isFirstPage: true, diff --git a/package/src/hooks/messagePreview/__tests__/useMessageDeliveryStatus.test.tsx b/package/src/hooks/messagePreview/__tests__/useMessageDeliveryStatus.test.tsx index 75a9b1e6e5..7a347a6885 100644 --- a/package/src/hooks/messagePreview/__tests__/useMessageDeliveryStatus.test.tsx +++ b/package/src/hooks/messagePreview/__tests__/useMessageDeliveryStatus.test.tsx @@ -73,4 +73,47 @@ describe('useMessageDeliveryStatus', () => { ); expect(result.current.status).toBe(MessageDeliveryStatus.READ); }); + + // The receipts tracker rebuilds its reader arrays on every snapshot, presence and user updates + // included, so a preview row must only re-render when the status itself changes. + it('does not re-render when the receipts change without changing the status', async () => { + const { + channels: [channel], + client, + } = await initiateClientWithChannels({ customUser: generateUser({ id: 'me' }) }); + const wrapper = ({ children }: PropsWithChildren) => {children}; + let renders = 0; + const { result } = renderHook( + () => { + renders++; + return useMessageDeliveryStatus({ + channel, + isReadEventsEnabled: true, + lastMessage: ownMessage, + }); + }, + { wrapper }, + ); + + act(() => + setReceipts(channel, { + delivered: { [ownMessage.id]: [fromPartial({ id: 'other' })] }, + }), + ); + expect(result.current.status).toBe(MessageDeliveryStatus.DELIVERED); + const rendersWhenDelivered = renders; + + act(() => + setReceipts(channel, { + delivered: { + [ownMessage.id]: [ + fromPartial({ id: 'other' }), + fromPartial({ id: 'third' }), + ], + }, + }), + ); + expect(result.current.status).toBe(MessageDeliveryStatus.DELIVERED); + expect(renders).toBe(rendersWhenDelivered); + }); }); diff --git a/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts b/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts index a05e571b3c..f3f571eb87 100644 --- a/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts +++ b/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts @@ -27,7 +27,8 @@ type MessageDeliveryStatusProps = { isReadEventsEnabled: boolean; }; -const EMPTY_USERS: UserResponse[] = []; +const hasOtherUser = (users: UserResponse[] | undefined, currentUserId: string | undefined) => + !!users && (users.length > 1 || (users.length === 1 && users[0].id !== currentUserId)); /** * Delivery/read status of the last own message, sourced reactively from the channel's @@ -43,16 +44,17 @@ export const useMessageDeliveryStatus = ({ }: MessageDeliveryStatusProps) => { const { client } = useChatContext(); const messageId = lastMessage?.id ?? ''; + const currentUserId = client.user?.id; const selector = useCallback( (snapshot: MessageReceiptsSnapshot) => ({ - deliveredTo: snapshot.deliveredByMessageId[messageId] ?? EMPTY_USERS, - readers: snapshot.readersByMessageId[messageId] ?? EMPTY_USERS, + isDelivered: hasOtherUser(snapshot.deliveredByMessageId[messageId], currentUserId), + isRead: hasOtherUser(snapshot.readersByMessageId[messageId], currentUserId), }), - [messageId], + [currentUserId, messageId], ); - const { deliveredTo, readers } = useStateStore( + const { isDelivered, isRead } = useStateStore( channel.messageReceiptsTracker.snapshotStore, selector, ); @@ -62,25 +64,21 @@ export const useMessageDeliveryStatus = ({ return MessageDeliveryStatus.NOT_SENT_BY_CURRENT_USER; } - const currentUserId = client.user?.id; const isOwnMessage = !!currentUserId && lastMessage?.user?.id === currentUserId; if (lastMessage?.created_at == null || !isOwnMessage) { return undefined; } - if (readers.length > 1 || (readers.length === 1 && readers[0].id !== currentUserId)) { + if (isRead) { return MessageDeliveryStatus.READ; } - if ( - deliveredTo.length > 1 || - (deliveredTo.length === 1 && deliveredTo[0].id !== currentUserId) - ) { + if (isDelivered) { return MessageDeliveryStatus.DELIVERED; } return MessageDeliveryStatus.SENT; - }, [client.user?.id, deliveredTo, isReadEventsEnabled, lastMessage, readers]); + }, [currentUserId, isDelivered, isReadEventsEnabled, isRead, lastMessage]); return { status }; }; diff --git a/yarn.lock b/yarn.lock index 8441ed56cc..8b785e0e56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6610,7 +6610,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-web: "npm:^0.21.2" react-native-worklets: "npm:0.11.1" - stream-chat: "npm:^10.0.0-rc.19" + stream-chat: "npm:^10.0.0-rc.20" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -17608,7 +17608,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-video: "npm:^6.19.2" react-native-worklets: "npm:^0.12.1" - stream-chat: "npm:^10.0.0-rc.19" + stream-chat: "npm:^10.0.0-rc.20" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18352,7 +18352,7 @@ __metadata: react-native-worklets: "npm:^0.12.1" react-test-renderer: "npm:19.2.3" rimraf: "npm:^6.0.1" - stream-chat: "npm:^10.0.0-rc.19" + stream-chat: "npm:^10.0.0-rc.20" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.7.0" uuid: "npm:^11.1.0" @@ -18426,9 +18426,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^10.0.0-rc.19": - version: 10.0.0-rc.19 - resolution: "stream-chat@npm:10.0.0-rc.19" +"stream-chat@npm:^10.0.0-rc.20": + version: 10.0.0-rc.20 + resolution: "stream-chat@npm:10.0.0-rc.20" dependencies: "@stream-io/logger": "npm:^2.0.0" "@stream-io/state-store": "npm:^1.1.6" @@ -18439,7 +18439,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/d837c9bd6ceeb68c69a406c9ee3af89da3f9f63fc5f7deed6b15dd0b175165e53b4042e4796deb91c7a3e99cbc4c3b8cdcb14377755b8e89e06e6301aeaa31a8 + checksum: 10c0/6fa312d0a2fdb62c5493e5f97d188e15a142d351e5fcae77d9363fde5444d68a47f6206ec05fc4518aceb44a701a6b285b257cd1659e73391418f24d693188f9 languageName: node linkType: hard