Files
im_webapp/web/src/features/contacts/BlocklistView.tsx
T
Your NameandClaude Sonnet 5 18d21a03bb fix(web): 第9轮审查批次3——通讯录域一批问题
- 手机号搜索加好友时来源永远上报"search"(账号搜索),NewFriendsView
  早就有phone的文案映射但一直用不到——现在按searchType正确区分
- 添加好友弹层三个快捷按钮布局改成横向(原来是纵向堆叠,和设计稿方向
  不一致),图标颜色改成强调色
- "已选择"状态标签的圆角从胶囊改成6px小圆角,和同弹层其它按钮风格一致
- 通讯录头部"加好友"按钮补上移动端尺寸(40px/桌面36px),之前和旁边
  FIXED_ENTRIES的断点处理不一致
- 我的二维码/名片二维码生成失败没有重试入口,只能关掉弹层重开——补上
  重试按钮,同时处理"铸造失败"和"canvas绘制失败"两种情况
- 通讯录域几乎所有列表(好友列表/新朋友/群聊列表/黑名单/好友主页/加
  好友搜索)都把请求失败误判成"没有数据",统一补上isError态+重试按
  钮;好友主页额外区分403(资料保护)/404(真的不存在)/其它错误三种情况,
  之前网络错误也会显示"未找到该用户"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:41:20 +08:00

159 lines
7.5 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { batchUnblockUsers, getBlocks, unblockUser } from '@/lib/api/friends';
import { formatEventTimestamp } from '@/lib/messages/time';
/** README §5.6. `getBlocks` nests the user under `.user` (see friends.ts's own comment on that
* endpoint's real shape, a doc-drift bug caught during Phase 1-2's review).
*
* Multi-select + batch-unblock has no dedicated mock anywhere in this design handoff (04-contacts.dc.html's
* own #1g only ever shows a single row, one 移出 button each) -- rather than invent a new interaction
* pattern from nothing, this reuses the SAME 多选/已选择N项/取消 header convention FavoritesLayout.tsx
* already established for its own multi-select, so it's consistent with something this app already does,
* not a one-off design.
*/
export function BlocklistView() {
const queryClient = useQueryClient();
const [error, setError] = useState('');
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const query = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const unblockMutation = useMutation({
mutationFn: (userId: string) => unblockUser(userId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
onError: (err) => setError(err instanceof Error ? err.message : '移出黑名单失败'),
});
const exitSelectMode = () => {
setSelectMode(false);
setSelected(new Set());
};
const bulkUnblockMutation = useMutation({
mutationFn: (userIds: string[]) => batchUnblockUsers(userIds),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
// A 200 here only means the REQUEST was accepted -- the doc's own worked example returns a
// per-item `failed[]` array alongside `items[]`, so some of the selected users can fail to
// unblock while the rest succeed. Silently treating this as full success (the same pattern
// already fixed for favorites/GroupAdminModal elsewhere in this app) would leave a user still
// on the blocklist with no indication anything went wrong.
if (data.failed?.length) setError(`${data.failed.length} 人移出失败,请重试`);
exitSelectMode();
},
onError: (err) => setError(err instanceof Error ? err.message : '批量移出失败'),
});
const toggleSelected = (userId: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
const items = query.data || [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
{selectMode ? (
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="text-sm text-text-secondary">
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
</span>
<span className="flex items-center gap-2">
<button
type="button"
onClick={() => bulkUnblockMutation.mutate(Array.from(selected))}
disabled={selected.size === 0 || bulkUnblockMutation.isPending}
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
>
<Icon name="remove_circle_outline" className="text-[18px]" />
移出
</button>
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
取消
</button>
</span>
</header>
) : (
<header className="flex h-15 flex-none items-center gap-1 border-b border-border-panel bg-white px-6">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="flex-1 text-base font-semibold text-text-heading">
黑名单{items.length ? ` ${items.length}` : ''}
</strong>
{items.length > 1 ? (
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
多选
</button>
) : null}
</header>
)}
<div className="flex flex-col gap-2.5 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">黑名单是空的</div>
) : (
items.map((b) => (
<div
key={b.user.user_id}
role={selectMode ? 'button' : undefined}
tabIndex={selectMode ? 0 : undefined}
onClick={selectMode ? () => toggleSelected(b.user.user_id) : undefined}
onKeyDown={selectMode ? (e) => e.key === 'Enter' && toggleSelected(b.user.user_id) : undefined}
className={`flex items-center gap-3 rounded-xl bg-white px-4 py-3 shadow-bubble ${selectMode ? 'cursor-pointer' : ''}`}
>
{selectMode ? <SelectionCheckbox selected={selected.has(b.user.user_id)} /> : null}
<Avatar
id={b.user.user_id}
name={b.user.display_name}
avatarFileId={b.user.avatar_file_id}
className="h-11 w-11 flex-none rounded-lg text-base grayscale"
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{b.user.display_name || b.user.user_id}</strong>
<span className="truncate text-xs text-text-tertiary-2">
拉黑于 {formatEventTimestamp(new Date(b.blocked_at))} · 对方无法向你发送消息或发起通话
</span>
</span>
{selectMode ? null : (
<button
type="button"
onClick={() => unblockMutation.mutate(b.user.user_id)}
disabled={unblockMutation.isPending}
className="h-8 flex-none rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
移出
</button>
)}
</div>
))
)}
{items.length ? (
<p className="m-0 mt-2 text-xs text-text-tertiary-3">移出黑名单后恢复通信权限,但不会自动恢复已删除的好友关系。</p>
) : null}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}