feat(ui): show a limited connection instead of offline behind proxies
When no realtime connection gets through (proxy buffering SSE, notes on long polling) but the server answers, the profile shows a yellow "eingeschränkt" instead of "offline". A full connection now shows a green "online" on the desktop too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7fcd5a258c
commit
282a9af557
6 files changed
+205
-129
No files matched your search
@@ -173,7 +173,7 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
|
|||||||
- **Neue Version:** Jeder Build legt `version.json` (Version und Build-Kennung) neben `index.html`, nie gecacht. Die App vergleicht sie mit ihrer eigenen Kennung beim Zurückkehren in Tab oder App, alle 15 Minuten und nach einem Neustart des Servers (Echtzeit-Verbindung wieder da) und zeigt dann „Neue Version verfügbar“ mit „Jetzt aktualisieren“ ([lib/useUpdateCheck.ts](../frontend/src/lib/useUpdateCheck.ts)).
|
- **Neue Version:** Jeder Build legt `version.json` (Version und Build-Kennung) neben `index.html`, nie gecacht. Die App vergleicht sie mit ihrer eigenen Kennung beim Zurückkehren in Tab oder App, alle 15 Minuten und nach einem Neustart des Servers (Echtzeit-Verbindung wieder da) und zeigt dann „Neue Version verfügbar“ mit „Jetzt aktualisieren“ ([lib/useUpdateCheck.ts](../frontend/src/lib/useUpdateCheck.ts)).
|
||||||
- **Zustand pro Tab:** Der offene Bereich (Triage, Workspace, Aufgaben …), die offene Notiz und die gewählte Liste stehen in `sessionStorage` ([lib/viewSession.ts](../frontend/src/lib/viewSession.ts)) und bleiben beim Neuladen erhalten.
|
- **Zustand pro Tab:** Der offene Bereich (Triage, Workspace, Aufgaben …), die offene Notiz und die gewählte Liste stehen in `sessionStorage` ([lib/viewSession.ts](../frontend/src/lib/viewSession.ts)) und bleiben beim Neuladen erhalten.
|
||||||
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
||||||
- **Verbindungsstatus im Kopf** ([lib/useServerOnline.ts](../frontend/src/lib/useServerOnline.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Offline gilt erst nach 4 s ohne Verbindung (Start, kurze Reconnects), ohne Gerätenetz sofort. Desktop: „● offline“ unter dem Namen, nur ohne Verbindung; Handy: Punkt am Profilbild, grün oder rot.
|
- **Verbindungsstatus im Kopf** ([lib/useConnectionState.ts](../frontend/src/lib/useConnectionState.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Fehlt sie länger als 4 s (Start, kurze Reconnects überbrückt), fragt die App `/api/v1/health` (danach alle 15 s): Antwortet der Server, ist die Verbindung **eingeschränkt** (z. B. Proxy, der SSE puffert; Notizen laufen dann über Long Polling, andere Änderungen erst beim Zurückkehren in den Tab oder Neuladen), sonst **offline**. Ohne Gerätenetz sofort offline. Anzeige als Punkt grün/gelb/rot: Desktop mit Text unter dem Namen, Handy am Profilbild.
|
||||||
|
|
||||||
## App-Icons, PWA & Social-Preview (Open Graph)
|
## App-Icons, PWA & Social-Preview (Open Graph)
|
||||||
|
|
||||||
|
|||||||
+24
-17
@@ -60,7 +60,7 @@ import { SharedListsView } from './features/lists/SharedListsView';
|
|||||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||||
import { OAuthConsentView } from './features/auth/OAuthConsentView';
|
import { OAuthConsentView } from './features/auth/OAuthConsentView';
|
||||||
import { OfflineBanner } from './components/OfflineBanner';
|
import { OfflineBanner } from './components/OfflineBanner';
|
||||||
import { useServerOnline } from './lib/useServerOnline';
|
import { useConnectionState } from './lib/useConnectionState';
|
||||||
import { SharedImportToast } from './components/SharedImportToast';
|
import { SharedImportToast } from './components/SharedImportToast';
|
||||||
import { outboxCount, clearOfflineData } from './lib/offline';
|
import { outboxCount, clearOfflineData } from './lib/offline';
|
||||||
import { DemoCountdown } from './components/DemoCountdown';
|
import { DemoCountdown } from './components/DemoCountdown';
|
||||||
@@ -83,6 +83,17 @@ import { APP_VERSION } from './version';
|
|||||||
import { readViewSession, writeViewSession } from './lib/viewSession';
|
import { readViewSession, writeViewSession } from './lib/viewSession';
|
||||||
import { setVoicePreference } from './lib/voice';
|
import { setVoicePreference } from './lib/voice';
|
||||||
|
|
||||||
|
// Connection status at the profile (useConnectionState); full class names for Tailwind
|
||||||
|
const CONNECTION_STATUS = {
|
||||||
|
online: { label: 'online', dot: 'bg-status-ok', title: 'Online – Änderungen kommen sofort an' },
|
||||||
|
limited: {
|
||||||
|
label: 'eingeschränkt',
|
||||||
|
dot: 'bg-status-warn',
|
||||||
|
title: 'Eingeschränkt – der Server ist erreichbar, aber keine Live-Verbindung (z. B. Firmen-Proxy); Notizen gleichen sich langsamer ab, andere Änderungen erscheinen beim Zurückkehren in den Tab oder nach dem Neuladen',
|
||||||
|
},
|
||||||
|
offline: { label: 'offline', dot: 'bg-status-err', title: 'Offline – keine Verbindung zum Server; Änderungen werden später synchronisiert' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
|
const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
|
||||||
type ActiveTab = (typeof APP_TABS)[number];
|
type ActiveTab = (typeof APP_TABS)[number];
|
||||||
|
|
||||||
@@ -192,7 +203,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
// Profile Dropdown State
|
// Profile Dropdown State
|
||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
const serverOnline = useServerOnline();
|
const connection = useConnectionState();
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
||||||
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
||||||
@@ -986,13 +997,11 @@ export const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<UserAvatar user={user} size="md" className="shadow-sm" />
|
<UserAvatar user={user} size="md" className="shadow-sm" />
|
||||||
{/* Connection status like in a chat app: green online, red without server */}
|
{/* Connection status like in a chat app: green online, yellow limited, red without server */}
|
||||||
<span
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={serverOnline ? 'Online' : 'Offline – keine Verbindung zum Server'}
|
aria-label={CONNECTION_STATUS[connection].title}
|
||||||
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${
|
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${CONNECTION_STATUS[connection].dot}`}
|
||||||
serverOnline ? 'bg-status-ok' : 'bg-status-err'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1064,16 +1073,14 @@ export const App: React.FC = () => {
|
|||||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||||
{user.displayName}
|
{user.displayName}
|
||||||
</span>
|
</span>
|
||||||
{!serverOnline && (
|
<span
|
||||||
<span
|
role="status"
|
||||||
role="status"
|
title={CONNECTION_STATUS[connection].title}
|
||||||
title="Keine Verbindung zum Server – Änderungen werden später synchronisiert"
|
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
|
||||||
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
|
>
|
||||||
>
|
<span className={`w-1.5 h-1.5 rounded-full ${CONNECTION_STATUS[connection].dot}`} />
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-status-err" />
|
{CONNECTION_STATUS[connection].label}
|
||||||
offline
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
let statusListener: ((connected: boolean) => void) | null = null;
|
||||||
|
let currentConnected = false;
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
onRealtimeStatus: (cb: (connected: boolean) => void) => {
|
||||||
|
statusListener = cb;
|
||||||
|
cb(currentConnected);
|
||||||
|
return () => (statusListener = null);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useConnectionState, SERVER_OFFLINE_GRACE_MS, SERVER_PROBE_INTERVAL_MS } from '../useConnectionState';
|
||||||
|
|
||||||
|
function setDeviceOnline(value: boolean) {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverAnswers = false;
|
||||||
|
const fetchMock = vi.fn(async () => {
|
||||||
|
if (!serverAnswers) throw new TypeError('Failed to fetch');
|
||||||
|
return { ok: true } as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Runs the timers and lets the health request settle
|
||||||
|
async function wait(ms: number) {
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useConnectionState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
fetchMock.mockClear();
|
||||||
|
currentConnected = false;
|
||||||
|
serverAnswers = false;
|
||||||
|
setDeviceOnline(true);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays online during the grace period at app start', async () => {
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
act(() => statusListener?.(true));
|
||||||
|
await wait(SERVER_OFFLINE_GRACE_MS + 100);
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns offline once the server stays unreachable and back online on reconnect', async () => {
|
||||||
|
currentConnected = true;
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
act(() => statusListener?.(false));
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
await wait(SERVER_OFFLINE_GRACE_MS + 100);
|
||||||
|
expect(result.current).toBe('offline');
|
||||||
|
act(() => statusListener?.(true));
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is limited when the server answers but no realtime connection gets through', async () => {
|
||||||
|
serverAnswers = true;
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
await wait(SERVER_OFFLINE_GRACE_MS + 100);
|
||||||
|
expect(result.current).toBe('limited');
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/v1/health', expect.anything());
|
||||||
|
|
||||||
|
// Asked again while the connection is missing
|
||||||
|
serverAnswers = false;
|
||||||
|
await wait(SERVER_PROBE_INTERVAL_MS + 100);
|
||||||
|
expect(result.current).toBe('offline');
|
||||||
|
serverAnswers = true;
|
||||||
|
await wait(SERVER_PROBE_INTERVAL_MS + 100);
|
||||||
|
expect(result.current).toBe('limited');
|
||||||
|
|
||||||
|
act(() => statusListener?.(true));
|
||||||
|
expect(result.current).toBe('online');
|
||||||
|
const calls = fetchMock.mock.calls.length;
|
||||||
|
await wait(SERVER_PROBE_INTERVAL_MS * 2);
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repeated failed reconnects do not postpone the check', async () => {
|
||||||
|
currentConnected = true;
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
act(() => statusListener?.(false));
|
||||||
|
await wait(SERVER_OFFLINE_GRACE_MS / 2);
|
||||||
|
}
|
||||||
|
expect(result.current).toBe('offline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is offline right away without device network', () => {
|
||||||
|
currentConnected = true;
|
||||||
|
const { result } = renderHook(() => useConnectionState());
|
||||||
|
act(() => {
|
||||||
|
setDeviceOnline(false);
|
||||||
|
statusListener?.(false);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe('offline');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { act, renderHook } from '@testing-library/react';
|
|
||||||
|
|
||||||
let statusListener: ((connected: boolean) => void) | null = null;
|
|
||||||
let currentConnected = false;
|
|
||||||
vi.mock('../api', () => ({
|
|
||||||
onRealtimeStatus: (cb: (connected: boolean) => void) => {
|
|
||||||
statusListener = cb;
|
|
||||||
cb(currentConnected);
|
|
||||||
return () => (statusListener = null);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useServerOnline, SERVER_OFFLINE_GRACE_MS } from '../useServerOnline';
|
|
||||||
|
|
||||||
function setDeviceOnline(value: boolean) {
|
|
||||||
Object.defineProperty(navigator, 'onLine', { value, configurable: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useServerOnline', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
currentConnected = false;
|
|
||||||
setDeviceOnline(true);
|
|
||||||
});
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
it('stays online during the grace period at app start', () => {
|
|
||||||
const { result } = renderHook(() => useServerOnline());
|
|
||||||
expect(result.current).toBe(true);
|
|
||||||
act(() => statusListener?.(true));
|
|
||||||
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
|
|
||||||
expect(result.current).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('turns offline once the server stays unreachable and back online on reconnect', () => {
|
|
||||||
currentConnected = true;
|
|
||||||
const { result } = renderHook(() => useServerOnline());
|
|
||||||
act(() => statusListener?.(false));
|
|
||||||
expect(result.current).toBe(true);
|
|
||||||
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS + 100));
|
|
||||||
expect(result.current).toBe(false);
|
|
||||||
act(() => statusListener?.(true));
|
|
||||||
expect(result.current).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('repeated failed reconnects do not postpone the offline state', () => {
|
|
||||||
currentConnected = true;
|
|
||||||
const { result } = renderHook(() => useServerOnline());
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
act(() => statusListener?.(false));
|
|
||||||
act(() => vi.advanceTimersByTime(SERVER_OFFLINE_GRACE_MS / 2));
|
|
||||||
}
|
|
||||||
expect(result.current).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is offline right away without device network', () => {
|
|
||||||
currentConnected = true;
|
|
||||||
const { result } = renderHook(() => useServerOnline());
|
|
||||||
act(() => {
|
|
||||||
setDeviceOnline(false);
|
|
||||||
statusListener?.(false);
|
|
||||||
});
|
|
||||||
expect(result.current).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { onRealtimeStatus } from './api';
|
||||||
|
|
||||||
|
// Grace period before a missing realtime connection counts as offline (app start, short reconnects)
|
||||||
|
export const SERVER_OFFLINE_GRACE_MS = 4000;
|
||||||
|
// How often the server is asked while the realtime connection is missing
|
||||||
|
export const SERVER_PROBE_INTERVAL_MS = 15000;
|
||||||
|
|
||||||
|
// online: realtime connection (WebSocket/SSE) is up; limited: the server answers plain requests, but no
|
||||||
|
// realtime connection gets through (e.g. a proxy that buffers event streams; notes fall back to long
|
||||||
|
// polling); offline: no server at all
|
||||||
|
export type ConnectionState = 'online' | 'limited' | 'offline';
|
||||||
|
|
||||||
|
async function serverReachable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/health', { cache: 'no-store' });
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the server is reachable, judged by the app-wide realtime connection. Unlike navigator.onLine it
|
||||||
|
// also notices a server that is down while the device still has network. Without device network it is
|
||||||
|
// offline right away; otherwise, once the connection stayed away for a moment, a request to the server
|
||||||
|
// decides between limited and offline (repeated while the connection is missing).
|
||||||
|
export function useConnectionState(): ConnectionState {
|
||||||
|
const [state, setState] = useState<ConnectionState>('online');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let connected = false;
|
||||||
|
let disposed = false;
|
||||||
|
let timer: number | undefined;
|
||||||
|
const stopProbing = () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
};
|
||||||
|
const probe = async () => {
|
||||||
|
const reachable = await serverReachable();
|
||||||
|
if (disposed || connected) return;
|
||||||
|
setState(reachable && navigator.onLine !== false ? 'limited' : 'offline');
|
||||||
|
timer = window.setTimeout(probe, SERVER_PROBE_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
// Reconnect attempts report "not connected" every few seconds; they must not restart the grace period
|
||||||
|
const update = () => {
|
||||||
|
if (connected) {
|
||||||
|
stopProbing();
|
||||||
|
setState('online');
|
||||||
|
} else if (navigator.onLine === false) {
|
||||||
|
stopProbing();
|
||||||
|
setState('offline');
|
||||||
|
} else if (timer === undefined) {
|
||||||
|
timer = window.setTimeout(probe, SERVER_OFFLINE_GRACE_MS);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unsubscribe = onRealtimeStatus((isConnected) => {
|
||||||
|
connected = isConnected;
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
window.addEventListener('online', update);
|
||||||
|
window.addEventListener('offline', update);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unsubscribe();
|
||||||
|
stopProbing();
|
||||||
|
window.removeEventListener('online', update);
|
||||||
|
window.removeEventListener('offline', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { onRealtimeStatus } from './api';
|
|
||||||
|
|
||||||
// Grace period before a missing realtime connection counts as offline (app start, short reconnects)
|
|
||||||
export const SERVER_OFFLINE_GRACE_MS = 4000;
|
|
||||||
|
|
||||||
// Whether the server is reachable, judged by the app-wide realtime connection (WebSocket/SSE). Unlike
|
|
||||||
// navigator.onLine it also notices a server that is down while the device still has network. Without
|
|
||||||
// device network it is offline right away; otherwise only after the connection stayed away for a moment.
|
|
||||||
export function useServerOnline(): boolean {
|
|
||||||
const [online, setOnline] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let connected = false;
|
|
||||||
let timer: number | undefined;
|
|
||||||
// Reconnect attempts report "not connected" every few seconds; they must not restart the grace period
|
|
||||||
const update = () => {
|
|
||||||
if (connected) {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
timer = undefined;
|
|
||||||
setOnline(true);
|
|
||||||
} else if (navigator.onLine === false) {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
timer = undefined;
|
|
||||||
setOnline(false);
|
|
||||||
} else if (timer === undefined) {
|
|
||||||
timer = window.setTimeout(() => setOnline(false), SERVER_OFFLINE_GRACE_MS);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const unsubscribe = onRealtimeStatus((isConnected) => {
|
|
||||||
connected = isConnected;
|
|
||||||
update();
|
|
||||||
});
|
|
||||||
window.addEventListener('online', update);
|
|
||||||
window.addEventListener('offline', update);
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
window.removeEventListener('online', update);
|
|
||||||
window.removeEventListener('offline', update);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return online;
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user