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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user