后端目前没有支持文件助手所需的配额/分类等能力,只是把它当作 普通话题处理;与其做一个功能不全的界面,先从导航栏、会话列表 置顶区、通讯录固定入口、设置页移动端快捷入口中移除该入口。slf 话题现在按普通会话行为处理(排序、未读计数均不再特殊处理)。 同时修复 DetailColumn 对自聊天(slf)显示语音/视频通话、备注、 拉黑、举报、删除好友等对自己无意义操作的问题,改为仅显示适用 的发消息/查找聊天记录/置顶/免打扰/清空聊天记录等项。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
9.2 KiB
TypeScript
174 lines
9.2 KiB
TypeScript
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: '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 + fixed entries (新朋友/群聊/黑名单, 文件助手 removed -- see
|
|
* ChatListColumn.tsx's PINNED_ENTRIES doc comment for why) + 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>
|
|
);
|
|
}
|