feat(web): offline/reconnect/realname top banners (README §3.7)

Four states, one shown at a time (offline > reconnecting > just-reconnected
> realname-reminder priority): no-network (navigator.onLine + online/offline
listeners), reconnecting-with-countdown, just-reconnected, and a skippable
realname-verification reminder (bootstrap.security.realname).

- connection.ts: claims Tinode's onAutoreconnectIteration(timeoutMs, promise?)
  callback (single-slot, same pattern as onConnect/onDisconnect) and runs its
  own 1s-tick countdown off the first call's timeout -- the SDK gives the
  backoff length once per iteration, it doesn't push a running countdown.
  Verified the callback's exact three-shape contract (timeoutMs alone starts
  a countdown; 0+promise means a retry is now in flight; -1 means retrying
  stopped) by reading the SDK's own Connection class, not guessed.
  retryReconnectNow() backs "立即重试": clears the countdown and calls
  tinode.reconnect(true).
- "已重新连接,正在同步 N 条新消息" intentionally shown without a fake N --
  confirmed via research that the old app never implemented this state at
  all, and there's no reliable signal here for how many messages arrived
  while disconnected; inventing a count would be dishonest data.

Verified live: toggling browser online/offline events correctly shows and
clears the no-network banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-08 02:31:32 +08:00
co-authored by Claude Sonnet 5
parent 80bf0aa41f
commit bccbdff9b2
6 changed files with 188 additions and 7 deletions
+8
View File
@@ -6,9 +6,17 @@ export interface MessagePolicy {
recall_window_seconds?: number;
}
export interface RealnameStatus {
required: boolean;
allow_skip: boolean;
remind_after?: string;
status: 'unverified' | 'pending' | 'verified' | string;
}
export interface BootstrapConfig {
message_policy?: MessagePolicy;
limits?: { max_forward_targets?: number; [key: string]: unknown };
security?: { realname?: RealnameStatus };
[key: string]: unknown;
}
+91 -1
View File
@@ -24,6 +24,69 @@ function setStatus(next: ConnectionStatus) {
listeners.forEach((cb) => cb());
}
/**
* Reconnect/network state driving the top banners (README §3.7). The SDK's own exponential backoff
* (base 2s, +/-30% jitter, up to 10 iterations -- internal to tinode-sdk, confirmed by reading its
* source, not reimplemented here) calls `onAutoreconnectIteration(timeoutMs, promise?)`: first with
* a fresh countdown length and no promise, then with `0` and the actual connect() promise once that
* countdown elapses and a retry is actually in flight, or `-1` if retrying was stopped (explicit
* disconnect). This module runs its own 1s-tick countdown display off that first callback, matching
* the old app's approach (the SDK gives the length once, not a running countdown).
*/
export interface ReconnectState {
/** > 0 while a "retry in N seconds" countdown is live; 0 otherwise. */
retryInSeconds: number;
/** True for the brief window between a countdown reaching 0 and the retry's outcome (onConnect/onDisconnect) landing. */
attemptInFlight: boolean;
/** navigator.onLine -- independent of the WS connection state; the two can disagree briefly. */
networkOnline: boolean;
/** Briefly true right after reconnecting, to drive the "正在同步" banner -- cleared after a few seconds. */
justReconnected: boolean;
}
let reconnectState: ReconnectState = { retryInSeconds: 0, attemptInFlight: false, networkOnline: true, justReconnected: false };
const reconnectListeners = new Set<() => void>();
let countdownTimer: ReturnType<typeof setInterval> | null = null;
let justReconnectedTimer: ReturnType<typeof setTimeout> | null = null;
function setReconnectState(patch: Partial<ReconnectState>) {
reconnectState = { ...reconnectState, ...patch };
reconnectListeners.forEach((cb) => cb());
}
export function getReconnectState(): ReconnectState {
return reconnectState;
}
export function subscribeReconnectState(callback: () => void): () => void {
reconnectListeners.add(callback);
return () => reconnectListeners.delete(callback);
}
function clearCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
function stopReconnectRetry() {
clearCountdown();
setReconnectState({ retryInSeconds: 0, attemptInFlight: false });
}
/** "立即重试" -- skip the remaining backoff wait and reconnect right now. */
export function retryReconnectNow() {
stopReconnectRetry();
tinode?.reconnect(true);
}
if (typeof window !== 'undefined') {
setReconnectState({ networkOnline: navigator.onLine });
window.addEventListener('online', () => setReconnectState({ networkOnline: true }));
window.addEventListener('offline', () => setReconnectState({ networkOnline: false }));
}
/**
* Registration-only extra fields (username/phone/invite_code) queued by registerAccount() below,
* consumed the next time an {acc} packet actually goes out. A module-level slot, not a per-call
@@ -82,11 +145,38 @@ function createTinode(): Tinode {
t.enableLogging(LOGGING_ENABLED, true);
installTenantHiPatch(t);
t.onConnect = () => setStatus('connected');
t.onConnect = () => {
const wasReconnecting = reconnectState.retryInSeconds > 0 || reconnectState.attemptInFlight;
setStatus('connected');
stopReconnectRetry();
if (wasReconnecting) {
setReconnectState({ justReconnected: true });
if (justReconnectedTimer) clearTimeout(justReconnectedTimer);
justReconnectedTimer = setTimeout(() => setReconnectState({ justReconnected: false }), 4000);
}
};
t.onDisconnect = () => setStatus('disconnected');
t.onLogin = (code: number) => {
if (code === 200) setStatus('authenticated');
};
t.onAutoreconnectIteration = (timeoutMs: number, promise?: Promise<unknown>) => {
clearCountdown();
if (timeoutMs < 0) {
stopReconnectRetry();
return;
}
if (promise) {
setReconnectState({ retryInSeconds: 0, attemptInFlight: true });
promise.catch(() => {});
return;
}
setReconnectState({ attemptInFlight: false, retryInSeconds: Math.max(1, Math.round(timeoutMs / 1000)) });
countdownTimer = setInterval(() => {
const next = Math.max(0, reconnectState.retryInSeconds - 1);
setReconnectState({ retryInSeconds: next });
if (next <= 0) clearCountdown();
}, 1000);
};
return t;
}
+6 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useSyncExternalStore } from 'react';
import type { Contact, StoredMessage } from 'tinode-sdk';
import { getConnectionStatus, getTinode, subscribeConnectionStatus } from './connection';
import { getConnectionStatus, getReconnectState, getTinode, subscribeConnectionStatus, subscribeReconnectState, type ReconnectState } from './connection';
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicChanges } from './topics';
/** The current WS connection status, reactive via useSyncExternalStore. */
@@ -10,6 +10,11 @@ export function useConnectionStatus() {
return useSyncExternalStore(subscribeConnectionStatus, getConnectionStatus);
}
/** Reconnect countdown / offline / just-reconnected state driving the top banners (README §3.7). */
export function useReconnectState(): ReconnectState {
return useSyncExternalStore(subscribeReconnectState, getReconnectState);
}
/** The singleton Tinode client instance (stable reference, not itself reactive). */
export function useTinode() {
return getTinode();
+2
View File
@@ -246,6 +246,8 @@ declare module 'tinode-sdk' {
connect(host?: string): Promise<void>;
disconnect(): void;
/** Skips the remaining exponential-backoff wait and retries the connection now. */
reconnect(force?: boolean): void;
isConnected(): boolean;
isAuthenticated(): boolean;
getCurrentUserID(): string;