feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes

Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+10
View File
@@ -1,7 +1,11 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { CallRoot } from '@/features/calls/CallRoot';
import { useBootstrap } from '@/lib/api/hooks';
import { useChatList } from '@/lib/tinode/hooks';
import { initCallSignaling } from '@/stores/calls';
import { BottomTabBar } from './BottomTabBar';
import { NavRail } from './NavRail';
@@ -15,6 +19,11 @@ import { NavRail } from './NavRail';
export function AppShell() {
const contacts = useChatList();
const bootstrap = useBootstrap();
// Once per app session, regardless of which page is open -- an incoming call must surface even
// while browsing contacts/settings/etc, not only while a conversation happens to be open.
useEffect(() => {
initCallSignaling();
}, []);
// "sys"/"slf" have their own authoritative unread source in entry_badges (bootstrap.ts) -- excluded
// here in case they ever also show up as regular Tinode subscriptions in `contacts`, which would
// otherwise double-count the same unread messages into this badge.
@@ -36,6 +45,7 @@ export function AppShell() {
<BottomTabBar unreadMessages={unreadMessages} />
</div>
</div>
<CallRoot />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl } from '@/lib/hooks';
interface AvatarProps {
/** Stable id (user_id/topic) used ONLY for the deterministic fallback color -- never the display name. */
id: string;
name: string | undefined;
avatarFileId?: string;
/** Sizing/rounding/font-size only (e.g. "h-20 w-20 rounded-[20px] text-2xl") -- the fallback tile's
* own color/centering/weight are applied internally, not expected from the caller. */
className?: string;
}
/**
* A real uploaded avatar (`avatar_file_id`) was, until now, stored and successfully round-tripped
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
* call site across the whole codebase drew only the letter-tile fallback, so uploading a custom
* avatar produced zero visible change anywhere, including on the very page the upload happened.
* `avatar_file_id` is a bare file id (e.g. "abc123"), not a ref path -- reconstruct the
* `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
* used for chat attachments) expects.
*/
export function Avatar({ id, name, avatarFileId, className = '' }: AvatarProps) {
const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined);
if (remote.url) {
return <img src={remote.url} alt="" className={`object-cover ${className}`} />;
}
return (
<span className={`grid place-items-center font-medium text-white ${avatarBgClass(id)} ${className}`}>{avatarLetter(name)}</span>
);
}
+5 -2
View File
@@ -1,7 +1,10 @@
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html). */
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html).
* Always `aria-hidden` -- the glyph's DOM text is the icon's internal ligature name (e.g.
* "arrow_back_ios_new"), never something a screen reader should announce. An icon-only
* interactive element (button/link with no visible text) must set its own `aria-label`. */
export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) {
return (
<i className={`material-icons select-none ${className}`} style={style}>
<i aria-hidden="true" className={`material-icons select-none ${className}`} style={style}>
{name}
</i>
);
+30 -8
View File
@@ -1,4 +1,4 @@
import { NavLink } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { Icon } from './Icon';
@@ -21,16 +21,38 @@ const BOTTOM_ITEMS: NavRailItem[] = [
{ to: '/settings', icon: 'settings', label: '设置' },
];
/** `/files` is `<Navigate to="/messages/slf" replace />` (App.tsx) -- the real URL right after
* clicking "文件助手" is `/messages/slf`, which a plain (non-`end`) NavLink match makes "消息"
* light up for (a same-topicId prefix match) while "文件助手" itself (`to: '/files'`, a path that
* no longer exists once redirected) never matches at all. Overriding both items' active state off
* the real pathname instead of trusting NavLink's own prefix match fixes both sides of the mix-up. */
function isNavItemActive(to: string, pathname: string): boolean {
if (to === '/files') return pathname === '/messages/slf';
if (to === '/messages') return pathname.startsWith('/messages') && pathname !== '/messages/slf';
return pathname === to || pathname.startsWith(`${to}/`);
}
function NavRailButton({ item }: { item: NavRailItem }) {
const location = useLocation();
const isActive = isNavItemActive(item.to, location.pathname);
return (
<NavLink
// Plain Link, not NavLink -- NavLink computes its OWN `aria-current` from ITS OWN prefix-match
// logic unconditionally (confirmed by reading react-router's source: passing an `aria-current`
// prop only changes the STRING used, not whether it's applied at all, which stays gated on
// NavLink's internal isActive). That's exactly `/files`'s own problem above -- NavLink's naive
// match would light up "消息" for `/messages/slf`, not "文件助手" -- so a second, independent
// aria-current computation fighting the same override this file already needed for `className`
// is a real, verifiable a11y bug (screen readers and sighted highlighting would disagree on
// which icon is current). Driving both off the one already-correct `isActive` avoids that
// entirely.
<Link
to={item.to}
title={item.label}
className={({ isActive }) =>
`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary'
}`
}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary'
}`}
>
<Icon name={item.icon} className="!text-2xl" />
{item.badge ? (
@@ -38,7 +60,7 @@ function NavRailButton({ item }: { item: NavRailItem }) {
{item.badge}
</b>
) : null}
</NavLink>
</Link>
);
}
+38 -6
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Toast } from '@/app/Toast';
import { restoreSession } from '@/lib/tinode/connection';
import { isTenantMismatch } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
@@ -27,10 +28,12 @@ export function RequireAuth() {
const tenant = useSessionStore((s) => s.tenant);
const myUserId = useSessionStore((s) => s.myUserId);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const clearTenant = useSessionStore((s) => s.clearTenant);
const [hasToken] = useState(() => !!sessionStorage.getItem('im-session-token'));
const [restoreFailed, setRestoreFailed] = useState(false);
const [restoreTimedOut, setRestoreTimedOut] = useState(false);
const [contactsError, setContactsError] = useState('');
const meSubscribedRef = useRef(false);
useEffect(() => {
if (myUserId || !tenant || !hasToken) return undefined;
@@ -44,11 +47,16 @@ export function RequireAuth() {
.then((uid) => {
if (cancelled) return;
setMyUserId(uid);
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
})
.catch(() => {
.catch((err) => {
if (!cancelled) {
sessionStorage.removeItem('im-session-token');
// docs/IM-前后端对接文档.md: a stale token from a DIFFERENT tenant than the one currently
// resolved fails with this specific reason, whose prescribed recovery is "清 Token 并回
// 企业码页" -- clearing the tenant here lets the fallback `<Navigate to="/login"/>` below
// do that for free, since LoginPage's own `if (!tenant)` guard bounces straight to
// `/tenant`, rather than adding a second, parallel navigation path.
if (isTenantMismatch(err)) clearTenant();
setRestoreFailed(true);
}
})
@@ -59,7 +67,19 @@ export function RequireAuth() {
cancelled = true;
window.clearTimeout(timeout);
};
}, [tenant, myUserId, hasToken, setMyUserId]);
}, [tenant, myUserId, hasToken, setMyUserId, clearTenant]);
// Single place that ensures the `me` topic gets subscribed once `myUserId` is known, regardless
// of HOW it became known (session restore above, or a fresh login/register that already called
// setMyUserId before this component ever mounted) -- LoginPage/RegisterPage used to each fire
// their own subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly
// logged-in user with an empty conversation list and no error on a transient failure. Centralizing
// here means it happens exactly once per authenticated mount and surfaces the same Toast either way.
useEffect(() => {
if (!myUserId || meSubscribedRef.current) return;
meSubscribedRef.current = true;
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
}, [myUserId]);
if (!tenant) return <Navigate to="/tenant" replace />;
if (myUserId) {
@@ -70,10 +90,22 @@ export function RequireAuth() {
</>
);
}
if (hasToken && !restoreFailed && !restoreTimedOut) {
// `hasToken` is a mount-time snapshot, not live state -- re-checking sessionStorage directly here
// (rather than trusting that stale flag alone) catches the token having been cleared elsewhere in
// the meantime (connection.ts's reconnect-triggered restoreSession failure, or client.ts's global
// 401 handler both clear it without this component's own effect ever running). Without this,
// `hasToken` would still read true and the branch below would show "正在恢复登录状态…" forever --
// there's no token left to restore and nothing would ever resolve that screen.
if (hasToken && !restoreFailed && sessionStorage.getItem('im-session-token')) {
// restoreTimedOut only switches the MESSAGE, it must never force a navigate here -- doing so
// would unmount this component, which sets the effect's `cancelled` flag and makes the still-
// in-flight restoreSession() promise discard its own result via `if (cancelled) return`. A
// merely-slow connection (10s+ round trip on cellular/VPN, not necessarily a dead host) would
// otherwise get bounced to a forced re-login even when the restore was about to succeed a moment
// later. Only an actual rejection (restoreFailed) or the user's own click below should leave.
return (
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-surface-page text-sm text-text-tertiary-3">
正在恢复登录状态…
{restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'}
<a href="/login" className="text-accent no-underline">
改为重新登录
</a>
-7
View File
@@ -1,7 +0,0 @@
export function SectionPlaceholder({ title }: { title: string }) {
return (
<div className="flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
{title}(后续阶段实现)
</div>
);
}
+6 -1
View File
@@ -29,7 +29,12 @@ export function Toast({ message, variant = 'success', onDone }: ToastProps) {
return (
<div
role="status"
className="fixed bottom-8 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-3 text-sm text-white"
// `position:fixed` resolves against the viewport, not AppShell's own Outlet box -- below 640px
// that viewport bottom is where the persistent BottomTabBar (82px tall) already sits, so the
// unconditional `bottom-12` this used to be sat squarely on top of it on every phone-width
// screen, for every Toast call site in the app. `sm:bottom-12` restores the original 48px gap
// once the tab bar is gone (>=640px, NavRail shown instead).
className="fixed bottom-[130px] left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-2.5 text-sm text-white sm:bottom-12"
>
<Icon name={variant === 'error' ? 'error' : 'check_circle'} className={`text-[18px] ${variant === 'error' ? 'text-danger-text' : 'text-online'}`} />
{message}