Skip to content

Commit 396b0cf

Browse files
me-andrevalentinyanakievccanos
authored
Timer-based reconnection for Realtime Whiteboards (#5595)
* Actual timer-based reconnection for RT Whiteboards * useTick() uses RAF --------- Co-authored-by: Valentin Yanakiev <[email protected]> Co-authored-by: Carlos Cano <[email protected]>
1 parent b40b496 commit 396b0cf

File tree

4 files changed

+188
-16
lines changed

4 files changed

+188
-16
lines changed

src/core/utils/onlineStatus.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useEffect, useState } from 'react';
2+
3+
const useOnlineStatus = () => {
4+
const [isOnline, setIsOnline] = useState(navigator.onLine);
5+
6+
useEffect(() => {
7+
const handleOnlineChange = () => setIsOnline(navigator.onLine);
8+
window.addEventListener('online', handleOnlineChange);
9+
window.addEventListener('offline', handleOnlineChange);
10+
setIsOnline(navigator.onLine);
11+
return () => {
12+
window.removeEventListener('online', handleOnlineChange);
13+
window.removeEventListener('offline', handleOnlineChange);
14+
};
15+
}, []);
16+
17+
return isOnline;
18+
};
19+
20+
export default useOnlineStatus;

src/core/utils/reconnectable.ts

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
interface ReconnectableProps {
4+
isOnline: boolean;
5+
skip: boolean;
6+
reconnect: () => void;
7+
}
8+
9+
const AUTO_RECONNECT_DEFAULT_INTERVAL = 5000;
10+
11+
const FIBONACCI_SEQUENCE = [1, 2, 3, 5, 8, 13, 21] as const;
12+
13+
const computeReconnectionTimeout = (baseInterval: number, tryNumber: number) => {
14+
const fibonacciIndex = Math.max(0, Math.min(tryNumber - 1, FIBONACCI_SEQUENCE.length - 1));
15+
16+
return baseInterval * FIBONACCI_SEQUENCE[fibonacciIndex];
17+
};
18+
19+
interface ReconnectableStrategy {
20+
baseInterval: number;
21+
computeReconnectionTimeout: (baseInterval: number, tryNumber: number) => number;
22+
}
23+
24+
interface ReconnectableProvided {
25+
autoReconnectTime: number | null;
26+
setupReconnectTimeout: () => void;
27+
}
28+
29+
const DEFAULT_RECONNECTABLE_STRATEGY: ReconnectableStrategy = {
30+
baseInterval: AUTO_RECONNECT_DEFAULT_INTERVAL,
31+
computeReconnectionTimeout,
32+
} as const;
33+
34+
const Reconnectable = ({ baseInterval, computeReconnectionTimeout } = DEFAULT_RECONNECTABLE_STRATEGY) => {
35+
const useReconnectable = ({ isOnline, skip, reconnect }: ReconnectableProps): ReconnectableProvided => {
36+
const autoReconnectIntervalMultiplierRef = useRef<number>(1);
37+
38+
const [autoReconnectTime, setAutoReconnectTime] = useState<number | null>(null);
39+
40+
const reconnectTimeoutId = useRef<number | null>(null);
41+
42+
useEffect(() => {
43+
if (autoReconnectTime !== null) {
44+
const timeRemaining = autoReconnectTime - Date.now();
45+
46+
reconnectTimeoutId.current = window.setTimeout(() => {
47+
setAutoReconnectTime(null);
48+
handleReconnect();
49+
}, Math.max(timeRemaining, 0));
50+
}
51+
return () => {
52+
if (reconnectTimeoutId.current !== null) {
53+
window.clearTimeout(reconnectTimeoutId.current);
54+
}
55+
reconnectTimeoutId.current = null;
56+
};
57+
}, [autoReconnectTime]);
58+
59+
const setupReconnectTimeout = () => {
60+
setAutoReconnectTime(
61+
Date.now() + computeReconnectionTimeout(baseInterval, autoReconnectIntervalMultiplierRef.current)
62+
);
63+
};
64+
65+
useEffect(() => {
66+
if (isOnline && !autoReconnectTime && !skip) {
67+
setupReconnectTimeout();
68+
}
69+
}, [isOnline]);
70+
71+
useEffect(() => {
72+
if (!isOnline) {
73+
setAutoReconnectTime(null);
74+
}
75+
}, [isOnline]);
76+
77+
useEffect(() => {
78+
if (skip) {
79+
autoReconnectIntervalMultiplierRef.current = 1;
80+
}
81+
}, [skip]);
82+
83+
const handleReconnect = () => {
84+
autoReconnectIntervalMultiplierRef.current++;
85+
reconnect();
86+
};
87+
88+
useEffect(() => {
89+
if (skip) {
90+
setAutoReconnectTime(null);
91+
}
92+
}, [skip]);
93+
94+
return {
95+
autoReconnectTime,
96+
setupReconnectTimeout,
97+
};
98+
};
99+
100+
return useReconnectable;
101+
};
102+
103+
export default Reconnectable;

