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:
sandrodachandClaude Opus 5.5 committed 2026-11-02 11:21:14 +01:00
1 parent 7fcd5a258c
commit 282a9af557
6 files changed
+205 -129

No files matched your search

+72
View File
@@ -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;
}