diff --git a/examples/SampleApp/metro.config.js b/examples/SampleApp/metro.config.js index c96816d4be..b76967f511 100644 --- a/examples/SampleApp/metro.config.js +++ b/examples/SampleApp/metro.config.js @@ -65,10 +65,13 @@ const uniqueModules = dependencyPackageNames.map((packageName) => { const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); // provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce((acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; -}, {}); +const extraNodeModules = uniqueModules.reduce( + (acc, item) => { + acc[item.packageName] = item.modulePath; + return acc; + }, + { 'stream-chat': '/Users/isekovanic/Projects/stream-chat-js' }, +); config.resolver.blockList = exclusionList(blockList); config.resolver.extraNodeModules = extraNodeModules; @@ -76,6 +79,6 @@ config.resolver.extraNodeModules = extraNodeModules; config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; // add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath]; +config.watchFolders = [packageDirPath, '/Users/isekovanic/Projects/stream-chat-js']; module.exports = config; diff --git a/examples/SampleApp/src/components/ChatScreenHeader.tsx b/examples/SampleApp/src/components/ChatScreenHeader.tsx index 938ca8189f..1cabe82aa2 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 { useWSConnectionState } 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 = !!useWSConnectionState()?.isOnline; 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 d01c5aad04..e5b0a04c5b 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, + useWSConnectionState, } 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 = !!useWSConnectionState()?.isOnline; const { chatClient } = useAppContext(); const navigation = useNavigation(); diff --git a/package.json b/package.json index 79f28d9e77..fda8c394a6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "examples/ExpoMessaging" ], "resolutions": { - "@types/react": "^19.2.0" + "@types/react": "^19.2.0", + "stream-chat": "portal:/Users/isekovanic/Projects/stream-chat-js" }, "engines": { "node": ">=22.13.0" diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index 7160006188..436fe7345f 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -83,12 +83,18 @@ 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. + // eslint-disable-next-line no-underscore-dangle + client.wsConnection._setStatus({ isOnline: 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; + // `_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. + // eslint-disable-next-line no-underscore-dangle + client.wsConnection._setStatus({ isOnline: true, connectionId: 'dummy_connection_id' }); }; // React flushes passive effects child-first, so the test-callback effect below runs BEFORE `Channel`'s @@ -212,10 +218,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 +1163,14 @@ export const OptimisticUpdates = () => { }); describe('pending task execution', () => { + // Every test in this block drives a real reconnect, and since `connection.changed` gained 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..c49f2321c6 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 { useWSConnectionState } 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 = useWSConnectionState()?.isOnline; + // 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 3dbe25c812..4b972eadaf 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 { useWSConnectionState } from '../Chat/hooks/useWSConnectionState'; import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardCompatibleView'; import { useMarkRead } from '../MessageList/hooks/useMarkRead'; import { Emoji } from '../MessageMenu/EmojiPickerList'; @@ -171,8 +172,7 @@ export type ChannelPropsWithContext = Pick & | 'maximumMessageLimit' > > & - Pick & - Partial< + Pick & { isOnline: boolean } & Partial< Pick< InputMessageInputContextValue, | 'additionalTextInputProps' @@ -691,7 +691,10 @@ const ChannelWithContext = (props: PropsWithChildren) = // landed, so `hasMoreHead` read here reflects the refreshed window. Channel view only, and only // when that window is at the newest, only if the user has paginated up into older history so leave // their read state alone. - const { unsubscribe } = client.on('connection.recovered', () => { + const { unsubscribe } = client.on('connection.recovered', (event) => { + if (event.connection !== 'ws') { + return; + } if (thread || channel.messagePaginator.hasMoreHead) { return; } @@ -1088,7 +1091,8 @@ export type ChannelProps = Partial) => { - const { client, enableOfflineSupport, isOnline, isMessageAIGenerated } = useChatContext(); + const { client, enableOfflineSupport, isMessageAIGenerated } = useChatContext(); + const isOnline = !!useWSConnectionState()?.isOnline; 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..5d8c1f8471 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 { useWSConnectionState } 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 = useWSConnectionState()?.isOnline; 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..30d0d051ea 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -2,7 +2,7 @@ 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 { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; @@ -10,8 +10,6 @@ import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; 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'; @@ -33,7 +31,14 @@ describe('Chat', () => { cleanup(); jest.clearAllMocks(); }); - const chatClient = getTestClient(); + + // A fresh client per test. The NetInfo registrar 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 +50,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 registrar 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('listens and updates state on a connection recovered event', async () => { + it('keeps the NetInfo listener alive after unmount, because the client outlives ', async () => { + // The registrar's lifetime is the CLIENT's, not this component's. Releasing it here would leave + // `isOnline` frozen at a stale value (`setStatusListenerRegistrar(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 registrar is a stable module-scope + // reference, so `ConfigController`'s no-op write check and `setStatusListenerRegistrar`'s identity + // guard both short-circuit a re-install. An inline registrar would subscribe again 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('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 +144,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 +170,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); }); }); 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..a0fe9c6c3c --- /dev/null +++ b/package/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx @@ -0,0 +1,113 @@ +/* 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 { getTestClientWithUser } from '../../../../mock-builders/mock'; +import { Chat } from '../../Chat'; +import { useNetworkConnectionState } from '../useNetworkConnectionState'; +import { useWSConnectionState } from '../useWSConnectionState'; + +/** + * Both stores expose `isOnline` and they mean different things, so every consumer here aliases them. + * That ambiguity is the reason these are two hooks and not one combined "connected" boolean. + */ +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 `connection.changed` meant a client that was + // already down rendered as online until something changed. + client.wsConnection._setStatus({ isOnline: false }); + + const { result } = renderHook(() => useWSConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current?.isOnline).toBe(false)); + }); + + it('follows the socket up and down', async () => { + const { result } = renderHook(() => useWSConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current?.isOnline).toBe(true)); + + act(() => { + client.wsConnection._setStatus({ isOnline: false }); + }); + expect(result.current?.isOnline).toBe(false); + + act(() => { + client.wsConnection._setStatus({ isOnline: true, connectionId: 'reconnected' }); + }); + expect(result.current?.isOnline).toBe(true); + expect(result.current?.connectionId).toBe('reconnected'); + }); + + it('is not moved by the device network going down', async () => { + // The guard that matters. Both facts dispatch `connection.changed` with the same payload + // shape, so nothing but the `connection` discriminator keeps them apart. + const { result } = renderHook(() => useWSConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current?.isOnline).toBe(true)); + + act(() => { + client.networkConnection.setStatus(false); + }); + + expect(result.current?.isOnline).toBe(true); + }); + }); + + describe('useNetworkConnectionState', () => { + it('starts unknown rather than assuming offline', async () => { + // `undefined`, not `false`. A guard written as `!isOnline` would render an offline banner here + // and never clear it — which is why every consumer tests `=== false`. + const { result } = renderHook(() => useNetworkConnectionState(), { wrapper }); + + await waitFor(() => expect(result.current).toBeDefined()); + expect(result.current?.isOnline).toBeUndefined(); + }); + + 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({ isOnline: false }); + }); + + expect(result.current?.isOnline).toBe(true); + }); + }); +}); 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/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..ef98dbe0d3 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 { NetworkStatusListenerRegistrar, 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 registrar.** 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,53 @@ 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 }; + // Registered through the declarative config rather than + // `client.networkConnection.setStatusListenerRegistrar(...)`. An imperatively installed registrar + // is torn down by the next configuration derivation — which includes every `client.config.set()` + // and the `disconnectUser()` -> `connectUser()` cycle and on React Native there is no platform + // default to replace it with, so the client would silently stop being told about the network. + client.config.set({ + client: { + networkConnection: { + statusListenerRegistrar: netInfoStatusListenerRegistrar, + }, + }, + }); + + // Deliberately no teardown. The registrar'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 — `setStatusListenerRegistrar(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: `netInfoStatusListenerRegistrar` is a stable + // module-scope reference, so `ConfigController`'s no-op write check and + // `setStatusListenerRegistrar`'s identity guard both short-circuit. A *different* client re-runs + // the effect through the dependency array and installs a fresh registrar for it. + }, [client, clientExists]); }; + +/** + * Subscribes to NetInfo and reports every change to the client. + * + * Module scope, so the same reference is handed to the client on every derivation — re-installing an + * identical registrar 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 + * registrar contract's "report the current status as soon as it is known" requirement — so no + * separate `NetInfo.fetch()` is needed. + */ +const netInfoStatusListenerRegistrar: NetworkStatusListenerRegistrar = (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..e1f9c0f49b --- /dev/null +++ b/package/src/components/Chat/hooks/useWSConnectionState.ts @@ -0,0 +1,42 @@ +import type { WSConnectionState } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +const identity = (state: WSConnectionState) => state; + +/** + * 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". + * + * `isOnline` here is always a boolean — a socket always has a state — so `!isOnline` is safe, unlike + * the network store's equivalent. + * + * Reads the store rather than reacting to `connection.changed`, so it is correct on mount rather than + * only after the first transition, and so it reports the paths the event is silent about + * (`client.closeConnection()`, the mobile backgrounding path, dispatches nothing). + * + * 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); +}; diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index 1ae31acea3..5e648f9b73 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 { useWSConnectionState } 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 = !!useWSConnectionState()?.isOnline; 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..aff693c5ec 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 { useWSConnectionState } 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 = !!useWSConnectionState()?.isOnline; const { channel } = useChannelContext(); const { audioRecordingEnabled, diff --git a/package/src/components/MessageList/NetworkDownIndicator.tsx b/package/src/components/MessageList/NetworkDownIndicator.tsx index 43fedbac1b..43e1361a82 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 { useWSConnectionState } from '../Chat/hooks/useWSConnectionState'; export const NetworkDownIndicator = () => { - const { isOnline } = useChatContext(); + const isNetworkOnline = useNetworkConnectionState()?.isOnline; + const isWSOnline = useWSConnectionState()?.isOnline; 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..e232e3aeb5 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -246,33 +246,68 @@ 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); + 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({ isOnline: 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..713355adfa 100644 --- a/package/src/components/ThreadList/ThreadList.tsx +++ b/package/src/components/ThreadList/ThreadList.tsx @@ -103,7 +103,12 @@ export const ThreadList = (props: ThreadListProps) => { return; } - const listener = client.on('connection.recovered', () => { + const listener = client.on('connection.recovered', (event) => { + // The socket going down is what invalidates the loaded list; a network recovery is a + // different fact and not a reason to requery. + if (event.connection !== 'ws') { + return; + } 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..f6384b48b7 100644 --- a/package/src/mock-builders/event/connectionChanged.ts +++ b/package/src/mock-builders/event/connectionChanged.ts @@ -1,9 +1,15 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { Event, StreamChat } from 'stream-chat'; -export default (client: StreamChat, online = true) => { +/** + * Both connections report through `connection.changed`, and consumers narrow on `connection` — so a + * dispatcher that omits it exercises nothing. Defaults to the socket, which is what almost every + * test means; pass `'network'` to drive the device-network half. + */ +export default (client: StreamChat, online = true, connection: 'network' | 'ws' = 'ws') => { client.dispatchEvent( fromPartial({ + connection, online, type: 'connection.changed', }), diff --git a/package/src/mock-builders/event/connectionRecovered.ts b/package/src/mock-builders/event/connectionRecovered.ts index a311ff7b64..5d73a48b84 100644 --- a/package/src/mock-builders/event/connectionRecovered.ts +++ b/package/src/mock-builders/event/connectionRecovered.ts @@ -1,9 +1,10 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { Event, StreamChat } from 'stream-chat'; -export default (client: StreamChat) => { +export default (client: StreamChat, connection: 'network' | 'ws' = 'ws') => { client.dispatchEvent( fromPartial({ + connection, type: 'connection.recovered', }), ); diff --git a/package/src/mock-builders/mock.ts b/package/src/mock-builders/mock.ts index 8f8cf32df0..ec21424405 100644 --- a/package/src/mock-builders/mock.ts +++ b/package/src/mock-builders/mock.ts @@ -28,7 +28,10 @@ type MockableStreamChat = StreamChat & { export const setUser = (client: StreamChat, user: MockUser): Promise => new Promise((resolve) => { const c = client as MockableStreamChat; - c.connectionId = 'dumm_connection_id'; + // A connected client means a live socket with a connection id — `channel.watch()` and + // `client.queryChannels()` now wait for one instead of degrading to `watch: false`, so a + // fixture that leaves the socket down makes every one of them wait out its timeout. + client.wsConnection._setStatus({ isOnline: true, connectionId: 'dummy_connection_id' }); // `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 fec19a5699..fa538a5b41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8566,7 +8566,7 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.11.23, dayjs@npm:^1.8.15": +"dayjs@npm:^1.11.13, dayjs@npm:^1.11.23, dayjs@npm:^1.8.15": version: 1.11.23 resolution: "dayjs@npm:1.11.23" checksum: 10c0/69ab04bf19c676e44ab50cc2fca223d265f3dafd107151ef3b0f3ad74d360c37caa602abe62f87cb25d90bd9f6bee003698af47df5b0dbf10a998b7b5f3bb3f1 @@ -11392,7 +11392,7 @@ __metadata: languageName: node linkType: hard -"i18next@npm:^26.4.2": +"i18next@npm:^26.3.6, i18next@npm:^26.4.2": version: 26.4.2 resolution: "i18next@npm:26.4.2" peerDependencies: @@ -18426,22 +18426,22 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^10.0.0-rc.10": - version: 10.0.0-rc.10 - resolution: "stream-chat@npm:10.0.0-rc.10" +"stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js::locator=root%40workspace%3A.": + version: 0.0.0-use.local + resolution: "stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js::locator=root%40workspace%3A." dependencies: "@stream-io/logger": "npm:^2.0.0" - "@stream-io/state-store": "npm:^1.1.6" axios: "npm:^1.19.0" + dayjs: "npm:^1.11.13" + i18next: "npm:^26.3.6" linkifyjs: "npm:^4.3.3" dependenciesMeta: esbuild: built: true husky: built: true - checksum: 10c0/00eb4de2b390f633a7b7e2613a6a2aa30543c6e0bcda96bbf10ac257cef07bd14b78e2c5677ad36c2b91d64d820baff63fa65782581f568755a9984f18843dc0 languageName: node - linkType: hard + linkType: soft "stream-combiner2@npm:~1.1.1": version: 1.1.1