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>
87 lines
3.9 KiB
TypeScript
87 lines
3.9 KiB
TypeScript
import { Link, useLocation } from 'react-router-dom';
|
|
|
|
import { Icon } from './Icon';
|
|
|
|
interface NavRailItem {
|
|
to: string;
|
|
icon: string;
|
|
label: string;
|
|
badge?: number;
|
|
}
|
|
|
|
const TOP_ITEMS: NavRailItem[] = [
|
|
{ to: '/messages', icon: 'chat_bubble', label: '消息' },
|
|
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
|
|
{ to: '/favorites', icon: 'inventory_2', label: '收藏' },
|
|
{ to: '/files', icon: 'folder', label: '文件助手' },
|
|
];
|
|
|
|
const BOTTOM_ITEMS: NavRailItem[] = [
|
|
{ to: '/calls', icon: 'phone', label: '通话记录' },
|
|
{ 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 (
|
|
// 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}
|
|
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 ? (
|
|
<b className="absolute top-1 right-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-unread px-1 text-[10px] leading-none font-medium text-white">
|
|
{item.badge}
|
|
</b>
|
|
) : null}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
/** Persistent 64px icon rail, visible at >=640px (see App.tsx for the <640 bottom-tab fallback). */
|
|
export function NavRail({ selfInitial = '我', unreadMessages = 0 }: { selfInitial?: string; unreadMessages?: number }) {
|
|
return (
|
|
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-[#e6e9ee] bg-surface-rail pb-3 pt-3.5">
|
|
<div className="mb-[22px] grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-semibold text-white">
|
|
{selfInitial}
|
|
</div>
|
|
<div className="flex flex-1 flex-col items-center gap-1.5">
|
|
{TOP_ITEMS.map((item) => (
|
|
<NavRailButton key={item.to} item={item.to === '/messages' ? { ...item, badge: unreadMessages } : item} />
|
|
))}
|
|
</div>
|
|
<div className="flex flex-col items-center gap-1.5">
|
|
{BOTTOM_ITEMS.map((item) => (
|
|
<NavRailButton key={item.to} item={item} />
|
|
))}
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|