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