diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index 7c3161e09..95f0aa6df 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -16,7 +16,7 @@ "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.11", + "stream-chat": "^10.0.0-rc.12", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/tutorial/src/2-core-component-setup/layout.css b/examples/tutorial/src/2-core-component-setup/layout.css index c3cf99687..5fa14209f 100644 --- a/examples/tutorial/src/2-core-component-setup/layout.css +++ b/examples/tutorial/src/2-core-component-setup/layout.css @@ -1,21 +1,21 @@ html, body, #root { - height: 100%; + height: 100%; } body { - margin: 0; + margin: 0; } #root { - display: flex; + display: flex; } .str-chat__channel-list { - width: 30%; + width: 30%; } .str-chat__channel { - width: 100%; + width: 100%; } .str-chat__thread { - width: 45%; -} \ No newline at end of file + width: 45%; +} diff --git a/examples/tutorial/src/3-channel-list/layout.css b/examples/tutorial/src/3-channel-list/layout.css index 2fd790e68..8e25c006e 100644 --- a/examples/tutorial/src/3-channel-list/layout.css +++ b/examples/tutorial/src/3-channel-list/layout.css @@ -19,7 +19,7 @@ /* Panel backgrounds */ --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ + --str-chat__background-core-app: #c7dafc; /* message list background */ /* Focus ring */ --str-chat__border-utility-focused: #1e40af; diff --git a/examples/tutorial/src/4-custom-ui-components/layout.css b/examples/tutorial/src/4-custom-ui-components/layout.css index 2fd790e68..8e25c006e 100644 --- a/examples/tutorial/src/4-custom-ui-components/layout.css +++ b/examples/tutorial/src/4-custom-ui-components/layout.css @@ -19,7 +19,7 @@ /* Panel backgrounds */ --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ + --str-chat__background-core-app: #c7dafc; /* message list background */ /* Focus ring */ --str-chat__border-utility-focused: #1e40af; diff --git a/examples/tutorial/src/5-custom-attachment-type/layout.css b/examples/tutorial/src/5-custom-attachment-type/layout.css index 2fd790e68..8e25c006e 100644 --- a/examples/tutorial/src/5-custom-attachment-type/layout.css +++ b/examples/tutorial/src/5-custom-attachment-type/layout.css @@ -19,7 +19,7 @@ /* Panel backgrounds */ --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ + --str-chat__background-core-app: #c7dafc; /* message list background */ /* Focus ring */ --str-chat__border-utility-focused: #1e40af; diff --git a/examples/tutorial/src/6-emoji-picker/layout.css b/examples/tutorial/src/6-emoji-picker/layout.css index 2fd790e68..8e25c006e 100644 --- a/examples/tutorial/src/6-emoji-picker/layout.css +++ b/examples/tutorial/src/6-emoji-picker/layout.css @@ -19,7 +19,7 @@ /* Panel backgrounds */ --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ + --str-chat__background-core-app: #c7dafc; /* message list background */ /* Focus ring */ --str-chat__border-utility-focused: #1e40af; diff --git a/examples/tutorial/src/7-livestream/layout.css b/examples/tutorial/src/7-livestream/layout.css index 2fd790e68..8e25c006e 100644 --- a/examples/tutorial/src/7-livestream/layout.css +++ b/examples/tutorial/src/7-livestream/layout.css @@ -19,7 +19,7 @@ /* Panel backgrounds */ --str-chat__background-core-elevation-1: #dbeafe; /* channel list, surrounding panels */ - --str-chat__background-core-app: #c7dafc; /* message list background */ + --str-chat__background-core-app: #c7dafc; /* message list background */ /* Focus ring */ --str-chat__border-utility-focused: #1e40af; diff --git a/examples/tutorial/src/tutorial-main.css b/examples/tutorial/src/tutorial-main.css index c6076e0fc..e21b8d5c6 100644 --- a/examples/tutorial/src/tutorial-main.css +++ b/examples/tutorial/src/tutorial-main.css @@ -51,7 +51,9 @@ background: rgba(255, 255, 255, 0.72); color: #0f172a; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); - transition: transform 160ms ease, box-shadow 160ms ease; + transition: + transform 160ms ease, + box-shadow 160ms ease; } .tutorial-browser__step-button--active { diff --git a/examples/vite/docs-playwright/screenshot-misc.ts b/examples/vite/docs-playwright/screenshot-misc.ts index 223651439..d64eb75f0 100644 --- a/examples/vite/docs-playwright/screenshot-misc.ts +++ b/examples/vite/docs-playwright/screenshot-misc.ts @@ -185,13 +185,12 @@ async function captureConnectionStatus(browser: any) { // Inject a connection status notification by dispatching an event await viewPage.evaluate(`(async () => { var client = window.client; - // Simulate connection failure notification - client.dispatchEvent({ - type: 'connection.changed', - online: false, - }); + // Simulate connection failure notification. The status store is the whole interface; the event + // this used to dispatch is gone. + client.wsConnection._setStatus({ isOnline: false }); })()`); - await viewPage.waitForTimeout(1500); + // The banner holds a drop for five seconds before showing it, so a brief flap does not strobe it. + await viewPage.waitForTimeout(6500); console.log('📸 ConnectionStatus.png'); // Screenshot the bottom area of the message list + notification + input diff --git a/examples/vite/index.html b/examples/vite/index.html index 4fbb263d7..dd8e27f14 100644 --- a/examples/vite/index.html +++ b/examples/vite/index.html @@ -7,8 +7,15 @@ Stream Chat React - - + +
diff --git a/examples/vite/package.json b/examples/vite/package.json index 7b2d34d36..5b6a93529 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -18,7 +18,7 @@ "modern-normalize": "^3.0.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.11", + "stream-chat": "^10.0.0-rc.12", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 36eb0d969..94b4ba5ba 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -77,6 +77,7 @@ import { resolveSingleChannel, SingleChannelModal, } from './SingleChannel/SingleChannelApp.tsx'; +import { ConnectionDevPanel } from './ConnectionDevPanel/ConnectionDevPanel.tsx'; import { SystemNotification } from './SystemNotification/SystemNotification.tsx'; import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx'; import { @@ -291,6 +292,9 @@ const App = () => { channelCid: state.layout.channelCid, })); const { mode: themeMode } = useAppSettingsSelector((state) => state.theme); + const { connectionPanel: connectionPanelVisible } = useAppSettingsSelector( + (state) => state.devTools, + ); const initialChannelId = useMemo(() => getInitialChannelIdFromUrl(), []); const initialChatView = useMemo(() => getInitialChatViewFromUrl(), []); const initialThreadId = useMemo(() => getInitialThreadIdFromUrl(), []); @@ -638,6 +642,7 @@ const App = () => { ref={appLayoutRef} style={initialAppLayoutStyle} > + {connectionPanelVisible && }
diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx index 8a739ee29..234235e12 100644 --- a/examples/vite/src/AppSettings/AppSettings.tsx +++ b/examples/vite/src/AppSettings/AppSettings.tsx @@ -26,6 +26,7 @@ import { ReactionsTab } from './tabs/Reactions'; import { SidebarTab } from './tabs/Sidebar'; import { appSettingsStore, useAppSettingsState } from './state'; import { + IconConnection, IconGear, IconMoon, IconSidebar, @@ -159,6 +160,30 @@ const SidebarThemeToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => { ); }; +const SidebarConnectionPanelToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => { + const { devTools } = useAppSettingsState(); + const { connectionPanel } = devTools; + + return ( + + appSettingsStore.partialNext({ + devTools: { ...devTools, connectionPanel: !connectionPanel }, + }) + } + role='switch' + text={connectionPanel ? 'Hide connection panel' : 'Connection panel'} + /> + ); +}; + const SidebarRtlToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => { const { theme, @@ -203,6 +228,7 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { return (
+ diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 94571be88..2e7db8f35 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -13,6 +13,12 @@ export type ChatViewSettingsState = { iconOnly: boolean; }; +/** Dev-only affordances that would not ship in an application. */ +export type DevToolsSettingsState = { + /** Shows the panel that drives the network and WebSocket facts independently. */ + connectionPanel: boolean; +}; + export type ThemeSettingsState = { direction: 'ltr' | 'rtl'; mode: 'dark' | 'light'; @@ -94,6 +100,7 @@ export type LayoutSettingsState = { export type AppSettingsState = { channelDetail: ChannelDetailSettingsState; chatView: ChatViewSettingsState; + devTools: DevToolsSettingsState; language: LanguageSettingsState; layout: LayoutSettingsState; messageActions: MessageActionsSettingsState; @@ -122,6 +129,9 @@ const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const defaultAppSettingsState: AppSettingsState = { + devTools: { + connectionPanel: false, + }, channelDetail: { modal: { channelMembersView: { diff --git a/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.scss b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.scss new file mode 100644 index 000000000..2d4ad3544 --- /dev/null +++ b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.scss @@ -0,0 +1,52 @@ +.connection-dev-panel { + align-items: center; + background: var(--str-chat__secondary-surface-color, #f7f7f8); + border-bottom: 1px solid var(--str-chat__surface-color, #e3e5e8); + display: flex; + flex-wrap: wrap; + font-family: monospace; + font-size: 11px; + gap: 10px; + padding: 6px 12px; + + &__label { + opacity: 0.6; + text-transform: uppercase; + } + + &__toggle { + align-items: center; + background: #fff; + border: 1px solid var(--str-chat__surface-color, #d0d3d8); + border-radius: 3px; + cursor: pointer; + display: flex; + font-family: inherit; + font-size: inherit; + gap: 8px; + padding: 3px 8px; + + // `false` is the only definite negative. `undefined` means unknown and gets its own colour, so + // the state that must not read as offline does not look like it. + &[data-state='false'] { + background: #ffd7d7; + } + + &[data-state='true'] { + background: #d7f5dd; + } + + &[data-state='undefined'] { + background: #ffe9c7; + } + } + + &__action { + opacity: 0.55; + } + + &__hint { + margin-left: auto; + opacity: 0.5; + } +} diff --git a/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx new file mode 100644 index 000000000..06a69e4d7 --- /dev/null +++ b/examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx @@ -0,0 +1,113 @@ +import { useRef } from 'react'; +import { + useChatContext, + useNetworkConnectionState, + useWSConnectionState, +} from 'stream-chat-react'; + +import './ConnectionDevPanel.scss'; + +/** + * Drives the two connection facts independently, so the pair that disagrees can actually be seen. + * + * DevTools' "Offline" checkbox is no use for this: it takes down `navigator.onLine` **and** the + * socket, which is the one combination that always worked. What needs exercising is a dead socket on + * a live network — a server close, an expired token, a health-check timeout — because that is the case + * the banner used to describe as "Waiting for network…". + * + * **Both toggles simulate rather than sever.** The socket one writes `client.wsConnection.state`, + * which is what the real socket does and what every consumer reads. Closing the real socket is no + * good for a toggle: `StableWSConnection` reconnects on its own within a second or two, so it would + * flip back by itself. + * + * Taking the socket down therefore takes five seconds to reach the banner, because `` holds a + * drop for `offlineNotificationDisplayDelayMs` before showing it. The label below flips at once. + * + * For the genuine path, close the socket from the console instead: + * + * ```js + * client.wsConnection.connection.ws.close() + * ``` + * + * Dev-only. Nothing here belongs in an application: `setStatus` is for a platform listener rather + * than a button. + */ +export const ConnectionDevPanel = () => { + const { client } = useChatContext(); + const { isOnline: networkOnline } = useNetworkConnectionState() ?? {}; + const { isHealthy: socketHealthy } = useWSConnectionState() ?? {}; + // Not in the socket's store: the id belongs to `client.connectionIdManager`. Read during render + // rather than subscribed to, which is enough here - both stores above re-render this panel. + const connectionId = client?.connectionIdManager.connectionId; + // Parked while the socket is simulated down, so bringing it back hands the *same* id over. See + // the toggle below for why inventing one is not an option. + const parkedConnectionId = useRef(undefined); + + if (!client) return null; + + return ( + + ); +}; diff --git a/examples/vite/src/CustomMessageUi/system-message-variants.css b/examples/vite/src/CustomMessageUi/system-message-variants.css index 0da863040..7ce87d1d9 100644 --- a/examples/vite/src/CustomMessageUi/system-message-variants.css +++ b/examples/vite/src/CustomMessageUi/system-message-variants.css @@ -9,13 +9,5 @@ flex-direction: column; align-items: center; border-radius: var(--str-chat__border-radius-md); - background: linear-gradient( - to right, - orange, - yellow, - green, - cyan, - blue, - violet - ); + background: linear-gradient(to right, orange, yellow, green, cyan, blue, violet); } diff --git a/examples/vite/src/CustomMessageUi/variants.css b/examples/vite/src/CustomMessageUi/variants.css index f2da4e9d2..eadb5708a 100644 --- a/examples/vite/src/CustomMessageUi/variants.css +++ b/examples/vite/src/CustomMessageUi/variants.css @@ -24,11 +24,11 @@ } .custom-message-ui--mine .custom-message-ui__name::before { - content: "<"; + content: '<'; } .custom-message-ui--other .custom-message-ui__name::after { - content: ">"; + content: '>'; } .custom-message-ui--mine { @@ -84,7 +84,7 @@ .custom-message-ui__metadata > *:nth-child(even)::after, .custom-message-ui__metadata > *:nth-child(even)::before { - content: "•"; + content: '•'; padding-inline: var(--cmui-gap-size-sm); } @@ -98,21 +98,21 @@ /* ─── Variant 5: Grouped metadata (hidden by default) ────────────── */ -[data-variant="5"] .custom-message-ui__metadata, -[data-variant="6"] .custom-message-ui__metadata, -[data-variant="7"] .custom-message-ui__metadata, -[data-variant="8"] .custom-message-ui__metadata { +[data-variant='5'] .custom-message-ui__metadata, +[data-variant='6'] .custom-message-ui__metadata, +[data-variant='7'] .custom-message-ui__metadata, +[data-variant='8'] .custom-message-ui__metadata { display: none; } -[data-variant="5"] .str-chat__li--bottom .custom-message-ui__metadata, -[data-variant="5"] .str-chat__li--single .custom-message-ui__metadata, -[data-variant="6"] .str-chat__li--bottom .custom-message-ui__metadata, -[data-variant="6"] .str-chat__li--single .custom-message-ui__metadata, -[data-variant="7"] .str-chat__li--bottom .custom-message-ui__metadata, -[data-variant="7"] .str-chat__li--single .custom-message-ui__metadata, -[data-variant="8"] .str-chat__li--bottom .custom-message-ui__metadata, -[data-variant="8"] .str-chat__li--single .custom-message-ui__metadata { +[data-variant='5'] .str-chat__li--bottom .custom-message-ui__metadata, +[data-variant='5'] .str-chat__li--single .custom-message-ui__metadata, +[data-variant='6'] .str-chat__li--bottom .custom-message-ui__metadata, +[data-variant='6'] .str-chat__li--single .custom-message-ui__metadata, +[data-variant='7'] .str-chat__li--bottom .custom-message-ui__metadata, +[data-variant='7'] .str-chat__li--single .custom-message-ui__metadata, +[data-variant='8'] .str-chat__li--bottom .custom-message-ui__metadata, +[data-variant='8'] .str-chat__li--single .custom-message-ui__metadata { display: flex; } @@ -197,7 +197,7 @@ padding: 5px; } -[data-variant="8"] { +[data-variant='8'] { --str-chat__own-message-reaction-background-color: var(--cmui-bg-color, #efefef); --str-chat__message-reaction-background-color: white; --str-chat__message-reaction-border-radius: 0.2rem; diff --git a/examples/vite/src/i18n/de.ts b/examples/vite/src/i18n/de.ts index 813246055..f761e020c 100644 --- a/examples/vite/src/i18n/de.ts +++ b/examples/vite/src/i18n/de.ts @@ -258,6 +258,7 @@ export const deTranslations = { 'channelListItem.video.ariaLabel': 'Video', 'channelListItem.voiceMessage.ariaLabel': 'Sprachnachricht', 'channelListItem.voted.text': '📊 {{votedBy}} hat abgestimmt: {{pollOptionText}}', + 'chat.reportLostConnection.reconnecting.text': 'Verbindung wird wiederhergestellt…', 'chat.reportLostConnection.waitingNetwork.text': 'Warte auf Netzwerk…', 'command.ban.args': '[@benutzername] [text]', 'command.ban.description': 'Einen Benutzer sperren', diff --git a/examples/vite/src/i18n/it.ts b/examples/vite/src/i18n/it.ts index 4f13d6f83..2eaa79dbd 100644 --- a/examples/vite/src/i18n/it.ts +++ b/examples/vite/src/i18n/it.ts @@ -247,6 +247,7 @@ export const itTranslations = { 'channelListItem.video.ariaLabel': 'video', 'channelListItem.voiceMessage.ariaLabel': 'messaggio vocale', 'channelListItem.voted.text': '📊 {{votedBy}} ha votato: {{pollOptionText}}', + 'chat.reportLostConnection.reconnecting.text': 'Riconnessione…', 'chat.reportLostConnection.waitingNetwork.text': 'In attesa della rete…', 'command.ban.args': '[@nomeutente] [testo]', 'command.ban.description': 'Banna un utente', diff --git a/examples/vite/src/icons.tsx b/examples/vite/src/icons.tsx index 1b3ecbf38..cc97e568e 100644 --- a/examples/vite/src/icons.tsx +++ b/examples/vite/src/icons.tsx @@ -12,6 +12,19 @@ export const IconSidebar = createIcon( />, ); +/** Two arcs and a dot — a signal/reachability mark, for the connection dev panel toggle. */ +export const IconConnection = createIcon( + 'IconConnection', + , +); + export const IconGear = createIcon( 'IconGear', , diff --git a/package.json b/package.json index d3526a2f7..714661b19 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "^10.0.0-rc.10" + "stream-chat": "^10.0.0-rc.12" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -201,7 +201,7 @@ "react-dom": "^19.2.6", "sass": "^1.100.0", "semantic-release": "^25.0.3", - "stream-chat": "10.0.0-rc.11", + "stream-chat": "^10.0.0-rc.12", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.1.3", diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index e8de59a43..c6d74f422 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -78,20 +78,6 @@ export const Channel = (props: PropsWithChildren) => { // One subscription per event, each released by its own handle, so subscribing and // unsubscribing cannot drift apart and nothing has to filter events it never asked for. const subscriptions = [ - // Reconnect hydration skips an active channel's message list -- a 25-message page would - // perturb a scrolled-back window -- and leaves it to `channel.reload()`, which re-watches - // sized to the loaded window. Nothing else calls it, so without this the list stays stale - // and offline hard deletes are never reconciled: they arrive via no event. - client.on('connection.recovered', async () => { - if (channel.pendingDisposal) return; - try { - await channel.reload(); - } catch (error) { - // The socket can drop again mid-reload. Keep the loaded window; the next recovery retries. - console.warn('Failed to reload the channel after connection recovery', error); - } - }), - // Channel state is not normalized, so rather than hunting this user's references through it // we re-query. Note what that does and does not do: it refreshes members, read state and // watchers, but not the loaded messages -- the page it asks for is older than the window. diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 387bc5171..84827d605 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -25,7 +25,6 @@ import { useStateStore } from '../../../store'; import type { GenerateChannelOptions } from '../../../mock-builders'; import { dispatchChannelTruncatedEvent, - dispatchConnectionChangedEvent, dispatchConnectionRecoveredEvent, erroredPostApi, generateChannel, @@ -36,6 +35,7 @@ import { getTestClientWithUser, initClientWithChannels, sendMessageApi, + setWSConnectionStatus, useMockedApis, } from '../../../mock-builders'; import { WithComponents } from '../../../context'; @@ -236,21 +236,6 @@ describe('Channel', () => { expect(await findByText('children')).toBeInTheDocument(); }); - // should these 'on' tests actually test if the handler works? - it('should add a connection recovery handler on the client on mount', async () => { - const { channel, chatClient } = await setup(); - const clientOnSpy = vi.spyOn(chatClient, 'on'); - - await renderComponent({ channel, chatClient }); - - await waitFor(() => - expect(clientOnSpy).toHaveBeenCalledWith( - 'connection.recovered', - expect.any(Function), - ), - ); - }); - it('releases every subscription it opened when the channel goes away', async () => { // The invariant, rather than the wiring: whatever this component subscribes to, it // unsubscribes from. Before the subscriptions were collected and released by their own handles, @@ -288,41 +273,49 @@ describe('Channel', () => { await renderComponent({ channel, chatClient }); // Wait for the mount effect to finish (it registers the event subscriptions)... await waitFor(() => - expect(clientOnSpy).toHaveBeenCalledWith( - 'connection.recovered', - expect.any(Function), - ), + expect(clientOnSpy).toHaveBeenCalledWith('user.deleted', expect.any(Function)), ); // ...then confirm it did not mark read. expect(markReadSpy).not.toHaveBeenCalled(); }); describe('connection recovery', () => { - // The client's reconnect hydration skips re-seeding the message list of an `active` channel - // (Channel marks it active while mounted) and delegates that window to `channel.reload()`. - // Nothing else calls it, so these pin the SDK component as the thing that does. - it('reloads the channel when the connection is recovered', async () => { + // `ConnectionRecoveryManager` reloads every active channel and *then* dispatches + // `connection.recovered`. `Channel` marks its channel active while mounted, so handling that + // event here as well would reload every open channel twice per reconnect — two full `watch()` + // requests. `Channel.reload()`'s `_reloading` flag would not collapse the pair: it is a + // re-entrancy guard and has already reset by the time the event is dispatched. + it('reloads an open channel exactly once per reconnect', async () => { const { channel, chatClient } = await setup(); await renderComponent({ channel, chatClient }); + await waitFor(() => expect(channel.active).toBe(true)); const reloadSpy = vi.spyOn(channel, 'reload').mockResolvedValue(undefined); + chatClient.connectionRecovery.registerSubscriptions(); + // A whole reconnect, driven from the socket's status store rather than by dispatching the + // recovery event directly — that is what exercises both would-be reloaders. Down first, + // because recovery ignores a first connect: nothing was loaded to fall behind. await act(async () => { - dispatchConnectionRecoveredEvent(chatClient); - await Promise.resolve(); + setWSConnectionStatus(chatClient, false); + setWSConnectionStatus(chatClient, true); + await new Promise((resolve) => setTimeout(resolve, 50)); }); - await waitFor(() => expect(reloadSpy).toHaveBeenCalledTimes(1)); + expect(reloadSpy).toHaveBeenCalledTimes(1); }); - it('does not reload a channel that is pending disposal', async () => { + it('leaves the reload to the client, so it happens even without this component', async () => { + // The reconciliation React's own reload existed for — a hard delete that happened offline + // arrives via no event, so only a re-query surfaces it — still happens, because the client + // reloads active channels itself. This pins that it is the client doing it. const { channel, chatClient } = await setup(); await renderComponent({ channel, chatClient }); + await waitFor(() => expect(channel.active).toBe(true)); const reloadSpy = vi.spyOn(channel, 'reload').mockResolvedValue(undefined); - // `reload()` goes through `getClient()`, which throws once the channel is pending disposal. - channel.pendingDisposal = true; + // `Channel` has no `connection.recovered` handler, so this alone must do nothing. await act(async () => { dispatchConnectionRecoveredEvent(chatClient); await Promise.resolve(); @@ -331,23 +324,27 @@ describe('Channel', () => { expect(reloadSpy).not.toHaveBeenCalled(); }); - it('keeps rendering when the reload fails', async () => { + it('keeps rendering when the reload fails during a reconnect', async () => { + // The guarantee the old React-side error handling provided, now held somewhere better: the + // client reloads active channels with `Promise.allSettled`, so a socket that flaps back down + // mid-reload cannot throw into this component at all. const { channel, chatClient } = await setup(); const { container } = await renderComponent({ channel, chatClient }); + await waitFor(() => expect(channel.active).toBe(true)); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const reloadSpy = vi .spyOn(channel, 'reload') .mockRejectedValue(new Error('socket flapped')); + chatClient.connectionRecovery.registerSubscriptions(); await act(async () => { - dispatchConnectionRecoveredEvent(chatClient); - await Promise.resolve(); + setWSConnectionStatus(chatClient, false); + setWSConnectionStatus(chatClient, true); + await new Promise((resolve) => setTimeout(resolve, 50)); }); - await waitFor(() => expect(reloadSpy).toHaveBeenCalledTimes(1)); + expect(reloadSpy).toHaveBeenCalledTimes(1); expect(container.querySelector('.str-chat__channel')).toBeInTheDocument(); - warnSpy.mockRestore(); }); }); @@ -363,7 +360,7 @@ describe('Channel', () => { // a re-render that reads channel state must not throw // (channel.lastRead() throws once the client is disconnected) await act(async () => { - dispatchConnectionChangedEvent(chatClient, false); + setWSConnectionStatus(chatClient, false); await Promise.resolve(); }); @@ -386,7 +383,7 @@ describe('Channel', () => { channel.pendingDisposal = true; await act(async () => { - dispatchConnectionChangedEvent(chatClient, false); + setWSConnectionStatus(chatClient, false); await Promise.resolve(); }); diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index 3c5d37644..8f6a6836a 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { OwnUserResponse } from 'stream-chat'; +import type { OwnUserResponse, StreamChat } from 'stream-chat'; import { ChannelPaginator } from 'stream-chat'; import { Chat } from '..'; @@ -14,10 +14,10 @@ import { Streami18n } from '../../../i18n'; import type { Notification } from 'stream-chat'; import type { UserMuteResponse } from 'stream-chat'; import { - dispatchConnectionChangedEvent, dispatchNotificationMutesUpdated, getTestClient, getTestClientWithUser, + setWSConnectionStatus, } from '../../../mock-builders'; const ChatContextConsumer = ({ fn }) => { @@ -376,37 +376,210 @@ describe('Chat', () => { }); describe('connection notifications', () => { - it('publishes and removes system connection-lost notification on connection changes', async () => { - const client = getTestClient(); - let connectionLostNotification; - + /** + * Takes the socket down and waits out the window the banner holds a drop for. + * + * The client publishes every transition as it happens; deciding a drop has lasted long enough to + * be worth telling a person about is the banner's job, so a test that wants the banner has to + * let that window pass. + */ + /** How long the banner sits on a drop, which is configuration rather than a fixed number. */ + const holdWindow = (client: StreamChat) => + client.wsConnection.config.offlineNotificationDisplayDelayMs; + + const dropSocket = (client: StreamChat) => { + vi.useFakeTimers(); + try { + act(() => setWSConnectionStatus(client, false)); + act(() => { + vi.advanceTimersByTime(holdWindow(client)); + }); + } finally { + vi.useRealTimers(); + } + }; + + it('keeps the notification when the socket drops before i18n has initialized', async () => { + // `Streami18n.init()` is asynchronous and replaces `t`, which re-runs the subscription effect. + // Dismissal must therefore be scoped to the mount, not to that effect's cleanup, or a drop + // inside the init window leaves no banner exactly when one is most wanted: an offline app + // launch, a captive portal, an expired token. + const client = await getTestClientWithUser(); render(
, ); - - expect(client.notifications.notifications).toHaveLength(0); - - act(() => dispatchConnectionChangedEvent(client, false)); - await waitFor(() => { - connectionLostNotification = client.notifications.notifications.find( + const chatNotifications = () => + client.notifications.notifications.filter( (notification) => notification.origin.emitter === 'Chat', ); - expect(connectionLostNotification).toBeDefined(); + + // Deliberately not awaiting anything first — the drop lands inside the init window. + dropSocket(client); + expect(chatNotifications()).toHaveLength(1); + + // Long enough for `init()` to resolve and `t` to be replaced. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); }); - expect(connectionLostNotification.message).toBe('Waiting for network…'); - expect(connectionLostNotification.tags).toEqual(['system']); + expect(chatNotifications()).toHaveLength(1); + }); - act(() => dispatchConnectionChangedEvent(client, true)); - await waitFor(() => { - expect( - client.notifications.notifications.find( - (notification) => notification.origin.emitter === 'Chat', - ), - ).toBeUndefined(); + /** + * The device losing its network and the socket dying are different facts, so they get different + * copy. Publishing "Waiting for network…" off the socket alone — which is what this did — told + * users their network was down when the server had closed the socket, the token had expired or a + * health check had timed out on working Wi-Fi. + */ + const chatNotificationsOf = (client: StreamChat) => + client.notifications.notifications.filter( + (notification) => notification.origin.emitter === 'Chat', + ); + + it('says reconnecting, not offline, when the socket dies on a working network', async () => { + const client = await getTestClientWithUser(); + render( + +
+ , + ); + // jsdom is a browser, so the built-in reporter has already reported the network as up. + expect(client.networkConnection.isOnline).toBe(true); + + dropSocket(client); + + await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(1)); + expect(chatNotificationsOf(client)[0].message).toBe('Reconnecting…'); + expect(chatNotificationsOf(client)[0].tags).toEqual(['system']); + }); + + it('says the network is down when the device reports no network', async () => { + const client = await getTestClientWithUser(); + render( + +
+ , + ); + + act(() => client.networkConnection.setStatus(false)); + + await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(1)); + expect(chatNotificationsOf(client)[0].message).toBe('Waiting for network…'); + }); + + it('swaps to the network message when the network drops while reconnecting', async () => { + const client = await getTestClientWithUser(); + render( + +
+ , + ); + + dropSocket(client); + await waitFor(() => + expect(chatNotificationsOf(client)[0].message).toBe('Reconnecting…'), + ); + + act(() => client.networkConnection.setStatus(false)); + + // One banner throughout, with the more specific message replacing the general one. + await waitFor(() => + expect(chatNotificationsOf(client)[0].message).toBe('Waiting for network…'), + ); + expect(chatNotificationsOf(client)).toHaveLength(1); + }); + + it('publishes immediately when the client is already offline at mount', async () => { + // The status is read on mount rather than waited for, so a client that is already offline when + // the banner mounts says so. + const client = await getTestClientWithUser(); + client.networkConnection.setStatus(false); + + render( + +
+ , + ); + + await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(1)); + expect(chatNotificationsOf(client)[0].message).toBe('Waiting for network…'); + }); + + it('clears on recovery', async () => { + const client = await getTestClientWithUser(); + render( + +
+ , + ); + + dropSocket(client); + await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(1)); + + act(() => setWSConnectionStatus(client, true)); + + await waitFor(() => expect(chatNotificationsOf(client)).toHaveLength(0)); + }); + + it('respects a configured hold window', async () => { + // The wait is configuration rather than a fixed number, so an integrator can shorten it, or + // switch the wait off with zero, without replacing the hook. Zero still defers to the next + // task, which is why this advances timers rather than asserting synchronously. + const client = await getTestClientWithUser(); + client.config.set({ + client: { wsConnection: { offlineNotificationDisplayDelayMs: 0 } }, }); + render( + +
+ , + ); + + vi.useFakeTimers(); + try { + act(() => setWSConnectionStatus(client, false)); + act(() => { + vi.advanceTimersByTime(0); + }); + } finally { + vi.useRealTimers(); + } + + expect(chatNotificationsOf(client)).toHaveLength(1); + expect(chatNotificationsOf(client)[0].message).toBe('Reconnecting…'); + }); + + it('shows nothing for a drop the socket recovers from inside the window', async () => { + // The reason the banner holds a drop at all. The socket retries on its own and most drops + // resolve in well under a second; announcing those makes a working application look broken. + const client = await getTestClientWithUser(); + render( + +
+ , + ); + + vi.useFakeTimers(); + try { + act(() => setWSConnectionStatus(client, false)); + act(() => { + vi.advanceTimersByTime(holdWindow(client) - 1); + }); + // Nothing yet, and nothing later either: coming back cancels the held drop rather than + // showing it and then removing it. + expect(chatNotificationsOf(client)).toHaveLength(0); + + act(() => setWSConnectionStatus(client, true)); + act(() => { + vi.advanceTimersByTime(holdWindow(client) * 2); + }); + } finally { + vi.useRealTimers(); + } + + expect(chatNotificationsOf(client)).toHaveLength(0); }); it('uses NotificationAnnouncer from ComponentContext', async () => { diff --git a/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx b/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx new file mode 100644 index 000000000..2ecd3b29c --- /dev/null +++ b/src/components/Chat/hooks/__tests__/connectionStateHooks.test.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { NetworkStatusReporter, StreamChat } from 'stream-chat'; + +import { Chat } from '../../Chat'; +import { useNetworkConnectionState } from '../useNetworkConnectionState'; +import { useNetworkConnectionStateSelector } from '../useNetworkConnectionState'; +import { useWSConnectionState } from '../useWSConnectionState'; +import { + getTestClient, + getTestClientWithUser, + setWSConnectionStatus, +} from '../../../../mock-builders'; + +/** An integrator's registration function, of the shape a platform API would be wrapped in. */ +const platformListener = () => { + let report: ((isOnline: boolean) => void) | undefined; + const reporter: NetworkStatusReporter = (onStatusChange) => { + report = onStatusChange; + return vi.fn(); + }; + return { + report: (isOnline: boolean) => { + if (!report) throw new Error('the reporter was never installed'); + act(() => report?.(isOnline)); + }, + reporter, + }; +}; + +const renderUnderChat = (client: StreamChat, ui: React.ReactNode) => + render({ui}); + +describe('connection state hooks', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('useNetworkConnectionState', () => { + it('starts online in jsdom, because jsdom is a browser', async () => { + // Worth stating explicitly: the SDK installs its built-in browser reporter whenever + // `window.addEventListener` exists and `navigator.onLine` is a boolean — which is true in + // jsdom. So a React test starts with a *known* status, not an unknown one, and the + // `undefined` case has to be arranged deliberately (see the next test). + const client = await getTestClientWithUser({ id: 'me' }); + const Consumer = () => { + const state = useNetworkConnectionState(); + return
{String(state?.isOnline)}
; + }; + + renderUnderChat(client, ); + + expect(screen.getByTestId('v')).toHaveTextContent('true'); + }); + + it('surfaces isOnline === undefined as-is until something has reported', () => { + // The React Native shape: `navigator` has no boolean `onLine`, so the browser reporter cannot + // be installed and the socket-derived stand-in takes over. That one reports nothing until the + // socket has been up once, so a client that has never connected is honestly unknown rather + // than coerced to either answer. Stubbed before the client is constructed, because a reporter + // installs and reports during construction. + vi.stubGlobal('navigator', { userAgent: 'ReactNative' }); + const client = getTestClient(); + const Consumer = () => { + const state = useNetworkConnectionState(); + return
{JSON.stringify(state?.isOnline ?? 'unknown')}
; + }; + + renderUnderChat(client, ); + + // Not coerced to false, and nothing crashed on the absent value. + expect(screen.getByTestId('v')).toHaveTextContent('"unknown"'); + vi.unstubAllGlobals(); + }); + + it('follows the socket where no platform reporter can be installed', async () => { + // What an integration that forgets to install one now gets: coarse rather than absent. The + // two facts cannot disagree under the stand-in, which is exactly why a real reporter is still + // worth installing. + vi.stubGlobal('navigator', { userAgent: 'ReactNative' }); + const client = await getTestClientWithUser({ id: 'me' }); + const Consumer = () => { + const state = useNetworkConnectionState(); + return
{String(state?.isOnline)}
; + }; + + renderUnderChat(client, ); + + // The fixture marks the socket up, and the stand-in reports that as the device's status. + expect(screen.getByTestId('v')).toHaveTextContent('true'); + + act(() => setWSConnectionStatus(client, false)); + + expect(screen.getByTestId('v')).toHaveTextContent('false'); + vi.unstubAllGlobals(); + }); + + it('re-renders when the reporter reports a change', async () => { + const client = await getTestClientWithUser({ id: 'me' }); + const platform = platformListener(); + client.config.set({ + client: { networkConnection: { statusReporter: platform.reporter } }, + }); + const Consumer = () => { + const state = useNetworkConnectionState(); + return
{String(state?.isOnline)}
; + }; + + renderUnderChat(client, ); + // Replacing the reporter does not reset the last known status — an edge is not a state — so + // this starts from what the browser reporter already reported. + expect(screen.getByTestId('v')).toHaveTextContent('true'); + + platform.report(false); + expect(screen.getByTestId('v')).toHaveTextContent('false'); + + platform.report(true); + expect(screen.getByTestId('v')).toHaveTextContent('true'); + }); + }); + + describe('useNetworkConnectionStateSelector', () => { + it('does not re-render when an unselected field changes', async () => { + const client = await getTestClientWithUser({ id: 'me' }); + const platform = platformListener(); + client.config.set({ + client: { networkConnection: { statusReporter: platform.reporter } }, + }); + const renders = vi.fn(); + const Consumer = () => { + // `lastOnlineAt` and `lastOfflineAt` also change on every report; this selects neither. + const selected = useNetworkConnectionStateSelector(({ isOnline }) => ({ + isOnline, + })); + renders(); + return
{String(selected?.isOnline)}
; + }; + + renderUnderChat(client, ); + const initial = renders.mock.calls.length; + + // `false` is a real change from the browser reporter's initial `true`. + platform.report(false); + expect(renders.mock.calls.length).toBeGreaterThan(initial); + const afterChange = renders.mock.calls.length; + + // A repeat of the same status is ignored by the observer, so nothing re-renders. + platform.report(false); + expect(renders.mock.calls.length).toBe(afterChange); + }); + }); + + describe('the two hooks together', () => { + it('distinguishes network-up/socket-down from network-down/socket-up', async () => { + // If these two are not distinguishable, the feature has not delivered its point. + const client = await getTestClientWithUser({ id: 'me' }); + const platform = platformListener(); + client.config.set({ + client: { networkConnection: { statusReporter: platform.reporter } }, + }); + const Consumer = () => { + // No aliasing needed: the socket reports `isHealthy` and the device `isOnline`, so the two + // would shadow one with the other. + const { isOnline: networkOnline } = useNetworkConnectionState() ?? {}; + const { isHealthy: socketOnline } = useWSConnectionState() ?? {}; + return ( +
{`network=${String(networkOnline)} socket=${String(socketOnline)}`}
+ ); + }; + + renderUnderChat(client, ); + // The mock client marks the socket up, so this is the interesting asymmetry: a device with no + // network while the socket still believes it is fine. + platform.report(false); + expect(screen.getByTestId('v')).toHaveTextContent('network=false socket=true'); + + // And the reverse: network fine, socket down. + platform.report(true); + act(() => { + client.wsConnection.state.partialNext({ isHealthy: false }); + }); + expect(screen.getByTestId('v')).toHaveTextContent('network=true socket=false'); + }); + }); + + describe('useWSConnectionState', () => { + it('reports the socket status', async () => { + const client = await getTestClientWithUser({ id: 'me' }); + const Consumer = () => { + const state = useWSConnectionState(); + return
{String(state?.isHealthy)}
; + }; + + renderUnderChat(client, ); + + expect(screen.getByTestId('v')).toHaveTextContent('true'); + }); + }); +}); diff --git a/src/components/Chat/hooks/useNetworkConnectionState.ts b/src/components/Chat/hooks/useNetworkConnectionState.ts new file mode 100644 index 000000000..83238b9ba --- /dev/null +++ b/src/components/Chat/hooks/useNetworkConnectionState.ts @@ -0,0 +1,50 @@ +import type { NetworkConnectionState } from 'stream-chat'; + +import { useChatContext } from '../../../context/ChatContext'; +import { useStateStore } from '../../../store'; + +const identity = (state: NetworkConnectionState) => state; + +/** + * The **device's** network status, as reported by the platform listener registered on + * `client.networkConnection`. + * + * Not the same fact as {@link useWSConnectionState}, and the difference is the point: a socket dies on + * a working 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 "you're offline"; use the WebSocket hook for + * "reconnecting…". + * + * `isOnline` has **three** states. `undefined` means *unknown* — nobody has told the client, because + * no reporter is installed or one is installed and has not reported yet. So a guard must test + * `isOnline === false`; `!isOnline` is also true when the answer is unknown and would claim "offline" + * on any host without a reporter. (The WebSocket store's `isOnline` is always a boolean, so `!` is + * fine there.) + * + * Must run under `ChatProvider`, e.g. from a child of ``. + */ +export const useNetworkConnectionState = (): NetworkConnectionState | undefined => { + 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. + * + * @example + * ```tsx + * const isOffline = useNetworkConnectionStateSelector( + * ({ isOnline }) => ({ isOffline: isOnline === false }), + * )?.isOffline; + * ``` + */ +export const useNetworkConnectionStateSelector = < + O extends Readonly | Readonly>, +>( + selector: (state: NetworkConnectionState) => O, +): O | undefined => { + const { client } = useChatContext(); + return useStateStore(client?.networkConnection.state, selector); +}; diff --git a/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts b/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts index e916f29da..439d7e876 100644 --- a/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts +++ b/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts @@ -1,54 +1,189 @@ -import { useEffect, useRef } from 'react'; -import type { EventPayload } from 'stream-chat'; +import { useCallback, useEffect, useRef } from 'react'; +import type { ConnectionType } from 'stream-chat'; import { useChatContext } from '../../../context/ChatContext'; import { useTranslationContext } from '../../../context/TranslationContext'; import { useNotificationApi } from '../../Notifications/hooks/useNotificationApi'; /** - * Publishes a persistent system notification while the client is offline and removes it when - * back online. Must run under `ChatProvider` and `TranslationProvider` (e.g. from a child of ``). + * Publishes a persistent system notification while this client cannot reach Stream, and removes it + * when it can again. Must run under `ChatProvider` and `TranslationProvider` (e.g. from a child of + * ``). + * + * **Two facts, two messages.** The device losing its network and this client's WebSocket dying are + * different things, and they disagree in both directions — a socket dies on working Wi-Fi when the + * server closes it, the token expires or a health check times out. So both are read, and the wording + * follows: + * + * - the device reports no network → the network message + * - the network is up (or unknown) and the socket is down → the reconnecting message + * + * Choosing that grouping is a copy decision rather than a fact about connectivity, which is why the + * SDK publishes no combined status and why the decision is made here, in the component that renders + * the copy. + * + * Both signals are subscribed **imperatively** rather than through the `useNetworkConnectionState` / + * `useWSConnectionState` hooks: `` calls this, and re-rendering the whole tree on every network + * flap is exactly what those hooks exist to let consumers avoid. + * + * **A drop is held before it is shown**, for + * `client.wsConnection.config.offlineNotificationDisplayDelayMs`, and dropped entirely if the socket + * returns inside that window: the socket retries on its own and most drops resolve in well under a + * second, so showing them all makes a working application look broken. The length is configuration + * rather than a constant here, so it can be changed without replacing this hook. The device's network + * is not held back — a browser reports it accurately and it does not flap the way a socket does. */ export const useReportLostConnectionSystemNotification = () => { const { t } = useTranslationContext(); const { client } = useChatContext(); const { addSystemNotification, removeNotification } = useNotificationApi(); - const connectionLostNotificationIdRef = useRef(null); + const notificationIdRef = useRef(null); + /** Outside the effect, so re-establishing the subscriptions does not republish what is showing. */ + const reasonRef = useRef(null); + /** + * The socket's status as last *shown*, which is not `client.wsConnection.state`: a drop the store + * has already published may still be inside its holding window here. + * + * Outside the effect because that re-runs whenever `t` is replaced, and re-seeding from the store + * would discard a drop still being held. Seeded once, on mount, so an application that starts up + * with no connection says so immediately rather than after the window. + */ + const socketOnlineRef = useRef(null); + /** The drop being held, so the socket returning can cancel it. */ + const heldDropRef = useRef | null>(null); + + const cancelHeldDrop = useCallback(() => { + if (heldDropRef.current === null) return; + clearTimeout(heldDropRef.current); + heldDropRef.current = null; + }, []); + + const dismissConnectionLostNotification = useCallback(() => { + if (!notificationIdRef.current) return; + removeNotification(notificationIdRef.current); + notificationIdRef.current = null; + reasonRef.current = null; + }, [removeNotification]); + + /** + * Dismissal is scoped to the mount, not to the subscriptions below. + * + * Their dependencies change for reasons that have nothing to do with the connection — `t` is + * replaced when `Streami18n.init()` resolves, asynchronously. When dismissal was part of that + * effect's cleanup, a socket dropping before init finished had its notification published and then + * immediately removed, leaving no banner on an offline app launch or behind a captive portal. + */ + useEffect( + () => () => { + cancelHeldDrop(); + dismissConnectionLostNotification(); + }, + [cancelHeldDrop, dismissConnectionLostNotification], + ); useEffect(() => { if (!t || !client) return; - const dismissConnectionLostNotification = () => { - if (!connectionLostNotificationIdRef.current) return; - removeNotification(connectionLostNotificationIdRef.current); - connectionLostNotificationIdRef.current = null; + // Keyed by `ConnectionType` rather than a local union, so a connection type added to the client + // breaks `yarn build` here (`tsconfig.lib.json`) until someone decides what the banner should say + // about it. Verified by widening the type: `Property 'sse' is missing`. Note `yarn types` does + // *not* catch it — without `strictNullChecks` the absent key is `undefined`, which is assignable + // to `string`. + const messages: Record = { + network: t('chat.reportLostConnection.waitingNetwork.text', 'Waiting for network…'), + ws: t('chat.reportLostConnection.reconnecting.text', 'Reconnecting…'), }; - const handleConnectionChanged = ({ online }: EventPayload<'connection.changed'>) => { - if (!online) { - if (connectionLostNotificationIdRef.current) return; - - connectionLostNotificationIdRef.current = addSystemNotification({ - duration: 0, - emitter: 'Chat', - message: t( - 'chat.reportLostConnection.waitingNetwork.text', - 'Waiting for network…', - ), - severity: 'loading', - type: 'system:network:connection:lost', - }); - return; - } + const show = (reason: ConnectionType) => { + if (reasonRef.current === reason) return; + // Replaced rather than left alone: a network drop while "Reconnecting…" is showing needs the + // more specific message. + dismissConnectionLostNotification(); + reasonRef.current = reason; + notificationIdRef.current = addSystemNotification({ + duration: 0, + emitter: 'Chat', + message: messages[reason], + severity: 'loading', + // One type for both messages, deliberately. Consumers filter banners on it — the SDK's own + // cookbook recipe does — so splitting it would silently stop those filters seeing the socket + // case. The `network` in the name is historical; the message is what was wrong. + type: 'system:network:connection:lost', + }); + }; + + if (socketOnlineRef.current === null) { + socketOnlineRef.current = client.wsConnection.isHealthy; + } + + // `=== false` for the network, never `!networkOnline`: `undefined` means nobody has told us, and + // on a host whose reporter cannot answer that must not read as offline. The socket's is a plain + // boolean. + let networkOnline = client.networkConnection.isOnline; + const sync = () => { + if (networkOnline === false) return show('network'); + if (!socketOnlineRef.current) return show('ws'); dismissConnectionLostNotification(); }; - const subscription = client.on('connection.changed', handleConnectionChanged); + const unsubscribeNetwork = client.networkConnection.state.subscribeWithSelector( + ({ isOnline }) => ({ isOnline }), + ({ isOnline }) => { + networkOnline = isOnline; + sync(); + }, + ); + + // `subscribeWithSelector` calls back immediately with the current value. That call is the seed + // above rather than a transition, and holding it would delay the banner on an application that + // starts up with no connection. + let seeded = false; + + const unsubscribeSocket = client.wsConnection.state.subscribeWithSelector( + ({ isHealthy }) => ({ isHealthy }), + ({ isHealthy }) => { + if (!seeded) { + seeded = true; + return; + } + + if (isHealthy) { + // Coming back is not held: there is no reason to sit on good news, and a drop still inside + // its window is cancelled rather than shown, so a brief flap produces nothing at all. + cancelHeldDrop(); + socketOnlineRef.current = true; + sync(); + return; + } + + // Already holding one. A second drop without an intervening recovery cannot happen, but a + // re-subscription during the window can, and restarting the timer would extend the wait. + if (heldDropRef.current !== null) return; + + heldDropRef.current = setTimeout(() => { + heldDropRef.current = null; + socketOnlineRef.current = false; + sync(); + // Read when the drop happens rather than captured, so a change to it reaches the next + // drop without this effect being re-established. + }, client.wsConnection.config.offlineNotificationDisplayDelayMs); + }, + ); + + // Read the current state rather than waiting for a transition — a client already offline when + // this mounts showed nothing at all before. + sync(); return () => { - subscription.unsubscribe(); - dismissConnectionLostNotification(); + unsubscribeNetwork(); + unsubscribeSocket(); }; - }, [addSystemNotification, client, removeNotification, t]); + }, [ + addSystemNotification, + cancelHeldDrop, + client, + dismissConnectionLostNotification, + t, + ]); }; diff --git a/src/components/Chat/hooks/useWSConnectionState.ts b/src/components/Chat/hooks/useWSConnectionState.ts new file mode 100644 index 000000000..2a78d88ee --- /dev/null +++ b/src/components/Chat/hooks/useWSConnectionState.ts @@ -0,0 +1,30 @@ +import type { WSConnectionState } from 'stream-chat'; + +import { useChatContext } from '../../../context/ChatContext'; +import { useStateStore } from '../../../store'; + +const identity = (state: WSConnectionState) => state; + +/** + * This client's **WebSocket** status — whether the realtime connection is up, and the connection id + * the server keys channel watches by. + * + * Not the device's network: see {@link useNetworkConnectionState}. Use this for "reconnecting…", for + * disabling a composer, or for anything that needs the realtime connection specifically. + * + * The store is written on **every** transition, including `client.closeConnection()` — the documented + * mobile backgrounding path — and it publishes a drop the moment it happens. If you are rendering a + * "connection lost" banner, hold a drop for `client.wsConnection.config.offlineNotificationDisplayDelayMs` before showing it and + * cancel it if the socket returns inside that window, which is what `` does: the socket retries + * on its own, and most drops resolve in well under a second. + * + * The connection id is not here: it lives on `client.connectionIdManager`, which holds any request + * that watches or subscribes to presence until one exists. Its `isHealthy` is always a boolean, unlike the + * network store's three-state `isOnline`. + * + * Must run under `ChatProvider`, e.g. from a child of ``. + */ +export const useWSConnectionState = (): WSConnectionState | undefined => { + const { client } = useChatContext(); + return useStateStore(client?.wsConnection.state, identity); +}; diff --git a/src/components/Chat/index.ts b/src/components/Chat/index.ts index 68e891507..e27bc6270 100644 --- a/src/components/Chat/index.ts +++ b/src/components/Chat/index.ts @@ -2,3 +2,5 @@ export * from './Chat'; export * from './hooks/useChat'; export * from './hooks/useReportLostConnectionSystemNotification'; export * from './hooks/useCreateChatClient'; +export * from './hooks/useNetworkConnectionState'; +export * from './hooks/useWSConnectionState'; diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json index 220a3d00f..fcf793aa0 100644 --- a/src/i18n/__tests__/catalog.fixture.json +++ b/src/i18n/__tests__/catalog.fixture.json @@ -183,6 +183,7 @@ "channelListItem.video.ariaLabel": "video", "channelListItem.voiceMessage.ariaLabel": "voice message", "channelListItem.voted.text": "📊 {{votedBy}} voted: {{pollOptionText}}", + "chat.reportLostConnection.reconnecting.text": "Reconnecting…", "chat.reportLostConnection.waitingNetwork.text": "Waiting for network…", "command.ban.args": "[@username] [text]", "command.ban.description": "Ban a user", diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index fb5a4730f..a83d9be02 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -194,6 +194,7 @@ export type TranslationCatalog = { 'channelListItem.video.ariaLabel': 'video'; 'channelListItem.voiceMessage.ariaLabel': 'voice message'; 'channelListItem.voted.text': '📊 {{votedBy}} voted: {{pollOptionText}}'; + 'chat.reportLostConnection.reconnecting.text': 'Reconnecting…'; 'chat.reportLostConnection.waitingNetwork.text': 'Waiting for network…'; 'command.ban.args': '[@username] [text]'; 'command.ban.description': 'Ban a user'; diff --git a/src/mock-builders/event/connectionChanged.ts b/src/mock-builders/event/connectionChanged.ts deleted file mode 100644 index 7bc01e698..000000000 --- a/src/mock-builders/event/connectionChanged.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { fromPartial } from '@total-typescript/shoehorn'; -import type { Event, StreamChat } from 'stream-chat'; - -export default (client: StreamChat, online: boolean) => { - client.dispatchEvent( - fromPartial({ - online, - type: 'connection.changed', - }), - ); -}; diff --git a/src/mock-builders/event/connectionRecovered.ts b/src/mock-builders/event/connectionRecovered.ts index a311ff7b6..75359cfb9 100644 --- a/src/mock-builders/event/connectionRecovered.ts +++ b/src/mock-builders/event/connectionRecovered.ts @@ -1,9 +1,16 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { Event, StreamChat } from 'stream-chat'; +import type { ConnectionType, Event, StreamChat } from 'stream-chat'; -export default (client: StreamChat) => { +/** + * Dispatches `connection.recovered`. + * + * `connection` defaults to `'ws'`, which is also the only value the client actually dispatches today — + * recovery is about the socket being back and its watches re-established. + */ +export default (client: StreamChat, connection: ConnectionType = 'ws') => { client.dispatchEvent( fromPartial({ + connection, type: 'connection.recovered', }), ); diff --git a/src/mock-builders/event/connectionStatus.ts b/src/mock-builders/event/connectionStatus.ts new file mode 100644 index 000000000..9dba9f3d2 --- /dev/null +++ b/src/mock-builders/event/connectionStatus.ts @@ -0,0 +1,36 @@ +import type { StreamChat } from 'stream-chat'; + +/** + * Drives this client's WebSocket status, the way a real socket coming up or going down would. + * + * Connectivity is published as `client.wsConnection.state` and `client.networkConnection.state`, so + * driving a test means writing the store. + * + * Through the public store rather than the socket's internal `_setStatus`, which + * `no-underscore-dangle` rightly rejects. Timestamps are stamped too, since a status without one is a + * state the real socket never produces. + * + * Anything reading this through the `` banner holds a drop for + * `client.wsConnection.config.offlineNotificationDisplayDelayMs` before showing it, so a test + * asserting on that banner has to advance timers. + */ +export const setWSConnectionStatus = ( + client: StreamChat, + isHealthy: boolean, + connectionId = 'mock-connection-id', +) => { + client.wsConnection.state.partialNext( + isHealthy + ? { isHealthy, lastHealthyAt: new Date() } + : { isHealthy, lastUnhealthyAt: new Date() }, + ); + // The id is separate state with its own lifecycle: published when the socket announces itself, + // dropped when it goes down so no request carries one the server has closed. + if (isHealthy) client.connectionIdManager.resolveConnectionId(connectionId); + else client.connectionIdManager.invalidate(); +}; + +/** Reports the device's network status, as a platform reporter would. */ +export const setNetworkStatus = (client: StreamChat, isOnline: boolean) => { + client.networkConnection.setStatus(isOnline); +}; diff --git a/src/mock-builders/event/index.ts b/src/mock-builders/event/index.ts index f6a118519..934339b1e 100644 --- a/src/mock-builders/event/index.ts +++ b/src/mock-builders/event/index.ts @@ -3,7 +3,7 @@ export { default as dispatchChannelHiddenEvent } from './channelHidden'; export { default as dispatchChannelTruncatedEvent } from './channelTruncated'; export { default as dispatchChannelUpdatedEvent } from './channelUpdated'; export { default as dispatchChannelVisibleEvent } from './channelVisible'; -export { default as dispatchConnectionChangedEvent } from './connectionChanged'; +export { setNetworkStatus, setWSConnectionStatus } from './connectionStatus'; export { default as dispatchConnectionRecoveredEvent } from './connectionRecovered'; export { default as dispatchMessageDeletedEvent } from './messageDeleted'; export { default as dispatchMessageNewEvent } from './messageNew'; diff --git a/src/mock-builders/index.ts b/src/mock-builders/index.ts index 0e1810d7f..80260c47e 100644 --- a/src/mock-builders/index.ts +++ b/src/mock-builders/index.ts @@ -8,12 +8,28 @@ const token = 'dummy_token'; const connectUser = (client: StreamChat, user: Partial) => new Promise((resolve) => { - client['connectionId'] = 'dumm_connection_id'; + // Mark the socket up, which is what "connected" means to the client: `channel.watch()` and + // `client.queryChannels()` wait for a live connection instead of degrading to `watch: false`. + // + // Written through the public store rather than the socket's internal `_setStatus`, which + // `no-underscore-dangle` rightly rejects — and a fixture standing in for a connection it never + // opens is exactly the case for setting the state directly. `lastHealthyAt` is stamped too, since + // online-without-a-timestamp is a state the real socket never produces. + // + client.wsConnection.state.partialNext({ + isHealthy: true, + lastHealthyAt: new Date(), + }); + // The id lives on its own manager, and the request layer holds any watching request until one + // exists — so a fixture that omits this makes every mocked `watch()` wait forever. + client.connectionIdManager.resolveConnectionId('dummy_connection_id'); client.user = { ...user, mutes: [] } as UserResponse; client['_user'] = { ...user } as UserResponse; // `userID` is a getter in v10 (derives from `client.user?.id`), so it can't be assigned; // setting `client.user` above is what populates it. - client['userToken'] = token; + // `userToken` was never a field on `StreamChat` — only `userTokenOrProvider`, a parameter — so + // this assignment wrote a property nothing reads. The `tokenManager` mock below is what actually + // supplies the token. client.wsPromise = Promise.resolve() as StreamChat['wsPromise']; resolve(); }); diff --git a/yarn.lock b/yarn.lock index ddfc7253d..67aba0577 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1861,7 +1861,7 @@ __metadata: emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" - stream-chat: "npm:10.0.0-rc.11" + stream-chat: "npm:^10.0.0-rc.12" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -1889,7 +1889,7 @@ __metadata: react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:10.0.0-rc.11" + stream-chat: "npm:^10.0.0-rc.12" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -9551,7 +9551,7 @@ __metadata: remark-parse: "npm:^11.0.0" sass: "npm:^1.100.0" semantic-release: "npm:^25.0.3" - stream-chat: "npm:10.0.0-rc.11" + stream-chat: "npm:^10.0.0-rc.12" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" @@ -9569,7 +9569,7 @@ __metadata: modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 - stream-chat: ^10.0.0-rc.10 + stream-chat: ^10.0.0-rc.12 dependenciesMeta: "@parcel/watcher": built: true @@ -9595,9 +9595,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" @@ -9608,7 +9608,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/681c8dd92559843474ea26c12ea3e07b23dda254bfbc82295f1db326b210e2064d2911e8315bcce78b0606ef836c34ab890918aed0e831efb903dac8f25b1721 + checksum: 10c0/908d8cdd440481cb84cf903eb0b7e840ca59615e95216a850765896433a7bcb6d438edfeccbe4ee601e4836dfe964e1c43ec7cbca9444cd980ebf9f4517b5d91 languageName: node linkType: hard