diff --git a/web/src/features/messages/MessagesLayout.tsx b/web/src/features/messages/MessagesLayout.tsx index f4c24df3..d5764c13 100644 --- a/web/src/features/messages/MessagesLayout.tsx +++ b/web/src/features/messages/MessagesLayout.tsx @@ -4,6 +4,7 @@ import { Outlet, useParams } from 'react-router-dom'; import { ChatListColumn } from '@/features/messages/ChatListColumn'; import { DetailColumn } from '@/features/messages/DetailColumn'; +import { OfflineBanner } from '@/features/messages/OfflineBanner'; import { useChatList } from '@/lib/tinode/hooks'; /** @@ -23,11 +24,16 @@ export function MessagesLayout() { return (
-
- -
-
- setDetailOpen((v) => !v) }} /> +
+
+ +
+
+ +
+
+ setDetailOpen((v) => !v) }} /> +
{topicId ? (
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 ( +
+ + + 根据法规要求,请先完成实名认证后再使用全部功能 + + +
+ ); + } + + 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;