Files
im_webapp/web/src/features/messages/DetailColumn.tsx
T
Your NameandClaude Sonnet 5 5c3f3b7653 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>
2026-09-09 20:16:33 +08:00

286 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}