Skip to content

Commit 7983b49

Browse files
committed
feat!: report network connection loss as a system notifications
- add addSystemNotification function to the useNotificationApi interface BREAKING CHANGE: - Removed ConnectionStatus component. Connection notification UI is app responsibility now.
1 parent 32fdf4c commit 7983b49

36 files changed

Lines changed: 599 additions & 129 deletions

examples/vite/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
getSelectedChatViewFromUrl,
5050
} from './ChatLayout/Sync.tsx';
5151
import { LoadingScreen } from './LoadingScreen/LoadingScreen.tsx';
52+
import { SystemNotification } from './SystemNotification/SystemNotification.tsx';
5253
import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx';
5354
import {
5455
CustomAttachmentActions,
@@ -377,6 +378,7 @@ const App = () => {
377378
style={initialAppLayoutStyle}
378379
data-variant={messageUiVariant ?? undefined}
379380
>
381+
<SystemNotification />
380382
<PanelLayoutStyleSync layoutRef={appLayoutRef} />
381383
<ChatViewSelectorWidthSync
382384
iconOnly={chatView.iconOnly}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
.str-chat__system-notification-anchor {
2+
position: relative;
3+
}
4+
5+
.str-chat__system-notification {
6+
display: flex;
7+
align-items: center;
8+
justify-content: center;
9+
gap: var(--spacing-xs, 8px);
10+
padding-block: var(--spacing-xs, 8px);
11+
padding-inline: var(--spacing-sm, 12px);
12+
background: var(--background-core-surface-default, #ebeef1);
13+
color: var(--chat-text-system, #414552);
14+
font-feature-settings:
15+
'liga' off,
16+
'clig' off;
17+
font-size: var(--typography-font-size-xs, 12px);
18+
font-style: normal;
19+
font-weight: var(--typography-font-weight-semi-bold, 600);
20+
line-height: var(--typography-line-height-tight, 16px);
21+
animation: str-chat__system-notification-slide-in 300ms ease-out both;
22+
overflow: hidden;
23+
position: fixed;
24+
inset-block-start: 0;
25+
inset-inline: 0;
26+
z-index: 2;
27+
}
28+
29+
.str-chat__system-notification--exiting {
30+
animation: str-chat__system-notification-slide-out 300ms ease-in both;
31+
}
32+
33+
.str-chat__system-notification--interactive {
34+
cursor: pointer;
35+
}
36+
37+
.str-chat__system-notification-icon {
38+
align-items: center;
39+
display: inline-flex;
40+
flex-shrink: 0;
41+
}
42+
43+
.str-chat__system-notification-message {
44+
white-space: nowrap;
45+
overflow-y: visible;
46+
overflow-x: hidden;
47+
overflow-x: clip;
48+
text-overflow: ellipsis;
49+
}
50+
51+
.str-chat__system-notification--loading .str-chat__system-notification-icon {
52+
animation: str-chat__system-notification-spin 1.5s linear infinite;
53+
}
54+
55+
@keyframes str-chat__system-notification-slide-in {
56+
from {
57+
max-height: 0;
58+
opacity: 0;
59+
padding-block: 0;
60+
}
61+
62+
to {
63+
max-height: 4rem;
64+
opacity: 1;
65+
}
66+
}
67+
68+
@keyframes str-chat__system-notification-slide-out {
69+
from {
70+
max-height: 4rem;
71+
opacity: 1;
72+
}
73+
74+
to {
75+
max-height: 0;
76+
opacity: 0;
77+
padding-block: 0;
78+
}
79+
}
80+
81+
@keyframes str-chat__system-notification-spin {
82+
from {
83+
transform: rotate(0deg);
84+
}
85+
86+
to {
87+
transform: rotate(360deg);
88+
}
89+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { type ComponentType, useEffect, useState } from 'react';
2+
import clsx from 'clsx';
3+
import type { Notification, NotificationSeverity } from 'stream-chat';
4+
5+
import {
6+
IconCheckmark,
7+
IconExclamationCircleFill,
8+
IconExclamationTriangleFill,
9+
IconInfoCircle,
10+
IconLoading,
11+
useSystemNotifications,
12+
} from 'stream-chat-react';
13+
14+
const IconsBySeverity: Record<NotificationSeverity, ComponentType | null> = {
15+
error: IconExclamationCircleFill,
16+
info: IconInfoCircle,
17+
loading: IconLoading,
18+
success: IconCheckmark,
19+
warning: IconExclamationTriangleFill,
20+
};
21+
22+
type SystemNotificationFilter = (notification: Notification) => boolean;
23+
24+
export type SystemNotificationProps = {
25+
/** Optional class name for the container */
26+
className?: string;
27+
/** Optional additional filter applied after the default system-tag filter. */
28+
filter?: SystemNotificationFilter;
29+
};
30+
31+
const SLIDE_OUT_ANIMATION_NAME = 'str-chat__system-notification-slide-out';
32+
33+
export const SystemNotification = ({ className, filter }: SystemNotificationProps) => {
34+
const notifications = useSystemNotifications(filter ? { filter } : undefined);
35+
const notification = notifications[0];
36+
37+
const [retainedNotification, setRetainedNotification] = useState<
38+
Notification | undefined
39+
>(notification);
40+
41+
useEffect(() => {
42+
if (notification) {
43+
setRetainedNotification(notification);
44+
}
45+
}, [notification]);
46+
47+
const isExiting = !notification && !!retainedNotification;
48+
const rendered = notification ?? retainedNotification;
49+
50+
if (!rendered) return null;
51+
52+
const Icon = rendered.severity
53+
? (IconsBySeverity[rendered.severity] ?? null)
54+
: IconExclamationCircleFill;
55+
const action = rendered.actions?.[0];
56+
57+
return (
58+
<div className='str-chat__system-notification-anchor'>
59+
<div
60+
aria-live='polite'
61+
className={clsx(
62+
'str-chat__system-notification',
63+
{
64+
'str-chat__system-notification--exiting': isExiting,
65+
'str-chat__system-notification--interactive': action,
66+
[`str-chat__system-notification--${rendered.severity}`]: rendered.severity,
67+
},
68+
className,
69+
)}
70+
data-testid='system-notification'
71+
onAnimationEnd={(e) => {
72+
if (e.animationName === SLIDE_OUT_ANIMATION_NAME) {
73+
setRetainedNotification(undefined);
74+
}
75+
}}
76+
onClick={action?.handler}
77+
onKeyDown={
78+
action
79+
? (event) => {
80+
if (event.key === 'Enter' || event.key === ' ') {
81+
event.preventDefault();
82+
action.handler();
83+
}
84+
}
85+
: undefined
86+
}
87+
role={action ? 'button' : 'status'}
88+
tabIndex={action ? 0 : undefined}
89+
>
90+
{Icon && (
91+
<span aria-hidden className='str-chat__system-notification-icon'>
92+
<Icon />
93+
</span>
94+
)}
95+
<span className='str-chat__system-notification-message'>{rendered.message}</span>
96+
</div>
97+
</div>
98+
);
99+
};

examples/vite/src/index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// v3 CSS import
44
@import url('stream-chat-react/dist/css/index.css') layer(stream-new);
55
@import url('./AppSettings/AppSettings.scss') layer(stream-app-overrides);
6+
@import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides);
67

78
:root {
89
font-synthesis: none;

src/components/Attachment/__tests__/Audio.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ vi.mock('../../../context/TranslationContext', () => ({
2323
useTranslationContext: () => ({ t: (s) => tSpy(s) }),
2424
}));
2525
vi.mock('../../Notifications', () => ({
26-
useNotificationApi: () => ({ addNotification: addNotificationSpy }),
26+
useNotificationApi: () => ({
27+
addNotification: addNotificationSpy,
28+
addSystemNotification: vi.fn(),
29+
}),
2730
useNotificationTarget: () => 'channel',
2831
}));
2932

src/components/Attachment/__tests__/VoiceRecording.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import { ResizeObserverMock } from '../../../mock-builders/browser';
1414
import { WithAudioPlayback } from '../../AudioPlayback';
1515

1616
vi.mock('../../Notifications', () => ({
17-
useNotificationApi: () => ({ addNotification: vi.fn() }),
17+
useNotificationApi: () => ({
18+
addNotification: vi.fn(),
19+
addSystemNotification: vi.fn(),
20+
}),
1821
useNotificationTarget: () => 'channel',
1922
}));
2023

src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ vi.mock('../../Notifications', async (importOriginal: any) => ({
2323
...(await importOriginal()),
2424
useNotificationApi: () => ({
2525
addNotification: mockAddNotification,
26+
addSystemNotification: vi.fn(),
2627
}),
2728
useNotificationTarget: () => 'channel',
2829
}));

src/components/Chat/Chat.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
1+
import type { PropsWithChildren } from 'react';
12
import React, { useMemo } from 'react';
3+
import type { StreamChat } from 'stream-chat';
24
import {
35
ChannelSearchSource,
46
MessageSearchSource,
57
SearchController,
68
UserSearchSource,
79
} from 'stream-chat';
8-
import type { PropsWithChildren } from 'react';
9-
import type { StreamChat } from 'stream-chat';
1010

1111
import { useChat } from './hooks/useChat';
12+
import { useReportLostConnectionSystemNotification } from './hooks/useReportLostConnectionSystemNotification';
1213
import { useCreateChatContext } from './hooks/useCreateChatContext';
1314
import { useChannelsQueryState } from './hooks/useChannelsQueryState';
15+
import type { CustomClasses } from '../../context/ChatContext';
1416
import { ChatProvider } from '../../context/ChatContext';
1517
import { TranslationProvider } from '../../context/TranslationContext';
16-
import type { CustomClasses } from '../../context/ChatContext';
1718
import { type MessageContextValue, ModalDialogManagerProvider } from '../../context';
1819
import type { SupportedTranslations } from '../../i18n/types';
1920
import type { Streami18n } from '../../i18n/Streami18n';
2021

22+
const NetworkConnectionNotificationReporter = () => {
23+
useReportLostConnectionSystemNotification();
24+
return null;
25+
};
26+
2127
export type ChatProps = {
2228
/** The StreamChat client object */
2329
client: StreamChat;
@@ -80,6 +86,7 @@ export const Chat = (props: PropsWithChildren<ChatProps>) => {
8086
initialNavOpen,
8187
});
8288

89+
useReportLostConnectionSystemNotification();
8390
const channelsQueryState = useChannelsQueryState();
8491

8592
const searchController = useMemo(
@@ -118,7 +125,10 @@ export const Chat = (props: PropsWithChildren<ChatProps>) => {
118125
return (
119126
<ChatProvider value={chatContextValue}>
120127
<TranslationProvider value={translators}>
121-
<ModalDialogManagerProvider>{children}</ModalDialogManagerProvider>
128+
<ModalDialogManagerProvider>
129+
<NetworkConnectionNotificationReporter />
130+
{children}
131+
</ModalDialogManagerProvider>
122132
</TranslationProvider>
123133
</ChatProvider>
124134
);

src/components/Chat/__tests__/Chat.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { ChatContextValue } from '../../../context';
1010
import { Streami18n } from '../../../i18n';
1111
import type { Mute } from 'stream-chat';
1212
import {
13+
dispatchConnectionChangedEvent,
1314
dispatchNotificationMutesUpdated,
1415
getTestClient,
1516
getTestClientWithUser,
@@ -306,6 +307,41 @@ describe('Chat', () => {
306307
});
307308
});
308309

310+
describe('connection notifications', () => {
311+
it('publishes and removes system connection-lost notification on connection changes', async () => {
312+
const client = getTestClient();
313+
let connectionLostNotification;
314+
315+
render(
316+
<Chat client={client}>
317+
<div data-testid='children' />
318+
</Chat>,
319+
);
320+
321+
expect(client.notifications.notifications).toHaveLength(0);
322+
323+
act(() => dispatchConnectionChangedEvent(client, false));
324+
await waitFor(() => {
325+
connectionLostNotification = client.notifications.notifications.find(
326+
(notification) => notification.origin.emitter === 'Chat',
327+
);
328+
expect(connectionLostNotification).toBeDefined();
329+
});
330+
331+
expect(connectionLostNotification.message).toBe('Waiting for network…');
332+
expect(connectionLostNotification.tags).toEqual(['system']);
333+
334+
act(() => dispatchConnectionChangedEvent(client, true));
335+
await waitFor(() => {
336+
expect(
337+
client.notifications.notifications.find(
338+
(notification) => notification.origin.emitter === 'Chat',
339+
),
340+
).toBeUndefined();
341+
});
342+
});
343+
});
344+
309345
describe('translation context', () => {
310346
it('should expose the context', async () => {
311347
let context: ChatContextValue;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
import { useChatContext } from '../../../context/ChatContext';
4+
import { useTranslationContext } from '../../../context/TranslationContext';
5+
import { useNotificationApi } from '../../Notifications/hooks/useNotificationApi';
6+
7+
/**
8+
* Publishes a persistent system notification while the client is offline and removes it when
9+
* back online. Must run under `ChatProvider` and `TranslationProvider` (e.g. from a child of `<Chat>`).
10+
*/
11+
export const useReportLostConnectionSystemNotification = () => {
12+
const { t } = useTranslationContext();
13+
const { client } = useChatContext();
14+
const { addSystemNotification, removeNotification } = useNotificationApi();
15+
const connectionLostNotificationIdRef = useRef<string | null>(null);
16+
17+
useEffect(() => {
18+
if (!t || !client) return;
19+
20+
const dismissConnectionLostNotification = () => {
21+
if (!connectionLostNotificationIdRef.current) return;
22+
removeNotification(connectionLostNotificationIdRef.current);
23+
connectionLostNotificationIdRef.current = null;
24+
};
25+
26+
const handleConnectionChanged = ({ online }: { online?: boolean }) => {
27+
if (!online) {
28+
if (connectionLostNotificationIdRef.current) return;
29+
30+
connectionLostNotificationIdRef.current = addSystemNotification({
31+
duration: 0,
32+
emitter: 'Chat',
33+
message: t('Waiting for network…'),
34+
severity: 'loading',
35+
type: 'system:network:connection:lost',
36+
});
37+
return;
38+
}
39+
40+
dismissConnectionLostNotification();
41+
};
42+
43+
client.on('connection.changed', handleConnectionChanged);
44+
45+
return () => {
46+
client.off('connection.changed', handleConnectionChanged);
47+
dismissConnectionLostNotification();
48+
};
49+
}, [addSystemNotification, client, removeNotification, t]);
50+
};

0 commit comments

Comments
 (0)