diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 8f8a410a14..88f1a19e02 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.11", + "stream-chat": "^10.0.0-rc.12", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 63215b5ca0..c570368dc3 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.11", + "stream-chat": "^10.0.0-rc.12", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/src/components/ChatScreenHeader.tsx b/examples/SampleApp/src/components/ChatScreenHeader.tsx index 938ca8189f..a90b9a1110 100644 --- a/examples/SampleApp/src/components/ChatScreenHeader.tsx +++ b/examples/SampleApp/src/components/ChatScreenHeader.tsx @@ -4,7 +4,7 @@ import { Image, StyleSheet, TouchableOpacity } from 'react-native'; import type { DrawerNavigationProp } from '@react-navigation/drawer'; import { CompositeNavigationProp, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useChatContext } from 'stream-chat-react-native'; +import { useSettledWSConnectionHealth } from 'stream-chat-react-native'; import { NetworkDownIndicator } from './NetworkDownIndicator'; import { RoundButton } from './RoundButton'; @@ -34,7 +34,7 @@ export const ChatScreenHeader: React.FC<{ title?: string }> = ({ title = 'Stream const navigation = useNavigation(); const { chatClient } = useAppContext(); - const { isOnline } = useChatContext(); + const isOnline = useSettledWSConnectionHealth(); return ( & Pick; export const FastImageAdapter = React.memo((props: ImageProps) => { - const { isOnline } = useChatContext(); + // The device's network, not the socket: these are plain HTTP image fetches. + const isOnline = useNetworkConnectionState()?.isOnline; const { source, transition = FastImage.transition.fade, diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 52ee511f86..4e6ec171f9 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -6,20 +6,20 @@ import { RouteProp, useFocusEffect, useNavigation } from '@react-navigation/nati import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { LocalMessage, Channel as StreamChatChannel, StreamChat } from 'stream-chat'; import { + AITypingIndicatorView, AlsoSentToChannelHeaderPressPayload, Channel, + ChannelAvatar, + MessageActionsParams, MessageComposer, - MessageList, MessageFlashList, + MessageList, + PortalWhileClosingView, useAttachmentPickerContext, useChannelPreviewDisplayName, - useChatContext, useTheme, - AITypingIndicatorView, useTranslationContext, - MessageActionsParams, - ChannelAvatar, - PortalWhileClosingView, + useSettledWSConnectionHealth, } from 'stream-chat-react-native'; import { ThreadType } from 'stream-chat-react-native-core'; @@ -60,7 +60,7 @@ const ChannelHeader: React.FC = ({ channel }) => { const { closePicker } = useAttachmentPickerContext(); const membersStatus = useChannelMembersStatus(channel); const displayName = useChannelPreviewDisplayName(channel); - const { isOnline } = useChatContext(); + const isOnline = useSettledWSConnectionHealth(); const { chatClient } = useAppContext(); const navigation = useNavigation(); diff --git a/package/package.json b/package/package.json index dcb98061eb..59126a9abe 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.11", + "stream-chat": "^10.0.0-rc.12", "use-sync-external-store": "^1.7.0" }, "peerDependencies": { diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index 7160006188..3f98e62477 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -83,12 +83,22 @@ const getOfflineDb = (client: StreamChat): TestOfflineDb => // request fails" tests below therefore force this offline case (they still pass an errored API mock, // but the offline short-circuit is what queues the task), NOT a raw 500. const markConnectionUnhealthy = (client: StreamChat) => { - (client.wsConnection as unknown as { isHealthy: boolean }).isHealthy = false; + // `_setStatus` is the SDK's own documented hook for faking socket status in tests — there is no + // public setter, because only the socket itself is supposed to write this. The connection id is + // dropped alongside it, mirroring `StableWSConnection._applyHealth`, so a watched query issued + // while down waits for the reconnect instead of racing ahead with a dead id. + client.connectionIdManager.invalidate(); + // eslint-disable-next-line no-underscore-dangle + client.wsConnection._setStatus({ isHealthy: false }); }; /** The counterpart of {@link markConnectionUnhealthy}, for tests that go offline and then reconnect. */ const markConnectionHealthy = (client: StreamChat) => { - (client.wsConnection as unknown as { isHealthy: boolean }).isHealthy = true; + // Resolved before the status flips, as the handshake does: anything parked on the id is released + // by this, and requests that need one would otherwise hang with no timeout to rescue them. + client.connectionIdManager.resolveConnectionId('dummy_connection_id'); + // eslint-disable-next-line no-underscore-dangle + client.wsConnection._setStatus({ isHealthy: true }); }; // React flushes passive effects child-first, so the test-callback effect below runs BEFORE `Channel`'s @@ -212,10 +222,10 @@ export const OptimisticUpdates = () => { channels: [channelResponse] as unknown as Parameters[0]['channels'], isLatestMessagesSet: true, }); - chatClient.wsConnection = { - isHealthy: true, - onlineStatusChanged: jest.fn(), - } as unknown as StreamChat['wsConnection']; + // `getTestClientWithUser` already marks the socket up on the real `WSConnection`. Replacing the + // object wholesale would drop its `state` store, which `markConnectionHealthy` / + // `markConnectionUnhealthy` and the connection hooks both read. + markConnectionHealthy(chatClient); }); afterEach(() => { @@ -1157,6 +1167,14 @@ export const OptimisticUpdates = () => { }); describe('pending task execution', () => { + // Every test in this block drives a real reconnect, and since recovery became store-driven its + // `connection` discriminator these events actually reach `ConnectionRecoveryManager` — so each + // one now runs a genuine recovery (`channel.reload()`) plus real SQLite pending-task I/O on top + // of a full render. That lands around 2s alone but exceeded the 5s default under the suite's + // parallel load, flaking roughly one run in two. The work is legitimate, not a hang; the + // default was simply sized for the old no-op path. + jest.setTimeout(20_000); + it('pending task should be executed after connection is recovered', async () => { const message = channel.messagePaginator.headItems[0]; const reaction = generateReaction(); diff --git a/package/src/components/Accessibility/NotificationAnnouncer.tsx b/package/src/components/Accessibility/NotificationAnnouncer.tsx index d638edf2f0..ab057cfda2 100644 --- a/package/src/components/Accessibility/NotificationAnnouncer.tsx +++ b/package/src/components/Accessibility/NotificationAnnouncer.tsx @@ -3,8 +3,9 @@ import { useEffect, useRef } from 'react'; import { useAccessibilityAnnouncer } from './useAccessibilityAnnouncer'; import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; -import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; +import { useNetworkConnectionState } from '../Chat/hooks/useNetworkConnectionState'; +import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState'; /** * Mirrors stream-chat-react's ``. RN does not yet have a @@ -18,7 +19,11 @@ import { useTranslationContext } from '../../contexts/translationContext/Transla */ export const NotificationAnnouncer = () => { const { announceConnectionState, enabled } = useAccessibilityContext(); - const { connectionRecovering, isOnline } = useChatContext(); + const isNetworkOnline = useNetworkConnectionState()?.isOnline; + const isWSOnline = useSettledWSConnectionHealth(); + // The socket is what 'connected' means to a chat user; the device network only decides which + // of the two offline messages is truthful. + const isOnline = !!isWSOnline; const announce = useAccessibilityAnnouncer(); const { t } = useTranslationContext(); const previousIsOnlineRef = useRef(undefined); @@ -36,13 +41,13 @@ export const NotificationAnnouncer = () => { announce(t('a11y.connection.connected.accessibilityLabel', 'Connected'), 'polite'); } else { announce( - connectionRecovering - ? t('a11y.connection.reconnecting.accessibilityLabel', 'Reconnecting') - : t('a11y.connection.offline.accessibilityLabel', 'Offline'), + isNetworkOnline === false + ? t('a11y.connection.offline.accessibilityLabel', 'Offline') + : t('a11y.connection.reconnecting.accessibilityLabel', 'Reconnecting'), 'assertive', ); } - }, [announce, announceConnectionState, connectionRecovering, enabled, isOnline, t]); + }, [announce, announceConnectionState, enabled, isNetworkOnline, isOnline, t]); return null; }; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 93a9e91cbd..645be0837f 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -85,6 +85,7 @@ import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; import { getFileNameFromPath, isLocalUrl, ReactionData } from '../../utils/utils'; import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; +import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState'; import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardCompatibleView'; import { useMarkRead } from '../MessageList/hooks/useMarkRead'; import { Emoji } from '../MessageMenu/EmojiPickerList'; @@ -170,8 +171,7 @@ export type ChannelPropsWithContext = Pick & | 'maxTimeBetweenGroupedMessages' > > & - Pick & - Partial< + Pick & { isOnline: boolean } & Partial< Pick< InputMessageInputContextValue, | 'additionalTextInputProps' @@ -678,8 +678,9 @@ const ChannelWithContext = (props: PropsWithChildren) = // Mark-read after the LLC's reconnect reload. `connection.recovered` is dispatched by // `client.connectionRecovery` once that reload has landed, so `hasMoreHead` read here reflects the - // refreshed window — which is why this cannot hang off `connection.changed`. Only the reload moved - // into the LLC; whether a caught-up channel is marked read stays a UI decision (see `useMarkRead`). + // refreshed window — which is why this cannot hang off the socket's status store, which moves the + // moment the socket does. Only the reload moved into the LLC; whether a caught-up channel is + // marked read stays a UI decision (see `useMarkRead`). useEffect(() => { if (!shouldSyncChannel) { return; @@ -1085,7 +1086,8 @@ export type ChannelProps = Partial) => { - const { client, enableOfflineSupport, isOnline, isMessageAIGenerated } = useChatContext(); + const { client, enableOfflineSupport, isMessageAIGenerated } = useChatContext(); + const isOnline = useSettledWSConnectionHealth(); const { t } = useTranslationContext(); const notificationHostId = props.notificationHostId ?? diff --git a/package/src/components/ChannelList/ChannelListView.tsx b/package/src/components/ChannelList/ChannelListView.tsx index 14dbf63ea3..daa24c9498 100644 --- a/package/src/components/ChannelList/ChannelListView.tsx +++ b/package/src/components/ChannelList/ChannelListView.tsx @@ -9,13 +9,14 @@ import { ChannelsContextValue, useChannelsContext, } from '../../contexts/channelsContext/ChannelsContext'; -import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useDebugContext } from '../../contexts/debugContext/DebugContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; import { ChannelPreview } from '../ChannelPreview/ChannelPreview'; +import { useNetworkConnectionState } from '../Chat/hooks/useNetworkConnectionState'; +import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState'; export type ChannelListViewPropsWithContext = Omit< ChannelsContextValue, @@ -23,7 +24,8 @@ export type ChannelListViewPropsWithContext = Omit< >; const StatusIndicator = () => { - const { isOnline } = useChatContext(); + const isNetworkOnline = useNetworkConnectionState()?.isOnline; + const isWSOnline = useSettledWSConnectionHealth(); const styles = useStyles(); const { error, loadingChannels, refreshList } = useChannelsContext(); const { ChannelListHeaderErrorIndicator, ChannelListHeaderNetworkDownIndicator } = @@ -33,7 +35,9 @@ const StatusIndicator = () => { return null; } - if (!isOnline) { + // `=== false` for the network (unknown must not read as offline), plain falsy for the socket + // (always a boolean). + if (isNetworkOnline === false || !isWSOnline) { return ( diff --git a/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx b/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx index 7245548859..4d3eb81666 100644 --- a/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx @@ -5,7 +5,6 @@ import type { Channel, StreamChat, UserResponse } from 'stream-chat'; import type { ChannelsContextValue } from '../../../contexts/channelsContext/ChannelsContext'; import { ChannelsProvider } from '../../../contexts/channelsContext/ChannelsContext'; -import { ChatContext, ChatProvider } from '../../../contexts/chatContext/ChatContext'; import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; @@ -27,20 +26,14 @@ const queryChannelsOverride: ChannelListQueryChannelsOverride = () => */ const Component = () => ( - - {(context) => ( - - - - )} - + ); @@ -58,36 +51,30 @@ const ComponentWithContextOverrides = ({ loadingChannels: boolean; }) => ( - - {(context) => ( - - - - - - )} - + + + ); diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 52a40c5e07..b902cfb3ab 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -1,7 +1,7 @@ import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; import { Platform } from 'react-native'; -import { Channel, OfflineDBState } from 'stream-chat'; +import { Channel, NetworkConnectionState, OfflineDBState } from 'stream-chat'; import { useClientMutedUsers } from './hooks'; import { useAppSettings } from './hooks/useAppSettings'; @@ -202,6 +202,10 @@ export type ChatProps = Pick & style?: ThemeStyle; }; +const networkSelector = (nextValue: NetworkConnectionState) => ({ + isOnline: nextValue.isOnline, +}); + const selector = (nextValue: OfflineDBState) => ({ initialized: nextValue.initialized, @@ -254,7 +258,10 @@ const ChatWithContext = (props: PropsWithChildren) => { /** * Setup connection event listeners */ - const { connectionRecovering, isOnline } = useIsOnline(client, closeConnectionOnBackground); + useIsOnline(client, closeConnectionOnBackground); + + // The device's network, for the one consumer that needs it before the context exists. + const isNetworkOnline = useStateStore(client.networkConnection?.state, networkSelector)?.isOnline; const { initialized: offlineDbInitialized, userId: offlineDbUserId } = useStateStore(client.offlineDb?.state, selector) ?? {}; @@ -334,16 +341,19 @@ const ChatWithContext = (props: PropsWithChildren) => { const initialisedDatabase = !!offlineDbInitialized && userID === offlineDbUserId; - const appSettings = useAppSettings(client, isOnline, enableOfflineSupport, initialisedDatabase); + const appSettings = useAppSettings( + client, + isNetworkOnline, + enableOfflineSupport, + initialisedDatabase, + ); const chatContext = useCreateChatContext({ appSettings, channel, client, - connectionRecovering, enableOfflineSupport, isMessageAIGenerated, - isOnline, mutedUsers, setActiveChannel, }); @@ -370,8 +380,9 @@ const ChatWithContext = (props: PropsWithChildren) => { * * - channel - currently active channel * - client - client connection - * - connectionRecovering - whether or not websocket is reconnecting - * - isOnline - whether or not set user is active + * + * Connection status is NOT on this context. Read it with `useWSConnectionState()` (our socket) + * or `useNetworkConnectionState()` (the device's network) — they are separate facts. * - setActiveChannel - function to set the currently active channel */ export const Chat = (props: PropsWithChildren) => { diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx index 4bdb5dfafd..5964b7be64 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -2,7 +2,9 @@ import React, { PropsWithChildren } from 'react'; import { View } from 'react-native'; import NetInfo from '@react-native-community/netinfo'; -import { act, cleanup, render, waitFor } from '@testing-library/react-native'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; + +import type { StreamChat } from 'stream-chat'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; @@ -11,13 +13,57 @@ import type { TranslationContextValue } from '../../../contexts/translationConte import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { sqliteMock } from '../../../mock-builders/DB/mock'; import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged'; -import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered'; import { getTestClient, getTestClientWithUser, setUser } from '../../../mock-builders/mock'; import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../../store/constants'; import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient'; import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Chat } from '../Chat'; +/** + * Replaces the sync manager's socket subscription with a spy wrapping it, and hands back the spy. + * + * It is a bare unsubscribe function now rather than `{ unsubscribe }` — the status store's + * `subscribe` returns one directly — so "was the previous one released?" is answered by whether this + * was called. `init()` releases whatever is assigned at the time, which is what makes the swap work. + */ +const captureSyncSubscription = async (client: StreamChat) => { + await waitFor(() => + expect(client.offlineDb!.syncManager.connectionChangedListener).toEqual(expect.any(Function)), + ); + + const released = jest.fn(client.offlineDb!.syncManager.connectionChangedListener!); + client.offlineDb!.syncManager.connectionChangedListener = released; + return released; +}; + +/** + * How many syncs one offline/online cycle sets off. + * + * This replaces counting `connection.changed` listeners on the client: the sync manager reads the + * socket's status store now, and a store subscription is not enumerable from outside. Counting the + * work a single reconnect produces tests the thing that assertion was a proxy for — a stacked + * subscription syncs twice — and does it without reaching into the store's internals. + */ +const syncsPerReconnect = async (client: StreamChat) => { + const syncManager = client.offlineDb!.syncManager as unknown as { + syncAndExecutePendingTasks: () => Promise; + }; + const sync = jest.spyOn(syncManager, 'syncAndExecutePendingTasks').mockResolvedValue(undefined); + + // Awaited separately: the sync manager's handler is async, so the online edge must be allowed to + // settle before the spy is read. + await act(() => { + dispatchConnectionChangedEvent(client, false); + }); + await act(() => { + dispatchConnectionChangedEvent(client, true); + }); + + const { length } = sync.mock.calls; + sync.mockRestore(); + return length; +}; + const ChatContextConsumer = ({ fn }: { fn: (ctx: ChatContextValue) => void }) => { fn(useChatContext()); return ; @@ -33,7 +79,14 @@ describe('Chat', () => { cleanup(); jest.clearAllMocks(); }); - const chatClient = getTestClient(); + + // A fresh client per test. The NetInfo reporter is installed once per CLIENT and deliberately + // never torn down on unmount, so a client shared across tests would only ever subscribe in the + // first one — and `clearAllMocks` would then hide that it had happened at all. + let chatClient: ReturnType; + beforeEach(() => { + chatClient = getTestClient(); + }); it('renders children without crashing', async () => { const { getByTestId } = render( @@ -45,30 +98,88 @@ describe('Chat', () => { await waitFor(() => expect(getByTestId('children')).toBeTruthy()); }); - it('listens and updates state on a connection changed event', async () => { - let context: ChatContextValue = {} as ChatContextValue; + it('installs a NetInfo reporter that feeds client.networkConnection', async () => { + // The whole RN integration: the client cannot detect device network status itself, so + // has to register a listener. Driving the captured callback proves the wiring end to end. + render( + + + , + ); + + await waitFor(() => expect(NetInfo.addEventListener).toHaveBeenCalled()); + const report = (NetInfo.addEventListener as jest.Mock).mock.calls[0][0]; + act(() => report({ isConnected: false, isInternetReachable: false })); + expect(chatClient.networkConnection.isOnline).toBe(false); + + act(() => report({ isConnected: true, isInternetReachable: true })); + expect(chatClient.networkConnection.isOnline).toBe(true); + }); + + it('prefers isInternetReachable, falling back to isConnected while it is null', async () => { render( - { - context = ctx; - }} - /> + , ); - await waitFor(() => expect(NetInfo.fetch).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(NetInfo.addEventListener).toHaveBeenCalled()); + const report = (NetInfo.addEventListener as jest.Mock).mock.calls[0][0]; - const { connectionRecovering } = context; - act(() => dispatchConnectionChangedEvent(chatClient, false)); - await waitFor(() => { - expect(context.connectionRecovering).toStrictEqual(!connectionRecovering); - expect(context.isOnline).toBeFalsy(); - }); + // Connected to a network that cannot actually reach the internet is offline for our purposes. + act(() => report({ isConnected: true, isInternetReachable: false })); + expect(chatClient.networkConnection.isOnline).toBe(false); + + // ...but until NetInfo has probed, isInternetReachable is null and isConnected is all we have. + act(() => report({ isConnected: true, isInternetReachable: null })); + expect(chatClient.networkConnection.isOnline).toBe(true); + }); + + it('keeps the NetInfo listener alive after unmount, because the client outlives ', async () => { + // The reporter's lifetime is the CLIENT's, not this component's. Releasing it here would leave + // `isOnline` frozen at a stale value (`setStatusReporter(null)` keeps the last status by design), + // and the client is still used outside the React tree — push handling, background work. + const unsubscribe = jest.fn(); + (NetInfo.addEventListener as jest.Mock).mockReturnValueOnce(unsubscribe); + + const { unmount } = render( + + + , + ); + + await waitFor(() => expect(NetInfo.addEventListener).toHaveBeenCalled()); + unmount(); + + expect(unsubscribe).not.toHaveBeenCalled(); + }); + + it('does not stack NetInfo listeners when remounts with the same client', async () => { + // The regression guard for dropping the teardown: the reporter is a stable module-scope + // reference, so `ConfigController`'s no-op write check and the observer's installed-reporter + // identity guard both short-circuit a re-install. An inline reporter would subscribe every mount. + const { unmount } = render( + + + , + ); + await waitFor(() => expect(NetInfo.addEventListener).toHaveBeenCalledTimes(1)); + unmount(); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy()); + expect(NetInfo.addEventListener).toHaveBeenCalledTimes(1); }); - it('listens and updates state on a connection recovered event', async () => { + it('keeps connection status off the chat context', async () => { + // It lives on the client's own stores, read through useWSConnectionState / + // useNetworkConnectionState — so a socket flap no longer re-renders every context consumer. let context: ChatContextValue = {} as ChatContextValue; render( @@ -81,9 +192,9 @@ describe('Chat', () => { , ); - act(() => dispatchConnectionRecoveredEvent(chatClient)); - - await waitFor(() => expect(context.connectionRecovering).toStrictEqual(false)); + await waitFor(() => expect(context.client).toBe(chatClient)); + expect('isOnline' in context).toBe(false); + expect('connectionRecovering' in context).toBe(false); }); }); @@ -107,7 +218,6 @@ describe('ChatContext', () => { expect(context).toBeInstanceOf(Object); expect(context.channel).toBeUndefined(); expect(context.client).toBe(chatClient); - expect(context.connectionRecovering).toBeFalsy(); expect(context.setActiveChannel).toBeInstanceOf(Function); }); }); @@ -251,31 +361,18 @@ describe('TranslationContext', () => { // initial mount and render const { rerender } = render(); - let unsubscribeSpy: jest.SpyInstance | undefined; - let listenersAfterInitialMount: Array = []; const initSpy = jest.spyOn(chatClientWithUser.offlineDb!.syncManager, 'init'); - - await waitFor(() => { - // the unsubscribe fn changes during init(), so we keep a reference to the spy - unsubscribeSpy = jest.spyOn( - chatClientWithUser.offlineDb!.syncManager.connectionChangedListener as object, - 'unsubscribe' as never, - ); - listenersAfterInitialMount = [ - ...(chatClientWithUser.listeners.get('connection.changed') ?? []), - ]; - }); + const released = await captureSyncSubscription(chatClientWithUser); // remount rerender(); await waitFor(() => { expect(initSpy).toHaveBeenCalledTimes(1); - expect(unsubscribeSpy).toHaveBeenCalledTimes(0); - expect([...(chatClientWithUser.listeners.get('connection.changed') ?? [])].length).toBe( - listenersAfterInitialMount.length, - ); + expect(released).toHaveBeenCalledTimes(0); }); + + expect(await syncsPerReconnect(chatClientWithUser)).toBe(1); }); it('makes sure DBSyncManager listeners are cleaned up if the user changes', async () => { @@ -284,20 +381,8 @@ describe('TranslationContext', () => { // initial render const { rerender } = render(); - let unsubscribeSpy: jest.SpyInstance | undefined; - let listenersAfterInitialMount: Array = []; const initSpy = jest.spyOn(chatClientWithUser.offlineDb!.syncManager, 'init'); - - await waitFor(() => { - // the unsubscribe fn changes during init(), so we keep a reference to the spy - unsubscribeSpy = jest.spyOn( - chatClientWithUser.offlineDb!.syncManager.connectionChangedListener as object, - 'unsubscribe' as never, - ); - listenersAfterInitialMount = [ - ...(chatClientWithUser.listeners.get('connection.changed') ?? []), - ]; - }); + const released = await captureSyncSubscription(chatClientWithUser); await act(async () => { await setUser(chatClientWithUser, { id: 'testID2' }); @@ -308,11 +393,11 @@ describe('TranslationContext', () => { await waitFor(() => { expect(initSpy).toHaveBeenCalledTimes(2); - expect(unsubscribeSpy).toHaveBeenCalledTimes(1); - expect([...(chatClientWithUser.listeners.get('connection.changed') ?? [])].length).toBe( - listenersAfterInitialMount.length, - ); + // The second init() released the first subscription before taking out its own. + expect(released).toHaveBeenCalledTimes(1); }); + + expect(await syncsPerReconnect(chatClientWithUser)).toBe(1); }); it('makes sure DBSyncManager state stays intact during normal rerenders', async () => { @@ -321,31 +406,18 @@ describe('TranslationContext', () => { // initial render const { rerender } = render(); - let unsubscribeSpy: jest.SpyInstance | undefined; const initSpy = jest.spyOn(chatClientWithUser.offlineDb!.syncManager, 'init'); - - await waitFor(() => { - // the unsubscribe fn changes during init(), so we keep a reference to the spy - unsubscribeSpy = jest.spyOn( - chatClientWithUser.offlineDb!.syncManager.connectionChangedListener as object, - 'unsubscribe' as never, - ); - }); - - const listenersAfterInitialMount = [ - ...(chatClientWithUser.listeners.get('connection.changed') ?? []), - ]; + const released = await captureSyncSubscription(chatClientWithUser); // rerender rerender(); await waitFor(() => { expect(initSpy).toHaveBeenCalledTimes(1); - expect(unsubscribeSpy).toHaveBeenCalledTimes(0); - expect([...(chatClientWithUser.listeners.get('connection.changed') ?? [])].length).toBe( - listenersAfterInitialMount.length, - ); + expect(released).toHaveBeenCalledTimes(0); }); + + expect(await syncsPerReconnect(chatClientWithUser)).toBe(1); }); it('forwards maxSyncEventsLimit to the offline DB sync manager', async () => { diff --git a/package/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx b/package/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx new file mode 100644 index 0000000000..90c5dd4daf --- /dev/null +++ b/package/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx @@ -0,0 +1,224 @@ +/* eslint no-underscore-dangle: 0 -- `_setStatus` is the SDK's own hook for faking socket status + in tests; there is no public setter because only the socket itself should write it. */ +import React, { PropsWithChildren } from 'react'; + +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import type { StreamChat } from 'stream-chat'; + +import { getTestClient, getTestClientWithUser } from '../../../../mock-builders/mock'; +import { Chat } from '../../Chat'; +import { useNetworkConnectionState } from '../useNetworkConnectionState'; +import { useSettledWSConnectionHealth, useWSConnectionState } from '../useWSConnectionState'; + +/** + * The socket is `isHealthy`, the device is `isOnline`. Two names for two facts, which is the reason + * these are two hooks and not one combined "connected" boolean — and the reason the socket's guard is + * `!isHealthy` while the network's has to be `=== false`. + */ +describe('connection state hooks', () => { + let client: StreamChat; + + const wrapper = ({ children }: PropsWithChildren) => {children}; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'me' }); + }); + + describe('useWSConnectionState', () => { + it('reads the current status on mount, not only on the next transition', async () => { + // The regression this replaces: driving state off a `connection.changed` event meant a client + // that was already down rendered as online until something changed. + client.wsConnection._setStatus({ isHealthy: false }); + + const { result } = renderHook(() => useWSConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current?.isHealthy).toBe(false)); + }); + + it('follows the socket up and down', async () => { + const { result } = renderHook(() => useWSConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current?.isHealthy).toBe(true)); + + act(() => { + client.wsConnection._setStatus({ isHealthy: false }); + }); + expect(result.current?.isHealthy).toBe(false); + expect(result.current?.lastUnhealthyAt).toBeInstanceOf(Date); + + act(() => { + client.wsConnection._setStatus({ isHealthy: true }); + }); + expect(result.current?.isHealthy).toBe(true); + expect(result.current?.lastHealthyAt).toBeInstanceOf(Date); + }); + + it('is not moved by the device network going down', async () => { + // The guard that matters. The two used to share one event and be told apart by a discriminator; + // now they are separate stores, and this is what proves nothing derives one from the other. + const { result } = renderHook(() => useWSConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current?.isHealthy).toBe(true)); + + act(() => { + client.networkConnection.setStatus(false); + }); + + expect(result.current?.isHealthy).toBe(true); + }); + }); + + describe('useSettledWSConnectionHealth', () => { + beforeEach(() => { + client.config.set({ + client: { wsConnection: { offlineNotificationDisplayDelayMs: 5000 } }, + }); + }); + + it('holds a drop back for the configured delay', async () => { + jest.useFakeTimers(); + + const { result } = renderHook(() => useSettledWSConnectionHealth(), { wrapper }); + + await waitFor(() => expect(result.current).toBe(true)); + + act(() => { + client.wsConnection._setStatus({ isHealthy: false }); + }); + // Still reads healthy: this is the flap the delay exists to swallow. + expect(result.current).toBe(true); + + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(result.current).toBe(false); + + jest.useRealTimers(); + }); + + it('never reports a drop the socket already recovered from', async () => { + // The bug that retiring the client-side timer was meant to end: a timer armed by a socket that + // has since been replaced announcing a drop over a working connection. + jest.useFakeTimers(); + + const { result } = renderHook(() => useSettledWSConnectionHealth(), { wrapper }); + + await waitFor(() => expect(result.current).toBe(true)); + + act(() => { + client.wsConnection._setStatus({ isHealthy: false }); + }); + act(() => { + jest.advanceTimersByTime(4000); + }); + act(() => { + client.wsConnection._setStatus({ isHealthy: true }); + }); + act(() => { + jest.advanceTimersByTime(10000); + }); + + expect(result.current).toBe(true); + + jest.useRealTimers(); + }); + + it('reports recovery immediately', async () => { + jest.useFakeTimers(); + + client.wsConnection._setStatus({ isHealthy: false }); + + const { result } = renderHook(() => useSettledWSConnectionHealth(), { wrapper }); + + // Already down when it mounted, so there is no flap to wait out. + await waitFor(() => expect(result.current).toBe(false)); + + act(() => { + client.wsConnection._setStatus({ isHealthy: true }); + }); + expect(result.current).toBe(true); + + jest.useRealTimers(); + }); + + it('holds nothing back when the delay is zero', async () => { + client.config.set({ + client: { wsConnection: { offlineNotificationDisplayDelayMs: 0 } }, + }); + + const { result } = renderHook(() => useSettledWSConnectionHealth(), { wrapper }); + + await waitFor(() => expect(result.current).toBe(true)); + + act(() => { + client.wsConnection._setStatus({ isHealthy: false }); + }); + expect(result.current).toBe(false); + }); + }); + + describe('useNetworkConnectionState', () => { + it('is unknown until something reports, so a guard cannot read it as offline', async () => { + // `undefined`, not `false`. A guard written as `!isOnline` would render an offline banner on + // an unreported network and never clear it — which is why every consumer tests `=== false`. + // An unconnected client has had nothing to report, not even the socket fallback below. + client = getTestClient(); + + const { result } = renderHook(() => useNetworkConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current).toBeDefined()); + expect(result.current?.isOnline).toBeUndefined(); + }); + + it('mirrors the socket until a real reporter is installed', () => { + // Not what we want, but what the client does: outside a browser it falls back to a reporter + // that mirrors its own WebSocket, and `` can only install the NetInfo one from an + // effect. `client` here is connected before anything renders, so the fallback has already + // written the DEVICE store from the SOCKET — the mis-blame the split exists to avoid. + // + // Install `netInfoStatusReporter` at client construction to close the window. This is pinned + // so that if the client ever stops fabricating the value, we notice here rather than in an app. + expect(client.networkConnection.isOnline).toBe(true); + + act(() => { + client.wsConnection._setStatus({ isHealthy: false }); + }); + + expect(client.networkConnection.isOnline).toBe(false); + }); + + it('follows the device network and stamps the matching timestamp', async () => { + const { result } = renderHook(() => useNetworkConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current).toBeDefined()); + + act(() => { + client.networkConnection.setStatus(false); + }); + expect(result.current?.isOnline).toBe(false); + expect(result.current?.lastOfflineAt).toBeInstanceOf(Date); + + act(() => { + client.networkConnection.setStatus(true); + }); + expect(result.current?.isOnline).toBe(true); + expect(result.current?.lastOnlineAt).toBeInstanceOf(Date); + }); + + it('is not moved by the socket dropping on a working network', async () => { + const { result } = renderHook(() => useNetworkConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current).toBeDefined()); + + act(() => { + client.networkConnection.setStatus(true); + }); + act(() => { + client.wsConnection._setStatus({ isHealthy: false }); + }); + + expect(result.current?.isOnline).toBe(true); + }); + }); +}); diff --git a/package/src/components/Chat/hooks/__tests__/useCreateChatClient.netinfo.test.ts b/package/src/components/Chat/hooks/__tests__/useCreateChatClient.netinfo.test.ts new file mode 100644 index 0000000000..607a880e2f --- /dev/null +++ b/package/src/components/Chat/hooks/__tests__/useCreateChatClient.netinfo.test.ts @@ -0,0 +1,47 @@ +import NetInfo from '@react-native-community/netinfo'; + +import { StreamChat } from 'stream-chat'; + +import { netInfoStatusReporter } from '../useIsOnline'; + +/** + * The reporter has to be named in the client's OWN options, not installed from an effect: until + * something reports, the client mirrors its WebSocket into the device-network store, and a + * socket-only failure then reads as the device having no network. + */ +describe('the NetInfo reporter at client construction', () => { + it('is installed before anything renders, so the socket fallback is never reached', () => { + const client = new StreamChat('key', { + config: { client: { networkConnection: { statusReporter: netInfoStatusReporter } } }, + } as never); + + expect(client.networkConnection.config.statusReporter).toBe(netInfoStatusReporter); + expect(NetInfo.addEventListener).toHaveBeenCalled(); + + // A socket that comes up and dies must not move the DEVICE's status. + const report = (NetInfo.addEventListener as jest.Mock).mock.calls[0][0]; + report({ isConnected: true, isInternetReachable: true }); + expect(client.networkConnection.isOnline).toBe(true); + + /* eslint-disable no-underscore-dangle */ + client.wsConnection._setStatus({ isHealthy: true }); + client.wsConnection._setStatus({ isHealthy: false }); + /* eslint-enable no-underscore-dangle */ + + expect(client.networkConnection.isOnline).toBe(true); + }); + + it('leaves the device unknown without one, and mirrors the socket instead', () => { + const client = new StreamChat('key'); + + expect(client.networkConnection.isOnline).toBeUndefined(); + + /* eslint-disable no-underscore-dangle */ + client.wsConnection._setStatus({ isHealthy: true }); + client.wsConnection._setStatus({ isHealthy: false }); + /* eslint-enable no-underscore-dangle */ + + // Fabricated from the socket: nothing knows anything about this device's network. + expect(client.networkConnection.isOnline).toBe(false); + }); +}); diff --git a/package/src/components/Chat/hooks/index.ts b/package/src/components/Chat/hooks/index.ts index 2d7822d6a2..dd79af3a6c 100644 --- a/package/src/components/Chat/hooks/index.ts +++ b/package/src/components/Chat/hooks/index.ts @@ -3,3 +3,5 @@ export * from './useIsOnline'; export * from './useAppSettings'; export * from './useClientMutedUsers'; export * from './useCreateChatContext'; +export * from './useNetworkConnectionState'; +export * from './useWSConnectionState'; diff --git a/package/src/components/Chat/hooks/useAppSettings.ts b/package/src/components/Chat/hooks/useAppSettings.ts index 6faa7b823a..b5759bb005 100644 --- a/package/src/components/Chat/hooks/useAppSettings.ts +++ b/package/src/components/Chat/hooks/useAppSettings.ts @@ -6,7 +6,7 @@ import { useIsMountedRef } from '../../../hooks/useIsMountedRef'; export const useAppSettings = ( client: StreamChat, - isOnline: boolean | null, + isNetworkOnline: boolean | undefined, enableOfflineSupport: boolean, initialisedDatabase: boolean, ): GetApplicationResponse | null => { @@ -35,7 +35,7 @@ export const useAppSettings = ( const userId = client.userID as string; - if (!isOnline && client.offlineDb) { + if (isNetworkOnline === false && client.offlineDb) { const appSettings = await client.offlineDb.getAppSettings({ userId }); setAppSettings(appSettings); return; @@ -59,7 +59,7 @@ export const useAppSettings = ( }; enforceAppSettings(); - }, [client, isOnline, initialisedDatabase, isMounted, enableOfflineSupport]); + }, [client, isNetworkOnline, initialisedDatabase, isMounted, enableOfflineSupport]); return appSettings; }; diff --git a/package/src/components/Chat/hooks/useCreateChatClient.ts b/package/src/components/Chat/hooks/useCreateChatClient.ts index 2ccbe8a916..b84eeb6c30 100644 --- a/package/src/components/Chat/hooks/useCreateChatClient.ts +++ b/package/src/components/Chat/hooks/useCreateChatClient.ts @@ -9,6 +9,35 @@ import type { UserResponse, } from 'stream-chat'; +import { netInfoStatusReporter } from './useIsOnline'; + +/** + * Names the NetInfo reporter in the client's own options, so the device's network status is reported + * from the moment the client exists. + * + * `` also installs it, but only from an effect — and until something reports, the client falls + * back to a reporter that mirrors its own WebSocket. That fallback cannot distinguish "this device + * has no network" from "this socket died", so a socket-only failure in that window (an expired + * token, a server close, the `closeConnection()` that backgrounding uses) is recorded as the device + * being offline, and the UI blames the network for it. Installing here means the fallback is never + * reached. + * + * A `statusReporter` the caller passed themselves wins — it is spread last. + */ +const withNetInfoReporter = (options?: StreamChatOptions): StreamChatOptions => ({ + ...options, + config: { + ...options?.config, + client: { + ...options?.config?.client, + networkConnection: { + statusReporter: netInfoStatusReporter, + ...options?.config?.client?.networkConnection, + }, + }, + }, +}); + /** * React hook to create, connect and return `StreamChat` client. */ @@ -33,7 +62,7 @@ export const useCreateChatClient = ({ const [cachedOptions] = useState(options); useEffect(() => { - const client = new StreamChat(apiKey, cachedOptions); + const client = new StreamChat(apiKey, withNetInfoReporter(cachedOptions)); let didUserConnectInterrupt = false; const connectionPromise = client.connectUser(cachedUserData, tokenOrProvider).then(() => { diff --git a/package/src/components/Chat/hooks/useCreateChatContext.ts b/package/src/components/Chat/hooks/useCreateChatContext.ts index 1a74a10e58..53ba662015 100644 --- a/package/src/components/Chat/hooks/useCreateChatContext.ts +++ b/package/src/components/Chat/hooks/useCreateChatContext.ts @@ -6,10 +6,8 @@ export const useCreateChatContext = ({ appSettings, channel, client, - connectionRecovering, enableOfflineSupport, isMessageAIGenerated, - isOnline, mutedUsers, setActiveChannel, }: ChatContextValue) => { @@ -26,15 +24,13 @@ export const useCreateChatContext = ({ appSettings, channel, client, - connectionRecovering, enableOfflineSupport, isMessageAIGenerated, - isOnline, mutedUsers, setActiveChannel, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [appSettings, channelId, clientValues, connectionRecovering, isOnline, mutedUsersLength], + [appSettings, channelId, clientValues, mutedUsersLength], ); return chatContext; diff --git a/package/src/components/Chat/hooks/useIsOnline.ts b/package/src/components/Chat/hooks/useIsOnline.ts index 1acaffb8f8..291578ae05 100644 --- a/package/src/components/Chat/hooks/useIsOnline.ts +++ b/package/src/components/Chat/hooks/useIsOnline.ts @@ -1,22 +1,24 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect } from 'react'; -import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo'; +import NetInfo from '@react-native-community/netinfo'; -import type { EventPayload, StreamChat } from 'stream-chat'; +import type { NetworkStatusReporter, StreamChat } from 'stream-chat'; import { useAppStateListener } from '../../../hooks/useAppStateListener'; -import { useIsMountedRef } from '../../../hooks/useIsMountedRef'; /** - * Disconnect the websocket connection when app goes to background, - * and reconnect when app comes to foreground. - * We do this to make sure the user receives push notifications when app is in the background. - * You can't receive push notification until you have active websocket connection. + * Reports the device's network status to the client, and owns the socket's app-state lifecycle. + * + * Two jobs, both side effects — this hook returns nothing. Read status with + * `useNetworkConnectionState()` (the device) or `useWSConnectionState()` (our socket); both read the + * client's own stores, so they are correct on mount rather than only after a transition. + * + * 1. **The network reporter.** The client cannot detect device network status itself — every + * platform reports it differently — so it has to be told. On React Native that means NetInfo. + * 2. **Background/foreground.** Close the socket when the app backgrounds and reopen it on + * foreground, because push notifications are only delivered while no socket is active. */ export const useIsOnline = (client: StreamChat, closeConnectionOnBackground = true) => { - const [isOnline, setIsOnline] = useState(null); - const [connectionRecovering, setConnectionRecovering] = useState(false); - const isMounted = useIsMountedRef(); const clientExists = !!client; const onBackground = useCallback(() => { @@ -25,7 +27,6 @@ export const useIsOnline = (client: StreamChat, closeConnectionOnBackground = tr } client.closeConnection(); - setIsOnline(false); }, [closeConnectionOnBackground, client, clientExists]); const onForeground = useCallback(() => { @@ -40,65 +41,71 @@ export const useIsOnline = (client: StreamChat, closeConnectionOnBackground = tr useAppStateListener(onForeground, onBackground); useEffect(() => { - const handleChangedEvent = (event: EventPayload<'connection.changed'>) => { - setConnectionRecovering(!event.online); - setIsOnline(event.online || false); - }; - - const handleRecoveredEvent = () => setConnectionRecovering(false); - - const notifyChatClient = (isConnected: boolean | null) => { - if (client?.wsConnection && isConnected) { - if (isConnected) { - client.wsConnection.onlineStatusChanged({ - type: 'online', - } as Event); - } else { - client.wsConnection.onlineStatusChanged({ - type: 'offline', - } as Event); - } - } - }; - - let unsubscribeNetInfo: NetInfoSubscription; - const setNetInfoListener = () => { - unsubscribeNetInfo = NetInfo.addEventListener((netInfoState) => { - if (!netInfoState && !client.wsConnection?.isHealthy) { - setConnectionRecovering(true); - setIsOnline(false); - } - const { isConnected, isInternetReachable } = netInfoState; - notifyChatClient( - isInternetReachable !== null ? isInternetReachable && isConnected : isConnected, - ); - }); - }; - - const setInitialOnlineState = async () => { - const { isConnected } = await NetInfo.fetch(); - if (isMounted.current) { - setIsOnline(isConnected); - notifyChatClient(isConnected); - } - }; - - setInitialOnlineState(); - - const chatListeners: Array> = []; - - if (client) { - chatListeners.push(client.on('connection.changed', handleChangedEvent)); - chatListeners.push(client.on('connection.recovered', handleRecoveredEvent)); - setNetInfoListener(); + if (!clientExists) { + return; } - return () => { - chatListeners.forEach((listener) => listener.unsubscribe?.()); - unsubscribeNetInfo?.(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clientExists]); - - return { connectionRecovering, isOnline }; + // Declarative config rather than `client.networkConnection.setStatusReporter(...)`. Both survive + // a configuration derivation now, but this one states the reporter as part of the client's + // configuration rather than as an edit applied to it, so a `client.config.get('client')` shows + // what is actually installed. + // + // Installing one at all is not optional on React Native. Left alone the client falls back to a + // reporter that mirrors its own WebSocket, which cannot report that the network came back before + // the socket noticed — the entire reason the network signal is worth having. + client.config.set({ + client: { + networkConnection: { + statusReporter: netInfoStatusReporter, + }, + }, + }); + + // Deliberately no teardown. The reporter's lifetime is the CLIENT's, not this component's: the + // client outlives `` (push handling, background work), and `isOnline` is supposed to stay + // true about the device for as long as the client exists. Tearing it down here would also leave a + // stale value rather than a cleared one — `setStatusReporter(null)` keeps the last known status by + // design — so consumers would read an authoritative-looking `isOnline` that nothing is updating + // any more. + // + // Re-running this is safe and cannot stack listeners: `netInfoStatusReporter` is a stable + // module-scope reference, so `ConfigController`'s no-op write check and the observer's own + // installed-reporter identity guard both short-circuit. A *different* client re-runs the effect + // through the dependency array and installs a fresh reporter for it. + }, [client, clientExists]); }; + +/** + * Subscribes to NetInfo and reports every change to the client. What `` installs. + * + * Exported so it can be installed **at client construction** instead, which is strictly better if + * you build the client yourself: + * + * ```ts + * new StreamChat(apiKey, { + * config: { client: { networkConnection: { statusReporter: netInfoStatusReporter } } }, + * }); + * ``` + * + * `` can only install it from an effect, so between the client being constructed and that + * effect running, the client falls back to a reporter that mirrors its own WebSocket. In that window + * a socket-only failure — an expired token, a server close — is recorded as the *device* having no + * network, and the UI blames the network for it. Installing here closes the window; the fallback is + * never reached. + * + * Module scope, so the same reference is handed to the client on every derivation — re-installing an + * identical reporter is a no-op there, and rebuilding it per render would tear the native listener + * down and recreate it for nothing. + * + * `NetInfo.addEventListener` fires once with the current state on subscribe, which satisfies the + * reporter contract's "report the current status as soon as it is known" requirement — so no + * separate `NetInfo.fetch()` is needed. + */ +export const netInfoStatusReporter: NetworkStatusReporter = (onStatusChange) => + NetInfo.addEventListener(({ isConnected, isInternetReachable }) => { + // `isInternetReachable` is the stronger signal but is `null` until NetInfo has probed, so fall + // back to `isConnected` until it resolves. Coerced because both are `boolean | null`. + onStatusChange( + isInternetReachable !== null ? isInternetReachable && isConnected : !!isConnected, + ); + }); diff --git a/package/src/components/Chat/hooks/useNetworkConnectionState.ts b/package/src/components/Chat/hooks/useNetworkConnectionState.ts new file mode 100644 index 0000000000..4b8594e85e --- /dev/null +++ b/package/src/components/Chat/hooks/useNetworkConnectionState.ts @@ -0,0 +1,39 @@ +import type { NetworkConnectionState } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +const identity = (state: NetworkConnectionState) => state; + +/** + * The **device's** network status, as reported by the NetInfo listener `` registers on + * `client.networkConnection`. + * + * Not the same fact as {@link useWSConnectionState}: a socket dies on a working network, and a device + * drops while the socket has not noticed yet. Use this for "you're offline"; use the WebSocket hook + * for "Reconnecting…". + * + * `isOnline` has **three** states. `undefined` means *unknown* — nobody has reported yet. A guard must + * therefore test `isOnline === false`; `!isOnline` is also true when the answer is unknown, which + * would claim "offline" on the very first render and on any host where no listener is installed. + * + * Must be used under ``. + */ +export const useNetworkConnectionState = () => { + const { client } = useChatContext(); + return useStateStore(client?.networkConnection?.state, identity); +}; + +/** + * {@link useNetworkConnectionState} narrowed to what a component actually reads, so it re-renders only + * when that changes. The selector must return a flat object or tuple — it is shallow-compared on its + * own keys, and must be declared at module scope to stay referentially stable. + */ +export const useNetworkConnectionStateSelector = < + O extends Readonly | Readonly>, +>( + selector: (state: NetworkConnectionState) => O, +) => { + const { client } = useChatContext(); + return useStateStore(client?.networkConnection?.state, selector); +}; diff --git a/package/src/components/Chat/hooks/useWSConnectionState.ts b/package/src/components/Chat/hooks/useWSConnectionState.ts new file mode 100644 index 0000000000..15cc1bdc09 --- /dev/null +++ b/package/src/components/Chat/hooks/useWSConnectionState.ts @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; + +import type { WSConnectionConfig, WSConnectionState } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +const identity = (state: WSConnectionState) => state; +const healthSelector = (state: WSConnectionState) => ({ isHealthy: state.isHealthy }); +const displayDelaySelector = (config: WSConnectionConfig) => ({ + offlineNotificationDisplayDelayMs: config.offlineNotificationDisplayDelayMs, +}); + +/** + * This client's WebSocket status. + * + * Not the same fact as {@link useNetworkConnectionState}, and the difference is the point: a socket + * dies on a perfectly good network (a server close, an expired token, a health-check timeout), and a + * device drops while the socket has not noticed yet. Use this for "Reconnecting…"; use the network + * hook for "you're offline". + * + * `isHealthy` is always a boolean — a socket always has a state — so `!isHealthy` is safe, unlike the + * network store's `isOnline`, which is `undefined` until something reports. + * + * Reads the store rather than reacting to an event, so it is correct on mount rather than only after + * the first transition, and so it reports the paths that were always silent + * (`client.closeConnection()`, the mobile backgrounding path, dispatches nothing). + * + * This is the **raw** status, which flips on every flap. Anything user-visible wants + * {@link useSettledWSConnectionHealth} instead. + * + * Must be used under ``. + */ +export const useWSConnectionState = () => { + const { client } = useChatContext(); + return useStateStore(client?.wsConnection?.state, identity); +}; + +/** + * {@link useWSConnectionState} narrowed to what a component actually reads, so it re-renders only + * when that changes. The selector must return a flat object or tuple — it is shallow-compared on its + * own keys, and must be declared at module scope to stay referentially stable. + */ +export const useWSConnectionStateSelector = < + O extends Readonly | Readonly>, +>( + selector: (state: WSConnectionState) => O, +) => { + const { client } = useChatContext(); + return useStateStore(client?.wsConnection?.state, selector); +}; + +/** + * `isHealthy`, but a drop has to last before it is believed. What UI should render. + * + * Recovery is reported immediately and only the drop is held back, because the two are not + * symmetric: showing "Reconnecting…" a moment late costs nothing, leaving it up a moment too long + * makes a working app look broken. + * + * Most drops resolve in well under a second — a backgrounded socket, a handover between cells, a + * server closing an idle connection — and a banner that rendered all of them would flash constantly. + * The client used to hold this back itself, debouncing its offline event by a fixed five seconds; + * that timer outlived the socket that armed it, so it was removed in favour of the value living + * here, where whoever draws the banner owns it. The length is + * `client.config.set({ client: { wsConnection: { offlineNotificationDisplayDelayMs } } })`; zero + * holds nothing back. + * + * A socket that is already down when this mounts reads as down straight away — there is no flap to + * wait out, and the delay is not a grace period for the initial state. + * + * Must be used under ``. + */ +export const useSettledWSConnectionHealth = () => { + const { client } = useChatContext(); + const isHealthy = !!useStateStore(client?.wsConnection?.state, healthSelector)?.isHealthy; + const delay = + useStateStore(client?.wsConnection?.configState, displayDelaySelector) + ?.offlineNotificationDisplayDelayMs ?? 0; + + const [settled, setSettled] = useState(isHealthy); + + useEffect(() => { + // Up is immediate, and clearing any pending down with it: a socket that came back before the + // timer fired must never announce the drop it already recovered from — the exact bug that + // retiring the client-side timer was meant to end. + if (isHealthy || delay <= 0) { + setSettled(isHealthy); + return; + } + + const timeout = setTimeout(() => setSettled(false), delay); + return () => clearTimeout(timeout); + }, [isHealthy, delay]); + + return settled; +}; diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index 1ae31acea3..2b16797997 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -18,12 +18,7 @@ import { audioRecorderSelector } from './utils/audioRecorderSelectors'; import { useScreenReaderMountFocus } from '../../a11y'; -import { - ChatContextValue, - useAttachmentPickerContext, - useChatContext, - useOwnCapabilitiesContext, -} from '../../contexts'; +import { useAttachmentPickerContext, useOwnCapabilitiesContext } from '../../contexts'; import { ChannelContextValue, useChannelContext, @@ -53,6 +48,7 @@ import { MessageInputHeightState } from '../../state-store/message-input-height- import { primitives } from '../../theme'; import { transitions } from '../../utils/animations/transitions'; import { type TextInputOverrideComponent } from '../AutoCompleteInput/AutoCompleteInput'; +import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState'; import { PollModal } from '../Poll/components/PollModal'; import { CreatePoll } from '../Poll/CreatePollContent'; import { PortalWhileClosingView } from '../UIComponents/PortalWhileClosingView'; @@ -137,8 +133,10 @@ const useStyles = () => { }, [semantics]); }; -type MessageComposerPropsWithContext = Pick & - Pick & { +type MessageComposerPropsWithContext = { isOnline: boolean } & Pick< + ChannelContextValue, + 'channel' +> & { members: MembersState['members']; watchers: ChannelWatchState['watchers']; } & Pick< @@ -614,7 +612,7 @@ export type MessageComposerProps = Partial; * [Translation Context](https://getstream.io/chat/docs/sdk/reactnative/contexts/translation-context/) */ export const MessageComposer = (props: MessageComposerProps) => { - const { isOnline } = useChatContext(); + const isOnline = useSettledWSConnectionHealth(); const ownCapabilities = useOwnCapabilitiesContext(); const { channel } = useChannelContext(); diff --git a/package/src/components/MessageInput/components/OutputButtons/index.tsx b/package/src/components/MessageInput/components/OutputButtons/index.tsx index c30ad5de51..18ca6b4bbe 100644 --- a/package/src/components/MessageInput/components/OutputButtons/index.tsx +++ b/package/src/components/MessageInput/components/OutputButtons/index.tsx @@ -8,9 +8,7 @@ import { EditButton } from './EditButton'; import { ChannelContextValue, - ChatContextValue, useChannelContext, - useChatContext, useMessageComposerHasSendableData, useTheme, } from '../../../../contexts'; @@ -24,12 +22,15 @@ import { import { useStateStore } from '../../../../hooks/useStateStore'; import { transitions } from '../../../../utils/animations/transitions'; import { AIStates, useAIState } from '../../../AITypingIndicatorView'; +import { useSettledWSConnectionHealth } from '../../../Chat/hooks/useWSConnectionState'; import { useIsCooldownActive } from '../../hooks/useIsCooldownActive'; export type OutputButtonsProps = Partial; -export type OutputButtonsWithContextProps = Pick & - Pick & +export type OutputButtonsWithContextProps = { isOnline: boolean } & Pick< + ChannelContextValue, + 'channel' +> & Pick< MessageInputContextValue, | 'asyncMessagesMinimumPressDuration' @@ -162,7 +163,8 @@ const MemoizedOutputButtonsWithContext = React.memo( ) as typeof OutputButtonsWithContext; export const OutputButtons = (props: OutputButtonsProps) => { - const { isOnline } = useChatContext(); + // The socket, not the device network: a command round-trips through the server. + const isOnline = useSettledWSConnectionHealth(); const { channel } = useChannelContext(); const { audioRecordingEnabled, diff --git a/package/src/components/MessageList/NetworkDownIndicator.tsx b/package/src/components/MessageList/NetworkDownIndicator.tsx index 43fedbac1b..34969b4558 100644 --- a/package/src/components/MessageList/NetworkDownIndicator.tsx +++ b/package/src/components/MessageList/NetworkDownIndicator.tsx @@ -1,24 +1,31 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import { useChatContext } from '../../contexts/chatContext/ChatContext'; - import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { primitives } from '../../theme'; +import { useNetworkConnectionState } from '../Chat/hooks/useNetworkConnectionState'; +import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState'; export const NetworkDownIndicator = () => { - const { isOnline } = useChatContext(); + const isNetworkOnline = useNetworkConnectionState()?.isOnline; + const isWSOnline = useSettledWSConnectionHealth(); const styles = useStyles(); const { t } = useTranslationContext(); - if (isOnline) { + const hasNoNetwork = isNetworkOnline === false; + + if (!hasNoNetwork && isWSOnline) { return null; } return ( - {t('common.reconnecting.text', 'Reconnecting...')} + + {hasNoNetwork + ? t('common.waitingForNetwork.text', 'Waiting for network...') + : t('common.reconnecting.text', 'Reconnecting...')} + ); }; diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 9e62282d1d..fbaeaea6af 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -246,33 +246,75 @@ describe('MessageList', () => { }); }); - it('should render the is offline error', async () => { - const user1 = generateUser(); - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user: user1 })], - messages: [generateMessage({ user: user1 })], + describe('the connection banner', () => { + const renderConnected = async () => { + const user1 = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 })], + messages: [generateMessage({ user: user1 })], + }); + + const chatClient = await getTestClientWithUser({ id: 'testID' } as UserResponse); + // A socket drop is held back by `offlineNotificationDisplayDelayMs` (5s by default) so a + // sub-second flap never reaches the screen. These tests are about WHICH banner renders, not + // about the wait, so they opt out of it — the wait itself is covered in + // `useSettledWSConnectionHealth`'s own tests. + chatClient.config.set({ + client: { wsConnection: { offlineNotificationDisplayDelayMs: 0 } }, + }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const utils = render( + + + + + + + , + ); + + return { chatClient, ...utils }; + }; + + it('shows nothing while the socket is up and the network is merely unknown', async () => { + // `undefined` network is the normal React Native state until NetInfo reports. A guard written + // as `!isOnline` would render the banner here and never clear it. + const { queryByTestId } = await renderConnected(); + + await waitFor(() => expect(queryByTestId('error-notification')).toBeNull()); }); - const chatClient = await getTestClientWithUser({ id: 'testID' } as UserResponse); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.channel.id); - await channel.watch(); + it('says Reconnecting when the socket drops on a working network', async () => { + const { chatClient, getByTestId, getByText } = await renderConnected(); - const { getByTestId, getByText, queryAllByTestId } = render( - - - - - - - , - ); + act(() => { + chatClient.networkConnection.setStatus(true); + // eslint-disable-next-line no-underscore-dangle + chatClient.wsConnection._setStatus({ isHealthy: false }); + }); - await waitFor(() => { - expect(queryAllByTestId('message-system')).toHaveLength(0); - expect(queryAllByTestId('typing-indicator')).toHaveLength(0); - expect(getByTestId('error-notification')).toBeTruthy(); - expect(getByText('Reconnecting...')).toBeTruthy(); + await waitFor(() => { + expect(getByTestId('error-notification')).toBeTruthy(); + expect(getByText('Reconnecting...')).toBeTruthy(); + }); + }); + + it('says Waiting for network when the device itself is offline', async () => { + // The whole point of the split: this used to read as "Reconnecting" too, blaming the socket + // for something the device did. + const { chatClient, getByTestId, getByText } = await renderConnected(); + + act(() => { + chatClient.networkConnection.setStatus(false); + }); + + await waitFor(() => { + expect(getByTestId('error-notification')).toBeTruthy(); + expect(getByText('Waiting for network...')).toBeTruthy(); + }); }); }); diff --git a/package/src/components/ThreadList/ThreadList.tsx b/package/src/components/ThreadList/ThreadList.tsx index 20a5dd67fc..643ce48e36 100644 --- a/package/src/components/ThreadList/ThreadList.tsx +++ b/package/src/components/ThreadList/ThreadList.tsx @@ -103,6 +103,8 @@ export const ThreadList = (props: ThreadListProps) => { return; } + // Only the socket recovers — a device regaining its network has no reconnected socket yet, and + // the event is dispatched once the client's own post-reconnect reloads have landed. const listener = client.on('connection.recovered', () => { client.threads.reload({ force: true }); }); diff --git a/package/src/contexts/chatContext/ChatContext.tsx b/package/src/contexts/chatContext/ChatContext.tsx index 291c9fad61..5c2aaa78b7 100644 --- a/package/src/contexts/chatContext/ChatContext.tsx +++ b/package/src/contexts/chatContext/ChatContext.tsx @@ -29,9 +29,7 @@ export type ChatContextValue = { * @overrideType StreamChat * */ client: StreamChat; - connectionRecovering: boolean; enableOfflineSupport: boolean; - isOnline: boolean | null; mutedUsers: UserMuteResponse[]; /** * @param newChannel Channel to set as active. diff --git a/package/src/hooks/useLoadingImage.tsx b/package/src/hooks/useLoadingImage.tsx index bb7d44022e..18383cd5e1 100644 --- a/package/src/hooks/useLoadingImage.tsx +++ b/package/src/hooks/useLoadingImage.tsx @@ -1,6 +1,6 @@ import { useEffect, useReducer, useRef } from 'react'; -import { useChatContext } from '../contexts/chatContext/ChatContext'; +import { useNetworkConnectionState } from '../components/Chat/hooks/useNetworkConnectionState'; type ImageState = { isLoadingImage: boolean; @@ -50,18 +50,18 @@ export const useLoadingImage = () => { const setLoadingImageErrorRef = useRef((isLoadingImageError: boolean) => dispatch({ isLoadingImageError, type: 'setLoadingImageError' }), ); - const { isOnline } = useChatContext(); + const isNetworkOnline = useNetworkConnectionState()?.isOnline; // storing the value of isLoadingImageError in a ref to avoid passing as a dep to useEffect const hasImageLoadedErroredRef = useRef(isLoadingImageError); hasImageLoadedErroredRef.current = isLoadingImageError; useEffect(() => { - if (isOnline && hasImageLoadedErroredRef.current) { + if (isNetworkOnline && hasImageLoadedErroredRef.current) { // if there was an error previously, reload the image automatically when user comes back online onReloadImageRef.current(); } - }, [isOnline]); + }, [isNetworkOnline]); return { isLoadingImage, diff --git a/package/src/i18n/__tests__/catalog.fixture.json b/package/src/i18n/__tests__/catalog.fixture.json index eb5208398e..a2ab58a5ea 100644 --- a/package/src/i18n/__tests__/catalog.fixture.json +++ b/package/src/i18n/__tests__/catalog.fixture.json @@ -186,6 +186,7 @@ "common.reconnecting.text": "Reconnecting...", "common.sendMessageFailed.error": "Send message request failed", "common.unknownUser.label": "Unknown User", + "common.waitingForNetwork.text": "Waiting for network...", "common.you.label": "You", "duration.messageReminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "imageGallery.footer.grid.accessibilityLabel": "Grid Icon", diff --git a/package/src/i18n/keys.ts b/package/src/i18n/keys.ts index 4ebf680c60..09edb5cec3 100644 --- a/package/src/i18n/keys.ts +++ b/package/src/i18n/keys.ts @@ -197,6 +197,7 @@ export type TranslationCatalog = { 'common.reconnecting.text': 'Reconnecting...'; 'common.sendMessageFailed.error': 'Send message request failed'; 'common.unknownUser.label': 'Unknown User'; + 'common.waitingForNetwork.text': 'Waiting for network...'; 'common.you.label': 'You'; 'duration.messageReminder': '{{ milliseconds | durationFormatter(withSuffix: true) }}'; 'imageGallery.footer.grid.accessibilityLabel': 'Grid Icon'; diff --git a/package/src/mock-builders/event/connectionChanged.ts b/package/src/mock-builders/event/connectionChanged.ts index 158310158f..59627ce1c3 100644 --- a/package/src/mock-builders/event/connectionChanged.ts +++ b/package/src/mock-builders/event/connectionChanged.ts @@ -1,11 +1,31 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { Event, StreamChat } from 'stream-chat'; +/* eslint no-underscore-dangle: 0 -- `_setStatus` is the SDK's own hook for faking socket status in + tests; there is no public setter because only the socket itself should write it. */ +import type { StreamChat } from 'stream-chat'; -export default (client: StreamChat, online = true) => { - client.dispatchEvent( - fromPartial({ - online, - type: 'connection.changed', - }), - ); +/** + * Drives a connection status change. + * + * There is no `connection.changed` event any more — both connections are stores, and this writes to + * whichever one is named. The name is kept because that is what the change means to a test, and + * because the two facts are still distinct: the socket by default, `'network'` for the device. + * + * The socket path mirrors `StableWSConnection._applyHealth`, connection id included. A drop + * invalidates the id, so anything waiting on one waits for the reconnect; coming back resolves it, + * because `queryChannels({ watch: true })` and `channel.watch()` now block on an id rather than + * degrading to an unwatched query. Skipping that bookkeeping leaves those requests hanging for the + * whole test. + */ +export default (client: StreamChat, online = true, connection: 'network' | 'ws' = 'ws') => { + if (connection === 'network') { + client.networkConnection.setStatus(online); + return; + } + + if (online) { + client.connectionIdManager.resolveConnectionId('dummy_connection_id'); + } else { + client.connectionIdManager.invalidate(); + } + + client.wsConnection._setStatus({ isHealthy: online }); }; diff --git a/package/src/mock-builders/event/connectionRecovered.ts b/package/src/mock-builders/event/connectionRecovered.ts index a311ff7b64..a659e091cf 100644 --- a/package/src/mock-builders/event/connectionRecovered.ts +++ b/package/src/mock-builders/event/connectionRecovered.ts @@ -1,6 +1,11 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { Event, StreamChat } from 'stream-chat'; +/** + * `connection.recovered` is the one connection event that survives: it reports that the client's own + * post-reconnect reloads have finished, which no store can say. It carries no payload — the + * `connection` discriminator it briefly had is gone, since only the socket ever recovers. + */ export default (client: StreamChat) => { client.dispatchEvent( fromPartial({ diff --git a/package/src/mock-builders/mock.ts b/package/src/mock-builders/mock.ts index 4a8c046024..d5226effe8 100644 --- a/package/src/mock-builders/mock.ts +++ b/package/src/mock-builders/mock.ts @@ -27,10 +27,12 @@ type MockableStreamChat = StreamChat & { export const setUser = (client: StreamChat, user: MockUser): Promise => new Promise((resolve) => { const c = client as MockableStreamChat; - // v10 keeps the connection id on `client.connectionIdManager`; requests that register a - // watch/presence subscription await it, so a mocked connect has to publish one or every - // `queryChannels()`/`channel.watch()` in the tests throws. - c.connectionIdManager.resolveConnectionId('dumm_connection_id'); + // A connected client means a live socket with a connection id. The id lives on its own manager + // now, and `channel.watch()` / `client.queryChannels()` await it rather than degrading to + // `watch: false` — with no timeout — so a fixture that leaves it unset hangs every one of them + // until the test itself times out. The socket status goes with it: both halves of "connected". + client.connectionIdManager.resolveConnectionId('dummy_connection_id'); + client.wsConnection._setStatus({ isHealthy: true }); // `userID` is now a read-only getter derived from `user.id`, so setting `user` is enough. c.user = { ...user, mutes: [] } as unknown as OwnUserResponse; c._user = { ...c.user }; diff --git a/yarn.lock b/yarn.lock index cc0fa926a6..2a2c15a71c 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.11" + stream-chat: "npm:^10.0.0-rc.12" 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.11" + stream-chat: "npm:^10.0.0-rc.12" 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.11" + stream-chat: "npm:^10.0.0-rc.12" 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.11": - version: 10.0.0-rc.11 - resolution: "stream-chat@npm:10.0.0-rc.11" +"stream-chat@npm:^10.0.0-rc.12": + version: 10.0.0-rc.12 + resolution: "stream-chat@npm:10.0.0-rc.12" 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/681c8dd92559843474ea26c12ea3e07b23dda254bfbc82295f1db326b210e2064d2911e8315bcce78b0606ef836c34ab890918aed0e831efb903dac8f25b1721 + checksum: 10c0/908d8cdd440481cb84cf903eb0b7e840ca59615e95216a850765896433a7bcb6d438edfeccbe4ee601e4836dfe964e1c43ec7cbca9444cd980ebf9f4517b5d91 languageName: node linkType: hard