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
@@ -0,0 +1,173 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { fetchAllPages } from '@/lib/api/client';
import { getBlocks, getFriendRequests, getFriends } from '@/lib/api/friends';
const FIXED_ENTRIES = [
{ key: 'requests', route: '/contacts/requests', icon: 'person_add', label: '新朋友', colorClass: 'bg-warn-bg text-warn' },
{ key: 'groups', route: '/contacts/groups', icon: 'groups', label: '群聊', colorClass: 'bg-accent-soft-bg text-accent' },
{ key: 'files', route: '/files', icon: 'folder', label: '文件助手', colorClass: 'bg-success-bg text-success' },
{ key: 'blocklist', route: '/contacts/blocklist', icon: 'block', label: '黑名单', colorClass: 'bg-[#f3efff] text-[#7c5cf6]' },
// README §5.7: mobile-only -- desktop already reaches this via ChatList's own pinned row (§2.1),
// §5.1's desktop entry list doesn't include it.
{ key: 'system', route: '/messages/system', icon: 'notifications', label: '系统消息', colorClass: 'bg-warn-bg text-warn', mobileOnly: true },
] as const;
/** Server-computed `initial` (doc line 1417) sorts CJK names correctly without a client-side pinyin
* library; falls back to the display name's own first character for the rare item missing one. */
function groupInitial(name: string, initial: string | undefined): string {
const raw = (initial || name || '').trim().charAt(0).toUpperCase();
return /[A-Z]/.test(raw) ? raw : '#';
}
/**
* README §5.1 (320px): search + 4 fixed entries (新朋友/群聊/文件助手/黑名单) + letter-grouped
* friend list + a right-side letter index. Friend list is fetched once (fetchAllPages) and both
* grouped and searched client-side, same convention ChatListColumn already uses for conversations
* -- friend lists are the same small-to-medium scale, no reason to hit the server per keystroke.
*/
export function ContactsListColumn() {
const { userId } = useParams();
const [query, setQuery] = useState('');
const [addingFriend, setAddingFriend] = useState(false);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
// Unlike friends/blocks (both already fetchAllPages), this used to read only page 1 -- the request
// COUNT BADGE would silently undercount for any account with more than one page of pending requests.
const requestsQuery = useQuery({
queryKey: ['friend-requests', 'inbound', 'pending'],
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'pending', cursor)),
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const trimmedQuery = query.trim().toLowerCase();
const friends = friendsQuery.data || [];
const filtered = trimmedQuery ? friends.filter((f) => (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmedQuery)) : friends;
const groups = useMemo(() => {
const byLetter = new Map<string, typeof filtered>();
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || '').localeCompare(b.remark || b.display_name || '', 'zh'))) {
const letter = groupInitial(f.remark || f.display_name || f.user_id, f.initial);
byLetter.set(letter, [...(byLetter.get(letter) || []), f]);
}
return [...byLetter.entries()].sort(([a], [b]) => (a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b)));
}, [filtered]);
const pendingRequestCount = requestsQuery.data?.length ?? 0;
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">通讯录</h1>
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-8 w-8 place-items-center text-text-heading">
<Icon name="person_add_alt" />
</button>
</header>
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索联系人"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
<div className="relative flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto">
{trimmedQuery ? null : (
<>
{FIXED_ENTRIES.map((entry) => {
const badge =
entry.key === 'requests'
? pendingRequestCount
: entry.key === 'blocklist'
? (blocksQuery.data?.length ?? 0)
: 0;
return (
<Link
key={entry.key}
to={entry.route}
className={`flex h-13 flex-none items-center gap-3 border-b border-border-list-item px-4 no-underline hover:bg-[#f6f8fb] ${
'mobileOnly' in entry && entry.mobileOnly ? 'md:hidden' : ''
}`}
>
{/* README §5.7: mobile fixed-entry icons/text are one size up from desktop's
36px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg md:h-9 md:w-9 ${entry.colorClass}`}>
<Icon name={entry.icon} className="text-xl" />
</span>
<strong className="flex-1 truncate text-base font-medium text-[#272b31] md:text-[15px]">{entry.label}</strong>
{entry.key === 'requests' && badge ? (
<b className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{badge}
</b>
) : entry.key === 'groups' ? (
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
) : entry.key === 'blocklist' && badge ? (
<span className="flex-none text-xs text-text-tertiary-3">{badge}</span>
) : null}
</Link>
);
})}
</>
)}
{friendsQuery.isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
) : filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
<Icon name={trimmedQuery ? 'search_off' : 'people_outline'} className="!text-4xl" />
<span className="text-sm">{trimmedQuery ? '未找到相关联系人' : '暂无联系人'}</span>
</div>
) : (
groups.map(([letter, items]) => (
<div key={letter} id={`contact-letter-${letter}`}>
<div className="bg-[#fafbfc] px-4 pb-1 pt-3.5 text-xs text-text-tertiary-2">{letter}</div>
{items.map((f) => {
const name = f.remark || f.display_name || f.user_id;
const active = userId === f.user_id;
return (
<Link
key={f.user_id}
to={`/contacts/${f.user_id}`}
className={`flex h-13.5 flex-none items-center gap-3 px-4 no-underline ${active ? 'bg-accent-selected-bg' : 'hover:bg-[#f6f8fb]'}`}
>
{/* README §5.7: mobile friend-row avatar/text are 44px/16px, one size up from
desktop's 40px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
<Avatar id={f.user_id} name={name} avatarFileId={f.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base md:h-10 md:w-10 md:text-sm" />
<span className="truncate text-base text-[#272b31] md:text-[15px]">{name}</span>
</Link>
);
})}
</div>
))
)}
</div>
{trimmedQuery || groups.length === 0 ? null : (
<div className="flex w-4 flex-none flex-col items-center justify-center gap-0.5 py-2">
{groups.map(([letter]) => (
<a
key={letter}
href={`#contact-letter-${letter}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(`contact-letter-${letter}`)?.scrollIntoView({ block: 'start' });
}}
className="text-[10px] leading-[14px] text-text-tertiary-2 hover:font-semibold hover:text-accent"
>
{letter}
</a>
))}
</div>
)}
</div>
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
</aside>
);
}