import { useLayoutEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate } 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 { blockUser, deleteFriend, getBlocks, getConversationSettings, getFriends, getUserProfile, unblockUser, updateConversationSettings, updateFriendRemark, } from '@/lib/api/friends'; import type { MediaType } from '@/lib/api/messages'; import { getMe } from '@/lib/api/profile'; import { useTinode } from '@/lib/tinode/hooks'; import { getP2PPeerUserId } from '@/lib/tinode/topics'; import { useCallStore } from '@/stores/calls'; import { requestConfirm } from '@/stores/confirmDialog'; import { notify } from '@/stores/globalToast'; import { useSessionStore } from '@/stores/session'; 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 ( ); } 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]); // `slf` (self-chat, e.g. opened via a chat-list row once it has real history -- there is no // "文件助手" entry point pointing at it anymore, see ChatListColumn.tsx's PINNED_ENTRIES doc // comment) isn't a friend and isn't really "someone else" at all -- getUserProfile('slf') has no // real user to look up, and every friend-only action below (remark/block/report/delete friend) // is nonsensical pointed at yourself. Gated out entirely rather than left to silently no-op or // error, confirmed live: this used to render "加入黑名单"/"投诉举报"/"删除好友" for your own notes // to yourself. const isSelf = topicId === 'slf'; const myUserId = useSessionStore((s) => s.myUserId); // NOT topicId -- see getP2PPeerUserId's own doc comment. Every lookup below that means "the other // user" (profile, friends-list match, block, remark) has to go through this, not the raw topic id, // because the two stop being interchangeable the instant this conversation has any real history. const peerUserId = isSelf ? topicId : getP2PPeerUserId(tinode.getTopic(topicId), myUserId); const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) }); const friend = friendsQuery.data?.find((f) => f.user_id === peerUserId); const profileQuery = useQuery({ queryKey: ['user-profile', peerUserId], queryFn: () => getUserProfile(peerUserId || ''), // `!isSelf` alone isn't enough now that peerUserId can genuinely be undefined for a beat (the // subscriber list not loaded yet) -- without also gating on it, this fires with `undefined` the // instant isSelf is false, same class of guaranteed-404 bug this whole fix targets, just with // the literal string "undefined" as the id instead of the p2p topic name. enabled: !isSelf && !!peerUserId, }); const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region] .filter(Boolean) .join(' · '); // Same real gap as ChatListColumn/ConversationView's avatars -- this panel's header tile only ever // drew the letter fallback, even though `profileQuery`/`friendsQuery` right above already carry a // real `avatar_file_id` (that's how ContactsListColumn/FriendProfileView show it correctly). `friend` // (the `/friends` list, same source the contacts pages already trust) takes priority since it's the // one confirmed accurate; `profileQuery` covers a P2P peer who isn't (or is no longer) a friend. const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf }); const avatarFileId = isSelf ? meQuery.data?.profile.avatar_file_id : (friend?.avatar_file_id ?? profileQuery.data?.avatar_file_id); 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(peerUserId, remark), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['friends'] }); setRemarkEditing(false); }, onError: (err) => showError(err, '备注保存失败'), }); const blockMutation = useMutation({ mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)), 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 === peerUserId); const deleteFriendMutation = useMutation({ mutationFn: () => deleteFriend(peerUserId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['friends'] }); navigate('/messages'); }, onError: (err) => showError(err, '删除好友失败'), }); const clearHistory = async () => { if (!(await requestConfirm({ message: '确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。', danger: true }))) return; tinode.getTopic(topicId).delMessagesAll(false); }; const startDeleteFriend = async () => { if (!(await requestConfirm({ message: `确定删除好友 ${peerName} 吗?`, danger: true }))) return; deleteFriendMutation.mutate(); }; const reportAbuse = () => { notify('该功能暂未开放,如需投诉举报请联系客服。'); }; return ( ); }