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,161 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getBlocks } from '@/lib/api/friends';
|
||||
import { getMyPrivacy, updateMyPrivacy, type AddMeMethods } from '@/lib/api/profile';
|
||||
|
||||
const ADD_ME_LABELS: { key: keyof AddMeMethods; label: string }[] = [
|
||||
{ key: 'account', label: '通过用户 ID' },
|
||||
{ key: 'phone', label: '通过手机号' },
|
||||
{ key: 'qr', label: '通过二维码' },
|
||||
{ key: 'group', label: '通过群聊' },
|
||||
];
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'everyone', label: '所有人' },
|
||||
{ value: 'friends', label: '仅好友' },
|
||||
{ value: 'nobody', label: '不展示' },
|
||||
];
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* screens/07-profile-settings.dc.html#1c: 添加我的方式 (4 toggles, ungrouped -- no dividers between
|
||||
* them, just the section label above) sits in its own card; 黑名单 sits in a second card alone
|
||||
* (that mockup also puts "加我为好友时需要验证" there, but that toggle has NO backing field anywhere
|
||||
* in this response shape or the group-invite backend logic -- confirmed absent, not built). The
|
||||
* visibility/read-receipts rows below have no mockup at all (added once the real backend fields were
|
||||
* found during research) -- given their own third card rather than folding them into either of the
|
||||
* above, so a real capability the design never anticipated doesn't look like it was crammed in.
|
||||
*/
|
||||
export function PrivacySettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const query = useQuery({ queryKey: ['my-privacy'], queryFn: getMyPrivacy });
|
||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (patch: Parameters<typeof updateMyPrivacy>[0]) => updateMyPrivacy(patch),
|
||||
// Merge, don't replace -- the doc's own worked example for this exact endpoint (line 2494)
|
||||
// returns a PARTIAL object (`{add_me_methods,read_receipts,version,updated_at}`, missing
|
||||
// `profile_visibility`/`last_seen_visibility`/`editable`). A blind replace would erase those
|
||||
// from the cache after every single save, most importantly `editable`: once it silently
|
||||
// disappeared, `const editable = query.data.editable || {}` re-derives an EMPTY object, which
|
||||
// re-enables every field a tenant policy had actually locked, until an unrelated refetch happens
|
||||
// to restore it.
|
||||
onSuccess: (data) => queryClient.setQueryData(['my-privacy'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
|
||||
});
|
||||
const patch = (partial: Partial<Omit<Parameters<typeof updateMyPrivacy>[0], 'version'>>) => {
|
||||
if (!query.data) return;
|
||||
mutation.mutate({ ...partial, version: query.data.version });
|
||||
};
|
||||
|
||||
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
|
||||
// `isLoading` alone used to gate this: it turns false once the fetch SETTLES, including into an
|
||||
// error, but `query.data` stays undefined either way -- the page was falling through to the same
|
||||
// "加载中…" text forever on a genuine failure (a slow/dropped connection, not just "still loading"),
|
||||
// with no retry affordance at all. Same fix already applied to SystemMessagesView's own query.
|
||||
if (query.isError || !query.data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const editable = query.data.editable || {};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-5 text-xl font-semibold text-text-heading">隐私</h2>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="px-7 pb-1.5 pt-4 text-[13px] text-text-tertiary-2">添加我的方式</div>
|
||||
{ADD_ME_LABELS.map((item, i) => (
|
||||
<div key={item.key} className={`flex items-center justify-between px-7 py-3 ${i === ADD_ME_LABELS.length - 1 ? 'pb-4.5' : ''}`}>
|
||||
<span className="text-text-body">{item.label}</span>
|
||||
<Toggle
|
||||
checked={query.data.add_me_methods[item.key]}
|
||||
disabled={editable.add_me_methods === false || mutation.isPending}
|
||||
onChange={() => patch({ add_me_methods: { [item.key]: !query.data.add_me_methods[item.key] } })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => navigate('/contacts/blocklist')} className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm">
|
||||
<span className="text-text-body">黑名单</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
{blocksQuery.data?.length ?? 0} 人
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
|
||||
<span className="text-text-body">资料可见范围</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||
{VISIBILITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={editable.profile_visibility === false || mutation.isPending}
|
||||
onClick={() => patch({ profile_visibility: opt.value })}
|
||||
className={`px-3 py-1.5 ${query.data.profile_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
|
||||
<span className="text-text-body">最近在线可见范围</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||
{VISIBILITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={editable.last_seen_visibility === false || mutation.isPending}
|
||||
onClick={() => patch({ last_seen_visibility: opt.value })}
|
||||
className={`px-3 py-1.5 ${query.data.last_seen_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-7 py-4">
|
||||
<span className="text-text-body">已读回执</span>
|
||||
<Toggle
|
||||
checked={query.data.read_receipts}
|
||||
disabled={editable.read_receipts === false || mutation.isPending}
|
||||
onChange={() => patch({ read_receipts: !query.data.read_receipts })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user