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:
co-authored by
Claude Sonnet 5
parent
80bf0aa41f
commit
bccbdff9b2
@@ -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 (
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
|
||||
<ChatListColumn />
|
||||
</div>
|
||||
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
||||
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v) }} />
|
||||
<div className="relative flex h-full min-w-0 flex-1">
|
||||
<div className="absolute inset-x-0 top-0 z-10">
|
||||
<OfflineBanner />
|
||||
</div>
|
||||
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
|
||||
<ChatListColumn />
|
||||
</div>
|
||||
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
||||
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v) }} />
|
||||
</div>
|
||||
</div>
|
||||
{topicId ? (
|
||||
<div className="hidden h-full lg:block">
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-10 flex-none items-center gap-2 bg-error-strip-bg px-4 text-[13px] text-error-strip-text">
|
||||
<Icon name="wifi_off" className="text-[16px]" />
|
||||
当前无网络连接,消息将在恢复后自动发送
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reconnect.retryInSeconds > 0 || reconnect.attemptInFlight) {
|
||||
return (
|
||||
<div className="flex h-10 flex-none items-center justify-between bg-announce-bg px-4 text-[13px] text-announce-text">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon name="autorenew" className="animate-spin text-[16px] text-announce" />
|
||||
{reconnect.attemptInFlight ? '连接已断开,正在重连…' : `连接已断开,正在重连(${reconnect.retryInSeconds} 秒后重试)…`}
|
||||
</span>
|
||||
{!reconnect.attemptInFlight ? (
|
||||
<button type="button" onClick={retryReconnectNow} className="text-accent">
|
||||
立即重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reconnect.justReconnected) {
|
||||
return (
|
||||
<div className="flex h-10 flex-none items-center gap-2 bg-info-strip-bg px-4 text-[13px] text-info-strip-text">
|
||||
<Icon name="sync" className="text-[16px] text-accent" />
|
||||
已重新连接,正在同步消息…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (realname && !realname.status.includes('verified') && realname.allow_skip) {
|
||||
return (
|
||||
<div className="flex h-10 flex-none items-center justify-between bg-warn-bg px-4 text-[13px] text-[#8a5a12]">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon name="gpp_maybe" className="text-[16px] text-warn" />
|
||||
根据法规要求,请先完成实名认证后再使用全部功能
|
||||
</span>
|
||||
<button type="button" onClick={() => navigate('/settings')} className="text-accent">
|
||||
去认证
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user