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>
286 lines
13 KiB
TypeScript
286 lines
13 KiB
TypeScript
import { useLayoutEffect, 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 {
|
||
blockUser,
|
||
deleteFriend,
|
||
getBlocks,
|
||
getConversationSettings,
|
||
getFriends,
|
||
getUserProfile,
|
||
unblockUser,
|
||
updateConversationSettings,
|
||
updateFriendRemark,
|
||
} from '@/lib/api/friends';
|
||
import type { MediaType } from '@/lib/api/messages';
|
||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||
import { useTinode } from '@/lib/tinode/hooks';
|
||
import { useCallStore } from '@/stores/calls';
|
||
|
||
interface DetailColumnProps {
|
||
topicId: string;
|
||
peerName: string;
|
||
onClose?: () => void;
|
||
onOpenHistory: (tab: 'text' | MediaType) => void;
|
||
}
|
||
|
||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={checked}
|
||
disabled={disabled}
|
||
onClick={onChange}
|
||
// README §3.4: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
|
||
// (18×18 knob) -- same fix as GroupDetailColumn.tsx's identical Toggle copy. The travel
|
||
// distance stays 18px at both sizes since track and knob grow by the same 4px.
|
||
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||
>
|
||
<span
|
||
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: DetailColumnProps) {
|
||
const navigate = useNavigate();
|
||
const tinode = useTinode();
|
||
const queryClient = useQueryClient();
|
||
const [remarkEditing, setRemarkEditing] = useState(false);
|
||
const [remarkDraft, setRemarkDraft] = useState('');
|
||
const [errorToast, setErrorToast] = useState('');
|
||
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
||
|
||
// DetailColumn is reused across every contact when docked (no `key={topicId}`, same instance),
|
||
// so without this an in-progress remark edit for the PREVIOUS contact stays open and could be
|
||
// saved against the NEW one, and a leftover error toast would keep showing over the new contact
|
||
// misattributing the failure. useLayoutEffect (not useEffect) so this resolves before paint --
|
||
// this component never remounts on topicId change, so a post-paint effect can let one frame
|
||
// render with the previous contact's stale edit box still open.
|
||
useLayoutEffect(() => {
|
||
setRemarkEditing(false);
|
||
setRemarkDraft('');
|
||
setErrorToast('');
|
||
}, [topicId]);
|
||
|
||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||
const friend = friendsQuery.data?.find((f) => f.user_id === topicId);
|
||
|
||
const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId) });
|
||
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
|
||
const settingsQuery = useQuery({
|
||
queryKey: ['conversation-settings', topicId],
|
||
queryFn: () => getConversationSettings(topicId),
|
||
});
|
||
|
||
const updateSettings = useMutation({
|
||
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) =>
|
||
updateConversationSettings(topicId, { ...patch, version: settingsQuery.data?.version ?? 0 }),
|
||
onSuccess: (data) => queryClient.setQueryData(['conversation-settings', topicId], data),
|
||
onError: (err) => showError(err, '设置失败,请重试'),
|
||
});
|
||
|
||
const remarkMutation = useMutation({
|
||
mutationFn: (remark: string) => updateFriendRemark(topicId, remark),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||
setRemarkEditing(false);
|
||
},
|
||
onError: (err) => showError(err, '备注保存失败'),
|
||
});
|
||
|
||
const blockMutation = useMutation({
|
||
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
||
onError: (err) => showError(err, '设置失败,请重试'),
|
||
});
|
||
|
||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === topicId);
|
||
|
||
const deleteFriendMutation = useMutation({
|
||
mutationFn: () => deleteFriend(topicId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||
navigate('/messages');
|
||
},
|
||
onError: (err) => showError(err, '删除好友失败'),
|
||
});
|
||
|
||
const clearHistory = () => {
|
||
if (!window.confirm('确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。')) return;
|
||
tinode.getTopic(topicId).delMessagesAll(false);
|
||
};
|
||
|
||
const startDeleteFriend = () => {
|
||
if (!window.confirm(`确定删除好友 ${peerName} 吗?`)) return;
|
||
deleteFriendMutation.mutate();
|
||
};
|
||
|
||
const reportAbuse = () => {
|
||
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
|
||
};
|
||
|
||
return (
|
||
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[320px] lg:w-[300px]">
|
||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||
<strong className="text-[15px] font-semibold">聊天设置</strong>
|
||
{onClose ? (
|
||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||
<Icon name="close" />
|
||
</button>
|
||
) : null}
|
||
</header>
|
||
|
||
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
|
||
<span className={`grid h-18 w-18 place-items-center rounded-2xl text-2xl font-medium text-white ${avatarBgClass(topicId)}`}>
|
||
{avatarLetter(peerName)}
|
||
</span>
|
||
<strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong>
|
||
{idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null}
|
||
{profileQuery.data?.signature ? (
|
||
<p className="m-0 text-center text-[13px] text-text-secondary">{profileQuery.data.signature}</p>
|
||
) : null}
|
||
<div className="mt-3 flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent"
|
||
>
|
||
<Icon name="chat_bubble_outline" className="text-[20px]" />
|
||
<span className="text-xs text-text-secondary">发消息</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={profileQuery.data?.capabilities?.can_call === false}
|
||
onClick={() => {
|
||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
|
||
}}
|
||
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
|
||
>
|
||
<Icon name="call" className="text-[20px]" />
|
||
<span className="text-xs text-text-secondary">语音</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={profileQuery.data?.capabilities?.can_call === false}
|
||
onClick={() => {
|
||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
|
||
}}
|
||
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
|
||
>
|
||
<Icon name="videocam" className="text-[20px]" />
|
||
<span className="text-xs text-text-secondary">视频</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-b border-border-list-item px-5 pb-3 pt-2 text-sm">
|
||
<button type="button" onClick={() => onOpenHistory('text')} className="flex w-full items-center justify-between py-2">
|
||
<span className="text-text-body">查找聊天记录</span>
|
||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||
</button>
|
||
<div className="mt-1 grid grid-cols-4 gap-2">
|
||
{(
|
||
[
|
||
{ type: 'image' as const, label: '图片', icon: 'image' },
|
||
{ type: 'video' as const, label: '视频', icon: 'videocam' },
|
||
{ type: 'file' as const, label: '文件', icon: 'description' },
|
||
{ type: 'link' as const, label: '链接', icon: 'link' },
|
||
] satisfies { type: MediaType; label: string; icon: string }[]
|
||
).map((item) => (
|
||
<button key={item.type} type="button" onClick={() => onOpenHistory(item.type)} className="flex flex-col items-center gap-1 py-1">
|
||
<Icon name={item.icon} className="text-[22px] text-text-tertiary-4" />
|
||
<span className="text-xs text-text-secondary">{item.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col border-b border-border-list-item px-5 text-sm">
|
||
{remarkEditing ? (
|
||
<div className="flex items-center gap-2 py-3">
|
||
<span className="flex-none text-text-body">备注</span>
|
||
<input
|
||
autoFocus
|
||
value={remarkDraft}
|
||
onChange={(e) => setRemarkDraft(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && remarkMutation.mutate(remarkDraft)}
|
||
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||
/>
|
||
<button type="button" onClick={() => remarkMutation.mutate(remarkDraft)} className="flex-none text-accent">
|
||
保存
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setRemarkDraft(friend?.remark || '');
|
||
setRemarkEditing(true);
|
||
}}
|
||
className="flex items-center justify-between py-3 text-left"
|
||
>
|
||
<span className="text-text-body">备注</span>
|
||
<span className="flex items-center gap-0.5 text-[13px] text-text-tertiary-2">
|
||
{friend?.remark || '未设置'}
|
||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||
</span>
|
||
</button>
|
||
)}
|
||
<div className="flex items-center justify-between py-3">
|
||
<span className="text-text-body">置顶聊天</span>
|
||
<Toggle
|
||
checked={!!settingsQuery.data?.pinned}
|
||
disabled={!settingsQuery.data || updateSettings.isPending}
|
||
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between py-3">
|
||
<span className="text-text-body">消息免打扰</span>
|
||
<Toggle
|
||
checked={!!settingsQuery.data?.mute_notifications}
|
||
disabled={!settingsQuery.data || updateSettings.isPending}
|
||
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between py-3">
|
||
<span className="text-text-body">加入黑名单</span>
|
||
<Toggle checked={isBlocked} disabled={blockMutation.isPending} onChange={() => blockMutation.mutate(!isBlocked)} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col px-5 pb-2 text-sm">
|
||
<button type="button" onClick={clearHistory} className="flex items-center justify-between py-3 text-left">
|
||
<span className="text-text-body">清空聊天记录</span>
|
||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||
</button>
|
||
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
|
||
<span className="text-text-body">投诉举报</span>
|
||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={startDeleteFriend}
|
||
className="mx-5 my-4 h-10 flex-none rounded-lg border border-danger-border-soft text-sm text-danger-text"
|
||
>
|
||
删除好友
|
||
</button>
|
||
|
||
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||
</aside>
|
||
);
|
||
}
|