Files
im_webapp/web/src/app/NavRail.tsx
T
Your NameandClaude Sonnet 5 5c3f3b7653 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>
2026-09-09 20:16:33 +08:00

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>
);
}