Skip to content
5 changes: 5 additions & 0 deletions examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,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 {
Expand Down Expand Up @@ -263,6 +264,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(), []);
Expand Down Expand Up @@ -546,6 +550,7 @@ const App = () => {
ref={appLayoutRef}
style={initialAppLayoutStyle}
>
{connectionPanelVisible && <ConnectionDevPanel />}
<SystemNotification />
<div className='app-chat-layout__body'>
<PanelLayoutStyleSync layoutRef={appLayoutRef} />
Expand Down
26 changes: 26 additions & 0 deletions examples/vite/src/AppSettings/AppSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ReactionsTab } from './tabs/Reactions';
import { SidebarTab } from './tabs/Sidebar';
import { appSettingsStore, useAppSettingsState } from './state';
import {
IconConnection,
IconGear,
IconMoon,
IconSidebar,
Expand Down Expand Up @@ -159,6 +160,30 @@ const SidebarThemeToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => {
);
};

const SidebarConnectionPanelToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => {
const { devTools } = useAppSettingsState();
const { connectionPanel } = devTools;

return (
<ChatViewSelectorButton
aria-checked={connectionPanel}
aria-label={`${connectionPanel ? 'Hide' : 'Show'} the connection dev panel`}
aria-selected={connectionPanel}
className='app__settings-group_button app__settings-group_button--toggle'
Icon={IconConnection}
iconOnly={iconOnly}
isActive={connectionPanel}
onClick={() =>
appSettingsStore.partialNext({
devTools: { ...devTools, connectionPanel: !connectionPanel },
})
}
role='switch'
text={connectionPanel ? 'Hide connection panel' : 'Connection panel'}
/>
);
};

const SidebarRtlToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => {
const {
theme,
Expand Down Expand Up @@ -203,6 +228,7 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => {

return (
<div className='app__settings-group'>
<SidebarConnectionPanelToggle iconOnly={iconOnly} />
<SidebarRtlToggle iconOnly={iconOnly} />
<SidebarThemeToggle iconOnly={iconOnly} />
<ActionsMenu iconOnly={iconOnly} />
Expand Down
10 changes: 10 additions & 0 deletions examples/vite/src/AppSettings/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,6 +91,7 @@ export type LayoutSettingsState = {
export type AppSettingsState = {
channelDetail: ChannelDetailSettingsState;
chatView: ChatViewSettingsState;
devTools: DevToolsSettingsState;
language: LanguageSettingsState;
layout: LayoutSettingsState;
messageActions: MessageActionsSettingsState;
Expand Down Expand Up @@ -113,6 +120,9 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;

const defaultAppSettingsState: AppSettingsState = {
devTools: {
connectionPanel: false,
},
channelDetail: {
modal: {
channelMembersView: {
Expand Down
52 changes: 52 additions & 0 deletions examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
100 changes: 100 additions & 0 deletions examples/vite/src/ConnectionDevPanel/ConnectionDevPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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` *and* dispatches `connection.changed`, which is what the real socket
* does — the store carries the raw state, the event is the announcement. 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, and the one thing that does hold — `client.closeConnection()` —
* deliberately dispatches no event, so no banner appears.
*
* The one thing this cannot show is the five-second delay the real event carries on the way down.
*
* For the genuine path, including the five-second announce delay on the way down, 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 { isOnline: socketOnline, connectionId } = useWSConnectionState() ?? {};

if (!client) return null;

return (
<aside className='connection-dev-panel'>
<span className='connection-dev-panel__label'>connection</span>

<button
aria-checked={networkOnline === false}
className='connection-dev-panel__toggle'
// `=== false`, never `!networkOnline`: `undefined` means no registrar has reported, and
// unknown must not render as offline.
data-state={String(networkOnline)}
onClick={() => client.networkConnection.setStatus(networkOnline === false)}
role='switch'
title='client.networkConnection — the device network, via setStatus()'
type='button'
>
network: {String(networkOnline)}
<span className='connection-dev-panel__action'>
{networkOnline === false ? 'bring online' : 'take offline'}
</span>
</button>

<button
aria-checked={!socketOnline}
className='connection-dev-panel__toggle'
data-state={String(socketOnline)}
onClick={() => {
const online = !socketOnline;
// Both channels, because the socket writes both and they carry different things: the
// store is the raw state (what this button reads, and what `isOnline` getters answer
// from), while the event is the announcement the banner listens to. Writing only the
// event left the store — and so this label — stuck on its old value, which is why the
// toggle could not be switched back.
client.wsConnection.state.partialNext({
isOnline: online,
...(online ? { lastOnlineAt: new Date() } : { lastOfflineAt: new Date() }),
});
client.dispatchEvent({
connection: 'ws',
online,
type: 'connection.changed',
});
}}
role='switch'
title={`client.wsConnection — this client's socket. connection id: ${String(connectionId)}`}
type='button'
>
socket: {String(socketOnline)}
<span className='connection-dev-panel__action'>
{socketOnline ? 'take down' : 'bring up'}
</span>
</button>

<span className='connection-dev-panel__hint'>
network offline wins the copy; socket down alone reads as reconnecting
</span>
</aside>
);
};
1 change: 1 addition & 0 deletions examples/vite/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,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',
Expand Down
1 change: 1 addition & 0 deletions examples/vite/src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,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',
Expand Down
13 changes: 13 additions & 0 deletions examples/vite/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
<path
d='M3.75 8.125C6.25 5.625 13.75 5.625 16.25 8.125M6.5 10.875C8.125 9.25 11.875 9.25 13.5 10.875M10 14.375H10.0083'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='1.5'
/>,
);

export const IconGear = createIcon(
'IconGear',
<path d='M13.5667 8.00031C13.5667 7.81566 13.4743 7.64281 13.3206 7.54034L12.7503 7.16046C12.0815 6.71442 11.7583 5.90079 11.9388 5.11749L12.0472 4.64777C12.092 4.45315 12.034 4.24906 11.8929 4.10773C11.7515 3.96629 11.5467 3.90753 11.3519 3.95245L10.8831 4.06085C10.0996 4.24168 9.28525 3.91849 8.8392 3.24933L8.45932 2.67902C8.35689 2.52558 8.18474 2.43303 8.00034 2.43292C7.81575 2.43292 7.64286 2.5254 7.54037 2.67902L7.16049 3.24933C6.7145 3.91832 5.90097 4.24146 5.11752 4.06085L4.6478 3.95245C4.45304 3.90767 4.24908 3.9664 4.10776 4.10773C3.96643 4.24905 3.9077 4.45301 3.95248 4.64777L4.06088 5.11749C4.24149 5.90094 3.91835 6.71447 3.24936 7.16046L2.67905 7.54034C2.52543 7.64282 2.43295 7.81572 2.43295 8.00031C2.43306 8.18471 2.52561 8.35686 2.67905 8.45929L3.24936 8.83917C3.91852 9.28522 4.24171 10.0995 4.06088 10.8831L3.95248 11.3519C3.90756 11.5467 3.96632 11.7514 4.10776 11.8929C4.24909 12.034 4.45317 12.092 4.6478 12.0472L5.11752 11.9388C5.90082 11.7583 6.71445 12.0814 7.16049 12.7503L7.54037 13.3206C7.64284 13.4743 7.81569 13.5667 8.00034 13.5667C8.18484 13.5666 8.35691 13.4742 8.45932 13.3206L8.8392 12.7503C9.2574 12.123 9.99909 11.8004 10.7357 11.9114L10.8831 11.9388L11.3519 12.0472C11.5467 12.0922 11.7505 12.0334 11.8919 11.8919C12.0334 11.7504 12.0922 11.5467 12.0472 11.3519L11.9388 10.8831C11.7581 10.0996 12.0812 9.28525 12.7503 8.83917L13.3206 8.45929C13.4742 8.35688 13.5666 8.18481 13.5667 8.00031ZM9.23373 8.00031C9.23373 7.31925 8.68135 6.76708 8.00034 6.76691C7.31917 6.76691 6.76694 7.31914 6.76694 8.00031C6.76711 8.68132 7.31928 9.2337 8.00034 9.2337C8.68124 9.23353 9.23356 8.68121 9.23373 8.00031ZM10.433 8.00031C10.4328 9.34395 9.34398 10.4327 8.00034 10.4329C6.65654 10.4329 5.56692 9.34406 5.56674 8.00031C5.56674 6.6564 6.65643 5.56671 8.00034 5.56671C9.34409 5.56689 10.433 6.65651 10.433 8.00031ZM14.7669 8.00031C14.7668 8.58607 14.474 9.13337 13.9867 9.45831L13.4164 9.8382C13.1627 10.0072 13.0404 10.3155 13.1087 10.6126L13.2171 11.0823C13.355 11.6803 13.1744 12.3067 12.7406 12.7405C12.3067 13.1744 11.6803 13.355 11.0824 13.2171L10.6126 13.1087C10.3155 13.0403 10.0073 13.1627 9.83823 13.4163L9.45834 13.9866C9.1334 14.4739 8.5861 14.7668 8.00034 14.7669C7.41454 14.7669 6.86735 14.4739 6.54233 13.9866L6.16245 13.4163C5.99328 13.1626 5.6843 13.0402 5.38705 13.1087L4.9183 13.2171C4.32022 13.3552 3.69313 13.1746 3.25912 12.7405C2.82535 12.3066 2.64568 11.6802 2.78354 11.0823L2.89194 10.6126C2.96027 10.3155 2.83695 10.0072 2.58334 9.8382L2.01401 9.45831C1.52667 9.13338 1.23384 8.58608 1.23373 8.00031C1.23373 7.4144 1.52657 6.86729 2.01401 6.5423L2.58334 6.16241C2.83709 5.99325 2.96042 5.68417 2.89194 5.38702L2.78354 4.91827C2.64553 4.32022 2.82513 3.69309 3.25912 3.25909C3.69312 2.8251 4.32025 2.6455 4.9183 2.78351L5.38705 2.89191C5.6842 2.96039 5.99328 2.83706 6.16245 2.58331L6.54233 2.01398C6.86732 1.52654 7.41443 1.2337 8.00034 1.2337C8.58611 1.23381 9.13341 1.52663 9.45834 2.01398L9.83823 2.58331C10.0073 2.83692 10.3156 2.96024 10.6126 2.89191L11.0824 2.78351C11.6803 2.64565 12.3067 2.82532 12.7406 3.25909C13.1746 3.6931 13.3552 4.3202 13.2171 4.91827L13.1087 5.38702C13.0402 5.68427 13.1626 5.99325 13.4164 6.16241L13.9867 6.5423C14.4739 6.86732 14.7669 7.41451 14.7669 8.00031Z' />,
Expand Down
33 changes: 0 additions & 33 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ const ChannelInner = (
const jumpToMessageFromSearch = useSearchFocusedMessage();

const originalTitle = useRef('');
const online = useRef(true);

const clearSearchFocusedMessageTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(
null,
Expand All @@ -136,7 +135,6 @@ const ChannelInner = (
!channel.initialized && initializeOnMount,
);

// todo: can we remove this big event handler and keep only relevant UI-only logic (e.g. 'connection.recovered')?
const handleEvent = async (event: Event) => {
// ignore the event if it is not targeted at the current channel.
// Event targeted at this channel or globally targeted event should lead to state refresh
Expand All @@ -146,33 +144,6 @@ const ChannelInner = (
if (event.type === 'user.watching.start' || event.type === 'user.watching.stop')
return;

if (event.type === 'connection.changed' && typeof event.online === 'boolean') {
online.current = event.online;
}

if (event.type === 'connection.recovered') {
// Refresh the loaded message window ourselves. The client's reconnect hydration deliberately
// skips re-seeding the message list of an `active` channel (we mark this one active while
// mounted) because its 25-message page would perturb a larger scrolled-back window — it hands
// that job to `channel.reload()`, which re-watches sized to the loaded window instead. Nothing
// calls it for us, so without this the list stays stale after a reconnect and hard deletes that
// happened while offline are never reconciled (they arrive via no event; only a re-query
// surfaces them). This is deliberately the SDK's opinion about how the default component
// behaves, not client-level policy.
//
// `recoverState` dispatches this only after re-querying the active channels, so the rest of the
// channel state is already fresh by now.
if (channel.pendingDisposal) return;
try {
await channel.reload();
} catch (error) {
// The socket can flap straight back down mid-reload. Keep the previously loaded window
// rather than tearing the view down — the next recovery re-runs this.
console.warn('Failed to reload the channel after connection recovery', error);
}
return;
}

if (event.type === 'message.new') {
const mainChannelUpdated =
!event.message?.parent_id || event.message?.show_in_channel;
Expand Down Expand Up @@ -306,8 +277,6 @@ const ChannelInner = (
}

// The more complex sync logic is done in Chat
client.on('connection.changed', handleEvent);
client.on('connection.recovered', handleEvent);
client.on('user.updated', handleEvent);
client.on('user.deleted', handleEvent);
client.on('user.messages.deleted', handleEvent);
Expand All @@ -318,8 +287,6 @@ const ChannelInner = (
isMounted = false;
if (errored || !done) return;
channel?.off(handleEvent);
client.off('connection.changed', handleEvent);
client.off('connection.recovered', handleEvent);
client.off('user.deleted', handleEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
Loading
Loading