src/core/utils/time/tick.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
const Tick = (precision = 1000) => {
4+
const getTimeWithPrecision = () => Math.round(Date.now() / precision) * precision;
5+
6+
const useTick = ({ skip = false }: { skip?: boolean } = {}) => {
7+
const tickTimeoutRef = useRef<number | null>(null);
8+
9+
const [, setTime] = useState(getTimeWithPrecision());
10+
11+
useEffect(() => {
12+
if (!skip) {
13+
const tick = () => {
14+
setTime(getTimeWithPrecision());
15+
tickTimeoutRef.current = requestAnimationFrame(tick);
16+
};
17+
18+
tick();
19+
20+
return () => {
21+
if (tickTimeoutRef.current !== null) {
22+
cancelAnimationFrame(tickTimeoutRef.current);
23+
}
24+
tickTimeoutRef.current = null;
25+
};
26+
}
27+
}, [skip]);
28+
29+
return Date.now();
30+
};
31+
32+
return useTick;
33+
};
34+
35+
export const useTick = Tick();
36+
37+
export default Tick;

src/domain/common/whiteboard/excalidraw/CollaborativeExcalidrawWrapper.tsx

+28-16
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@ import Dialog from '@mui/material/Dialog';
1414
import DialogHeader from '../../../../core/ui/dialog/DialogHeader';
1515
import { DialogContent } from '../../../../core/ui/dialog/deprecated';
1616
import WrapperMarkdown from '../../../../core/ui/markdown/WrapperMarkdown';
17-
import { Text } from '../../../../core/ui/typography';
17+
import { Caption, Text } from '../../../../core/ui/typography';
1818
import { formatTimeElapsed } from '../../../shared/utils/formatTimeElapsed';
1919
import { Button, DialogActions } from '@mui/material';
2020
import { useTranslation } from 'react-i18next';
2121
import { LoadingButton } from '@mui/lab';
22+
import useOnlineStatus from '../../../../core/utils/onlineStatus';
23+
import Reconnectable from '../../../../core/utils/reconnectable';
24+
import { useTick } from '../../../../core/utils/time/tick';
2225

2326
const useActorWhiteboardStyles = makeStyles(theme => ({
2427
container: {
@@ -61,6 +64,8 @@ export interface WhiteboardWhiteboardProps {
6164

6265
const WINDOW_SCROLL_HANDLER_DEBOUNCE_INTERVAL = 100;
6366

67+
const useReconnectable = Reconnectable();
68+
6469
const CollaborativeExcalidrawWrapper = ({ entities, actions, options, collabApiRef }: WhiteboardWhiteboardProps) => {
6570
const { whiteboard, filesManager, lastSavedDate } = entities;
6671

@@ -138,6 +143,9 @@ const CollaborativeExcalidrawWrapper = ({ entities, actions, options, collabApiR
138143
},
139144
onCloseConnection: () => {
140145
setCollaborationStoppedNoticeOpen(true);
146+
if (isOnline) {
147+
setupReconnectTimeout();
148+
}
141149
},
142150
onInitialize: collabApi => {
143151
combinedCollabApiRef.current = collabApi;
@@ -153,10 +161,24 @@ const CollaborativeExcalidrawWrapper = ({ entities, actions, options, collabApiR
153161

154162
const [collaborationStartTime, setCollaborationStartTime] = useState<number | null>(Date.now());
155163

164+
const [collaborationStoppedNoticeOpen, setCollaborationStoppedNoticeOpen] = useState(false);
165+
166+
const isOnline = useOnlineStatus();
167+
156168
const restartCollaboration = () => {
157169
setCollaborationStartTime(Date.now());
158170
};
159171

172+
const { autoReconnectTime, setupReconnectTimeout } = useReconnectable({
173+
isOnline,
174+
reconnect: restartCollaboration,
175+
skip: !collaborationStoppedNoticeOpen || collaborating,
176+
});
177+
178+
const time = useTick({
179+
skip: autoReconnectTime === null,
180+
});
181+
160182
useEffect(() => {
161183
if (!connecting && collaborating) {
162184
setCollaborationStoppedNoticeOpen(false);
@@ -180,23 +202,8 @@ const CollaborativeExcalidrawWrapper = ({ entities, actions, options, collabApiR
180202
[actions.onInitApi]
181203
);
182204

183-
const [collaborationStoppedNoticeOpen, setCollaborationStoppedNoticeOpen] = useState(false);
184-
185205
const { t } = useTranslation();
186206

187-
const [isOnline, setIsOnline] = useState(navigator.onLine);
188-
189-
useEffect(() => {
190-
const handleOnlineChange = () => setIsOnline(navigator.onLine);
191-
window.addEventListener('online', handleOnlineChange);
192-
window.addEventListener('offline', handleOnlineChange);
193-
setIsOnline(navigator.onLine);
194-
return () => {
195-
window.removeEventListener('online', handleOnlineChange);
196-
window.removeEventListener('offline', handleOnlineChange);
197-
};
198-
}, []);
199-
200207
return (
201208
<>
202209
<div className={styles.container}>
@@ -237,6 +244,11 @@ const CollaborativeExcalidrawWrapper = ({ entities, actions, options, collabApiR
237244
<DialogActions>
238245
<LoadingButton onClick={restartCollaboration} disabled={!isOnline} loading={connecting}>
239246
Reconnect
247+
<Caption textTransform="none">
248+
{autoReconnectTime !== null &&
249+
autoReconnectTime - time > 0 &&
250+
` (${Math.ceil((autoReconnectTime - time) / 1000)}s)`}
251+
</Caption>
240252
</LoadingButton>
241253
<Button onClick={() => setCollaborationStoppedNoticeOpen(false)}>{t('buttons.ok')}</Button>
242254
</DialogActions>

0 commit comments

Comments
 (0)