用户报告:控制台反复出现 GET /v1/im/users/p2pp77kUAkgqpVbZoiTpjPWtg/profile
404。根因:这个应用的多处代码(DetailColumn的好友资料/拉黑/备注/删除好友,以及
本会话早前刚加的头像peerProfileQuery)一直假设"P2P会话的topicId就是对方的
user_id"——这个假设只在"第一次用对方usr...地址打开、还从未真正subscribe过"
这个短暂窗口内成立。读tinode-sdk源码确认:一旦subscribe成功,SDK会把这个话题
从usr...地址重命名为服务端分配的真实p2p...哈希名,而Topic.topicType()自己的
前缀表里根本没有p2p这一项(只有usr映射到P2P类型),导致topic.isP2PType()/
topic.p2pPeerDesc()对已建立的会话恒定返回false/undefined——SDK自己内建的"取
对方身份"方法在这个场景下也是坏的,不只是这个项目自己的代码有问题。
只要一个P2P会话有过真实的消息历史(几乎所有现存对话都是这样),它的topicId
就是这个不能反解的p2p...哈希,而不是对方user_id,直接拿去查/v1/im/users/{id}/
profile、匹配好友列表的user_id、传给拉黑/删除好友接口,全部会用错id。
修复:topics.ts新增getP2PPeerUserId(topic, myUserId)——topicId本身是usr...
前缀时直接返回(还没subscribe过,本来就对),否则读topic.subscribers()真实
的订阅者列表,找出不是自己的那个人的真实user_id;订阅者列表还没加载出来时返回
undefined而不是猜一个不可能对的值,调用方原本就有的enabled门控会正确地等待
而不是提前拿错误id发请求。tinode-sdk.d.ts补充subscribers()方法和Subscription
类型声明(原来完全没声明这个方法)。DetailColumn.tsx/ConversationView.tsx改用
这个真实user_id去查资料/匹配好友/头像取色,不再直接假设topicId能用。
实机验证:真实会话grpMc5uN...不对,是p2pp77kUAkgqpVbZoiTpjPWtg(systest04↔
systest02,有真实历史消息的老会话)——修复前详情栏"ID:"整行空白、404貫穿控制
台;修复后详情栏正确显示"ID:usrp77kUAkgqpU",好友资料/备注/拉黑状态全部正确
关联到真实好友记录。
328 lines
16 KiB
TypeScript
328 lines
16 KiB
TypeScript
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 (
|
||
<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]);
|
||
|
||
// `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 (
|
||
<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">
|
||
<Avatar id={peerUserId || topicId} name={peerName} avatarFileId={avatarFileId} className="h-18 w-18 rounded-2xl text-2xl" />
|
||
<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>
|
||
{isSelf ? null : (
|
||
<>
|
||
<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">
|
||
{isSelf ? null : 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>
|
||
{isSelf ? null : (
|
||
<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>
|
||
{isSelf ? null : (
|
||
<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>
|
||
|
||
{isSelf ? null : (
|
||
<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>
|
||
);
|
||
}
|