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