diff --git a/web/src/features/messages/OfflineBanner.tsx b/web/src/features/messages/OfflineBanner.tsx
new file mode 100644
index 00000000..be6bb921
--- /dev/null
+++ b/web/src/features/messages/OfflineBanner.tsx
@@ -0,0 +1,70 @@
+import { useNavigate } from 'react-router-dom';
+
+import { Icon } from '@/app/Icon';
+import { useBootstrap } from '@/lib/api/hooks';
+import { retryReconnectNow } from '@/lib/tinode/connection';
+import { useReconnectState } from '@/lib/tinode/hooks';
+
+/**
+ * README §3.7's four top-banner states, overlaying ChatList+Main. Only one shows at a time,
+ * priority: offline > reconnecting > just-reconnected > realname-reminder (most to least urgent).
+ * "已重新连接,正在同步 N 条新消息" is shown without a fake N -- the old app never implemented this
+ * state at all (confirmed by research), and there's no reliable signal here for "how many messages
+ * arrived while disconnected," so inventing a number would just be dishonest data.
+ */
+export function OfflineBanner() {
+ const navigate = useNavigate();
+ const reconnect = useReconnectState();
+ const bootstrap = useBootstrap();
+ const realname = bootstrap.data?.security?.realname;
+
+ if (!reconnect.networkOnline) {
+ return (
+
+
+ 当前无网络连接,消息将在恢复后自动发送
+
+ );
+ }
+
+ if (reconnect.retryInSeconds > 0 || reconnect.attemptInFlight) {
+ return (
+
+
+
+ {reconnect.attemptInFlight ? '连接已断开,正在重连…' : `连接已断开,正在重连(${reconnect.retryInSeconds} 秒后重试)…`}
+
+ {!reconnect.attemptInFlight ? (
+
+ 立即重试
+
+ ) : null}
+
+ );
+ }
+
+ if (reconnect.justReconnected) {
+ return (
+
+
+ 已重新连接,正在同步消息…
+
+ );
+ }
+
+ if (realname && !realname.status.includes('verified') && realname.allow_skip) {
+ return (
+
+
+
+ 根据法规要求,请先完成实名认证后再使用全部功能
+
+ navigate('/settings')} className="text-accent">
+ 去认证
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/web/src/lib/api/bootstrap.ts b/web/src/lib/api/bootstrap.ts
index c51f4e19..fd756b65 100644
--- a/web/src/lib/api/bootstrap.ts
+++ b/web/src/lib/api/bootstrap.ts
@@ -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;
}
diff --git a/web/src/lib/tinode/connection.ts b/web/src/lib/tinode/connection.ts
index c8be7f44..2bf3b8eb 100644
--- a/web/src/lib/tinode/connection.ts
+++ b/web/src/lib/tinode/connection.ts
@@ -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
| null = null;
+let justReconnectedTimer: ReturnType | null = null;
+
+function setReconnectState(patch: Partial) {
+ 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) => {
+ 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;
}
diff --git a/web/src/lib/tinode/hooks.ts b/web/src/lib/tinode/hooks.ts
index 04663473..c67b4b05 100644
--- a/web/src/lib/tinode/hooks.ts
+++ b/web/src/lib/tinode/hooks.ts
@@ -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();
diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts
index cf2262e5..48d550b2 100644
--- a/web/src/lib/tinode/tinode-sdk.d.ts
+++ b/web/src/lib/tinode/tinode-sdk.d.ts
@@ -246,6 +246,8 @@ declare module 'tinode-sdk' {
connect(host?: string): Promise;
disconnect(): void;
+ /** Skips the remaining exponential-backoff wait and retries the connection now. */
+ reconnect(force?: boolean): void;
isConnected(): boolean;
isAuthenticated(): boolean;
getCurrentUserID(): string;