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,158 @@
|
||||
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 { 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>
|
||||
) : 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 ? (
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full border text-xs text-white ${
|
||||
selected.has(b.user.user_id) ? 'border-accent bg-accent' : 'border-[#d5d9df] bg-white'
|
||||
}`}
|
||||
>
|
||||
{selected.has(b.user.user_id) ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</span>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user