Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/ExpoMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^"
},
Expand Down
4 changes: 2 additions & 2 deletions examples/SampleApp/src/components/ChatScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,7 +34,7 @@ export const ChatScreenHeader: React.FC<{ title?: string }> = ({ title = 'Stream

const navigation = useNavigation<ChatScreenHeaderNavigationProp>();
const { chatClient } = useAppContext();
const { isOnline } = useChatContext();
const isOnline = useSettledWSConnectionHealth();

return (
<ScreenHeader
Expand Down
5 changes: 3 additions & 2 deletions examples/SampleApp/src/components/FastImageAdapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { ImageProps } from 'react-native';

import FastImage from '@d11/react-native-fast-image';
import type { FastImageProps } from '@d11/react-native-fast-image';
import { useChatContext } from 'stream-chat-react-native';
import { useNetworkConnectionState } from 'stream-chat-react-native';

type FastImageAdapterProps = Omit<ImageProps, 'source'> &
Pick<FastImageProps, 'source' | 'transition'>;

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,
Expand Down
14 changes: 7 additions & 7 deletions examples/SampleApp/src/screens/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,7 +60,7 @@ const ChannelHeader: React.FC<ChannelHeaderProps> = ({ channel }) => {
const { closePicker } = useAttachmentPickerContext();
const membersStatus = useChannelMembersStatus(channel);
const displayName = useChannelPreviewDisplayName(channel);
const { isOnline } = useChatContext();
const isOnline = useSettledWSConnectionHealth();
const { chatClient } = useAppContext();
const navigation = useNavigation<ChannelScreenNavigationProp>();

Expand Down
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
30 changes: 24 additions & 6 deletions package/src/__tests__/offline-support/optimistic-update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -212,10 +222,10 @@ export const OptimisticUpdates = () => {
channels: [channelResponse] as unknown as Parameters<typeof upsertChannels>[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(() => {
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 11 additions & 6 deletions package/src/components/Accessibility/NotificationAnnouncer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<NotificationAnnouncer />`. RN does not yet have a
Expand All @@ -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<boolean | null | undefined>(undefined);
Expand All @@ -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;
};
12 changes: 7 additions & 5 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -170,8 +171,7 @@ export type ChannelPropsWithContext = Pick<ChannelContextValue, 'channel'> &
| 'maxTimeBetweenGroupedMessages'
>
> &
Pick<ChatContextValue, 'client' | 'enableOfflineSupport' | 'isOnline'> &
Partial<
Pick<ChatContextValue, 'client' | 'enableOfflineSupport'> & { isOnline: boolean } & Partial<
Pick<
InputMessageInputContextValue,
| 'additionalTextInputProps'
Expand Down Expand Up @@ -678,8 +678,9 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =

// 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;
Expand Down Expand Up @@ -1085,7 +1086,8 @@ export type ChannelProps = Partial<Omit<ChannelPropsWithContext, 'channel' | 'th
* @example ./Channel.md
*/
export const Channel = (props: PropsWithChildren<ChannelProps>) => {
const { client, enableOfflineSupport, isOnline, isMessageAIGenerated } = useChatContext();
const { client, enableOfflineSupport, isMessageAIGenerated } = useChatContext();
const isOnline = useSettledWSConnectionHealth();
const { t } = useTranslationContext();
const notificationHostId =
props.notificationHostId ??
Expand Down
10 changes: 7 additions & 3 deletions package/src/components/ChannelList/ChannelListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@ 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,
'maxUnreadCount' | 'numberOfSkeletons' | 'onSelect'
>;

const StatusIndicator = () => {
const { isOnline } = useChatContext();
const isNetworkOnline = useNetworkConnectionState()?.isOnline;
const isWSOnline = useSettledWSConnectionHealth();
const styles = useStyles();
const { error, loadingChannels, refreshList } = useChannelsContext();
const { ChannelListHeaderErrorIndicator, ChannelListHeaderNetworkDownIndicator } =
Expand All @@ -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 (
<View style={styles.statusIndicator}>
<ChannelListHeaderNetworkDownIndicator />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,20 +26,14 @@ const queryChannelsOverride: ChannelListQueryChannelsOverride = () =>
*/
const Component = () => (
<Chat client={chatClient}>
<ChatContext.Consumer>
{(context) => (
<ChatProvider value={{ ...context, isOnline: true }}>
<ChannelList
filters={{
members: {
$in: ['vishal', 'neil'],
},
}}
queryChannelsOverride={queryChannelsOverride}
/>
</ChatProvider>
)}
</ChatContext.Consumer>
<ChannelList
filters={{
members: {
$in: ['vishal', 'neil'],
},
}}
queryChannelsOverride={queryChannelsOverride}
/>
</Chat>
);

Expand All @@ -58,36 +51,30 @@ const ComponentWithContextOverrides = ({
loadingChannels: boolean;
}) => (
<Chat client={chatClient}>
<ChatContext.Consumer>
{(context) => (
<ChatProvider value={{ ...context, isOnline: true }}>
<ChannelsProvider
value={
{
additionalFlatListProps: {},
channelListInitialized: !loadingChannels && !error,
channels: error ? null : [],
error: error ? new Error('test error') : undefined,
forceUpdate: 0,
hasNextPage: false,
loadingChannels,
loadingNextPage: false,
loadMoreThreshold: 0.1,
loadNextPage: noop,
maxUnreadCount: 255,
numberOfSkeletons: 8,
refreshing: false,
refreshList: noop,
reloadList: noop,
setFlatListRef: noop,
} as unknown as ChannelsContextValue
}
>
<ChannelListView />
</ChannelsProvider>
</ChatProvider>
)}
</ChatContext.Consumer>
<ChannelsProvider
value={
{
additionalFlatListProps: {},
channelListInitialized: !loadingChannels && !error,
channels: error ? null : [],
error: error ? new Error('test error') : undefined,
forceUpdate: 0,
hasNextPage: false,
loadingChannels,
loadingNextPage: false,
loadMoreThreshold: 0.1,
loadNextPage: noop,
maxUnreadCount: 255,
numberOfSkeletons: 8,
refreshing: false,
refreshList: noop,
reloadList: noop,
setFlatListRef: noop,
} as unknown as ChannelsContextValue
}
>
<ChannelListView />
</ChannelsProvider>
</Chat>
);

Expand Down
Loading
Loading