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:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user