修复已建立的P2P会话详情栏/头像404:话题名不是对方user_id
用户报告:控制台反复出现 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",好友资料/备注/拉黑状态全部正确
关联到真实好友记录。
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
|||||||
closeConversation,
|
closeConversation,
|
||||||
ensureTopicAttached,
|
ensureTopicAttached,
|
||||||
forgetTopic,
|
forgetTopic,
|
||||||
|
getP2PPeerUserId,
|
||||||
markConfirmedRead,
|
markConfirmedRead,
|
||||||
openConversation,
|
openConversation,
|
||||||
setActiveConversationTopic,
|
setActiveConversationTopic,
|
||||||
@@ -463,16 +464,23 @@ export function ConversationView() {
|
|||||||
// `['me']` key NavRail/ChatListColumn/SettingsLayout already keep warm, so this is a cache read in
|
// `['me']` key NavRail/ChatListColumn/SettingsLayout already keep warm, so this is a cache read in
|
||||||
// the normal case, not a new request.
|
// the normal case, not a new request.
|
||||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
||||||
|
// NOT topicId -- see getP2PPeerUserId's own doc comment. A P2P topic with any real history is
|
||||||
|
// addressed by a real, opaque `p2p...` name, not the peer's `usr...` id (that only holds true in
|
||||||
|
// the brief window before the very first subscribe) -- confirmed live: without this, this query
|
||||||
|
// 404s (`GET /users/{p2p...}/profile`) for every established conversation, and only ever appeared
|
||||||
|
// to work in earlier testing because that test happened to use a conversation opened for the first
|
||||||
|
// time in the same session, before the SDK's own rename kicked in.
|
||||||
|
const peerUserId = !isGroup && !isSelf && topicId ? getP2PPeerUserId(tinode.getTopic(topicId), myUserId) : undefined;
|
||||||
// Real gap this was written to close: the chat header/message-row avatars only ever drew the
|
// Real gap this was written to close: the chat header/message-row avatars only ever drew the
|
||||||
// letter-tile fallback, even after a real custom avatar was uploaded and correctly showing up on
|
// letter-tile fallback, even after a real custom avatar was uploaded and correctly showing up on
|
||||||
// the contacts pages (FriendProfileView/ContactsListColumn) -- those read this exact same REST
|
// the contacts pages (FriendProfileView/ContactsListColumn) -- those read this exact same REST
|
||||||
// profile, the conversation screen just never fetched it. Same query DetailColumn already runs
|
// profile, the conversation screen just never fetched it. Same query DetailColumn already runs
|
||||||
// (`['user-profile', topicId]`), so this dedupes with it whenever the detail panel happens to be
|
// (`['user-profile', peerUserId]`), so this dedupes with it whenever the detail panel happens to be
|
||||||
// open, and only fetches for a real P2P peer, never for a group or `slf`.
|
// open, and only fetches for a real P2P peer, never for a group or `slf`.
|
||||||
const peerProfileQuery = useQuery({
|
const peerProfileQuery = useQuery({
|
||||||
queryKey: ['user-profile', topicId],
|
queryKey: ['user-profile', peerUserId],
|
||||||
queryFn: () => getUserProfile(topicId || ''),
|
queryFn: () => getUserProfile(peerUserId || ''),
|
||||||
enabled: !!topicId && !isGroup && !isSelf,
|
enabled: !!peerUserId,
|
||||||
});
|
});
|
||||||
const membersQuery = useQuery({
|
const membersQuery = useQuery({
|
||||||
queryKey: ['group-members', topicId],
|
queryKey: ['group-members', topicId],
|
||||||
@@ -525,6 +533,11 @@ export function ConversationView() {
|
|||||||
const myAvatarFileId = meQuery.data?.profile.avatar_file_id;
|
const myAvatarFileId = meQuery.data?.profile.avatar_file_id;
|
||||||
const peerAvatarFileId = isSelf ? myAvatarFileId : peerProfileQuery.data?.avatar_file_id;
|
const peerAvatarFileId = isSelf ? myAvatarFileId : peerProfileQuery.data?.avatar_file_id;
|
||||||
const headerAvatarFileId = isGroup ? groupProfileQuery.data?.avatar_file_id : peerAvatarFileId;
|
const headerAvatarFileId = isGroup ? groupProfileQuery.data?.avatar_file_id : peerAvatarFileId;
|
||||||
|
// Color-hash id, not the file lookup above -- topicId is right for a group (its own identity), but
|
||||||
|
// for P2P this should be the peer's stable usr id (peerUserId), matching the same hash ContactsListColumn/
|
||||||
|
// FriendProfileView use for this same person, rather than the p2p topic name (which isP2P/self also
|
||||||
|
// never had a usr-shaped id for in the first place).
|
||||||
|
const headerAvatarId = isGroup ? topicId : peerUserId || topicId;
|
||||||
// `peerName` is the GROUP's own name for a group topic, not any member's -- reusing it to label
|
// `peerName` is the GROUP's own name for a group topic, not any member's -- reusing it to label
|
||||||
// "who is being quoted" (as both the reply-composer preview and the actual sent quote block used
|
// "who is being quoted" (as both the reply-composer preview and the actual sent quote block used
|
||||||
// to) meant every single group-chat reply permanently displayed "回复 <群名>:…" for every member,
|
// to) meant every single group-chat reply permanently displayed "回复 <群名>:…" for every member,
|
||||||
@@ -824,7 +837,7 @@ export function ConversationView() {
|
|||||||
<Icon name="arrow_back_ios_new" />
|
<Icon name="arrow_back_ios_new" />
|
||||||
</Link>
|
</Link>
|
||||||
<button type="button" onClick={onToggleDetail} className="flex items-center gap-2.5 text-left">
|
<button type="button" onClick={onToggleDetail} className="flex items-center gap-2.5 text-left">
|
||||||
<Avatar id={topicId} name={peerName} avatarFileId={headerAvatarFileId} className="h-9.5 w-9.5 flex-none rounded-lg text-sm" />
|
<Avatar id={headerAvatarId} name={peerName} avatarFileId={headerAvatarFileId} className="h-9.5 w-9.5 flex-none rounded-lg text-sm" />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<strong className="text-base font-semibold text-text-heading">
|
<strong className="text-base font-semibold text-text-heading">
|
||||||
{peerName}
|
{peerName}
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import {
|
|||||||
import type { MediaType } from '@/lib/api/messages';
|
import type { MediaType } from '@/lib/api/messages';
|
||||||
import { getMe } from '@/lib/api/profile';
|
import { getMe } from '@/lib/api/profile';
|
||||||
import { useTinode } from '@/lib/tinode/hooks';
|
import { useTinode } from '@/lib/tinode/hooks';
|
||||||
|
import { getP2PPeerUserId } from '@/lib/tinode/topics';
|
||||||
import { useCallStore } from '@/stores/calls';
|
import { useCallStore } from '@/stores/calls';
|
||||||
import { requestConfirm } from '@/stores/confirmDialog';
|
import { requestConfirm } from '@/stores/confirmDialog';
|
||||||
import { notify } from '@/stores/globalToast';
|
import { notify } from '@/stores/globalToast';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
interface DetailColumnProps {
|
interface DetailColumnProps {
|
||||||
topicId: string;
|
topicId: string;
|
||||||
@@ -81,11 +83,24 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
|||||||
// error, confirmed live: this used to render "加入黑名单"/"投诉举报"/"删除好友" for your own notes
|
// error, confirmed live: this used to render "加入黑名单"/"投诉举报"/"删除好友" for your own notes
|
||||||
// to yourself.
|
// to yourself.
|
||||||
const isSelf = topicId === 'slf';
|
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 friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||||
const friend = friendsQuery.data?.find((f) => f.user_id === topicId);
|
const friend = friendsQuery.data?.find((f) => f.user_id === peerUserId);
|
||||||
|
|
||||||
const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId), enabled: !isSelf });
|
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]
|
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ');
|
.join(' · ');
|
||||||
@@ -110,7 +125,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
|||||||
});
|
});
|
||||||
|
|
||||||
const remarkMutation = useMutation({
|
const remarkMutation = useMutation({
|
||||||
mutationFn: (remark: string) => updateFriendRemark(topicId, remark),
|
mutationFn: (remark: string) => updateFriendRemark(peerUserId, remark),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||||
setRemarkEditing(false);
|
setRemarkEditing(false);
|
||||||
@@ -119,16 +134,16 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
|||||||
});
|
});
|
||||||
|
|
||||||
const blockMutation = useMutation({
|
const blockMutation = useMutation({
|
||||||
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
|
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
||||||
onError: (err) => showError(err, '设置失败,请重试'),
|
onError: (err) => showError(err, '设置失败,请重试'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||||
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === topicId);
|
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === peerUserId);
|
||||||
|
|
||||||
const deleteFriendMutation = useMutation({
|
const deleteFriendMutation = useMutation({
|
||||||
mutationFn: () => deleteFriend(topicId),
|
mutationFn: () => deleteFriend(peerUserId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||||
navigate('/messages');
|
navigate('/messages');
|
||||||
@@ -162,7 +177,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
|
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
|
||||||
<Avatar id={topicId} name={peerName} avatarFileId={avatarFileId} className="h-18 w-18 rounded-2xl text-2xl" />
|
<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>
|
<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}
|
{idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null}
|
||||||
{profileQuery.data?.signature ? (
|
{profileQuery.data?.signature ? (
|
||||||
|
|||||||
Vendored
+26
@@ -33,6 +33,18 @@ declare module 'tinode-sdk' {
|
|||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One entry from `Topic#subscribers()` -- fields confirmed live against a real P2P topic (only
|
||||||
|
* `user` is actually consumed anywhere in this app so far; the rest are declared for completeness). */
|
||||||
|
export interface Subscription {
|
||||||
|
user: string;
|
||||||
|
online?: boolean;
|
||||||
|
updated?: Date;
|
||||||
|
read?: number;
|
||||||
|
recv?: number;
|
||||||
|
acs?: { want?: string; given?: string; mode?: string };
|
||||||
|
public?: TopicDescPublic | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CredentialParam {
|
export interface CredentialParam {
|
||||||
meth: string;
|
meth: string;
|
||||||
val?: string;
|
val?: string;
|
||||||
@@ -191,6 +203,20 @@ declare module 'tinode-sdk' {
|
|||||||
isSelfType(): boolean;
|
isSelfType(): boolean;
|
||||||
getAccessMode(): AccessMode | undefined;
|
getAccessMode(): AccessMode | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates this topic's cached subscriber list, one `Subscription` per call -- for a P2P topic
|
||||||
|
* this is always exactly the two participants (self included), confirmed live. This is the only
|
||||||
|
* reliable way to recover the OTHER party's real `usr...` id once a P2P topic has been subscribed
|
||||||
|
* at least once: the SDK renames a freshly-addressed `usr...` topic to a real, opaque `p2p...`
|
||||||
|
* name the moment `subscribe()` succeeds (see topics.ts's `openConversation`), and -- confirmed by
|
||||||
|
* reading tinode.dev.js -- `Topic.topicType()`'s own prefix table has no `p2p` entry at all (only
|
||||||
|
* `usr` maps to the P2P type), so `isP2PType()`/`p2pPeerDesc()` silently stop working the instant
|
||||||
|
* a topic has this real name. `topic.name` itself is NOT a usable substitute for the peer's id
|
||||||
|
* past that point -- anything needing "the other user" (profile lookup, friends-list matching,
|
||||||
|
* block/remark) must go through this method instead.
|
||||||
|
*/
|
||||||
|
subscribers(callback: (sub: Subscription) => void): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterates locally-cached messages in `[sinceId, beforeId]` (both omitted = everything cached).
|
* Iterates locally-cached messages in `[sinceId, beforeId]` (both omitted = everything cached).
|
||||||
* `callback(msg, prevMsg, nextMsg, bufferIndex)` fires once per message, already resolved to the
|
* `callback(msg, prevMsg, nextMsg, bufferIndex)` fires once per message, already resolved to the
|
||||||
|
|||||||
@@ -38,6 +38,37 @@ export function getActiveConversationTopic(): string | null {
|
|||||||
return activeConversationTopic;
|
return activeConversationTopic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other party's real `usr...` id for a P2P conversation -- NOT the same as `topic.name` once a
|
||||||
|
* conversation has any real history. Real, live-confirmed bug this fixes: a P2P topic addressed by
|
||||||
|
* the peer's own `usr...` id (the only address that exists before the very first subscribe -- e.g.
|
||||||
|
* straight from a friend's profile) gets renamed by the SDK to a real, opaque `p2p...` name the
|
||||||
|
* instant `subscribe()` succeeds (see openConversation's own doc comment on this same rename). Every
|
||||||
|
* P2P conversation with actual history is addressed by this `p2p...` form from then on -- but reading
|
||||||
|
* tinode.dev.js confirms `Topic.topicType()`'s prefix table has no `p2p` entry at all (only `usr` maps
|
||||||
|
* to the P2P type), so `topic.isP2PType()`/`topic.p2pPeerDesc()` silently return false/undefined for
|
||||||
|
* every one of these topics, and code that assumed "the P2P topic name IS the peer's user id" (true
|
||||||
|
* only for the brief pre-first-subscribe window) breaks the moment that assumption stops holding --
|
||||||
|
* confirmed live: `GET /users/{topicName}/profile` 404s, `friends.find(f => f.user_id === topicName)`
|
||||||
|
* never matches, block/remark/avatar all silently target a user id that doesn't exist.
|
||||||
|
*
|
||||||
|
* Returns `undefined` (never `topic.name` as a guess) when the subscriber list isn't populated yet --
|
||||||
|
* live-confirmed this matters: on the render right after navigating straight to a `p2p...`-named
|
||||||
|
* conversation, `subscribers()` can still be empty for the one or two renders before the subscribe
|
||||||
|
* response fills it in, and returning `topic.name` as a placeholder there just fires the exact same
|
||||||
|
* doomed `GET /users/{p2p...}/profile` request this function exists to prevent, a render later. Every
|
||||||
|
* caller already guards its query with `enabled: !!peerUserId`, so `undefined` correctly makes it wait
|
||||||
|
* instead of firing once with a value it knows is wrong.
|
||||||
|
*/
|
||||||
|
export function getP2PPeerUserId(topic: Topic, myUserId: string | null | undefined): string | undefined {
|
||||||
|
if (topic.name.startsWith('usr')) return topic.name;
|
||||||
|
let peer: string | undefined;
|
||||||
|
topic.subscribers((sub) => {
|
||||||
|
if (!peer && sub.user !== myUserId) peer = sub.user;
|
||||||
|
});
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
const confirmedReadSeq = new Map<string, number>();
|
const confirmedReadSeq = new Map<string, number>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user