修复Zentao批量报告的6个前端bug:来电UI不弹出/黑名单发送无提示/群名校验/群人数与通话记录显示uid
用户提供了一份Zentao导出的bug清单(#21/23/24/25/26/27/28共7条),逐条实机复现
排查,修复了其中6个真正的前端bug,1个是纯后端问题(禁言不生效)已记录到
docs/IM-后端问题清单.md,1个(好友申请404/500)未能独立复现只记录了测试团队
的原始报告,另1个(离线状态推送)实测后确认并非报告描述的"永久不更新",只是
有正常的心跳超时延迟。
- **#23 来电UI完全不弹出**(根因最深的一个):initCallSignaling用的是
`me.getMeTopic().contacts()`枚举联系人再查最新消息——但这个方法只反映`me`
话题自己的订阅同步进度,一个P2P话题可能早就真正订阅上、能收到消息,`me`的
同步却还没跟上,导致这个联系人在枚举里直接消失。用两个真实账号实机验证:
被叫方`me.contacts()`只枚举出1个群,主叫方完全没出现,来电消息静默丢失。
topics.ts里`getChatListSnapshot()`自己的注释早就写明了这个坑、并且已经改
用`mapTopics()`规避,只是`stores/calls.ts`这个监听器当时没有跟着改。现在
统一复用`getChatListSnapshot()`,实机验证来电卡片正确弹出。
- **#28 拉黑后发送消息无原因提示**:读tinode-sdk源码确认发送失败的具体原因
从架构上就拿不到——`Topic#publishMessage`自己的`.catch()`把服务端拒绝原因
吞掉、只留一个`_failed`标记,不会重新抛出。改成事前判断:新增
`getConversationContext()`(P2P专用,`relationship`字段区分
`blocked_by_me`/`blocked_by_peer`),发送区直接换成对应文案的横幅,两个
方向都拿真实账号互相拉黑验证过。
- **#26 群名超长/留空自动名两个校验问题**:`MAX_TITLE_LENGTH`一直是60,但
后端`im_http.go`真实校验的是32字(读源码确认),导致33-59字之间的群名能
输入、能提交,然后被后端拒绝只显示通用错误。改成32,从输入框这一层就不
再放行超长名。另外群名留空时自动拼接的成员昵称组合,理论上可能被内容审核
拒绝——这种情况下用户根本不知道"名字"是问题所在,补了针对性提示。
- **#27 通话记录/群列表显示原始uid、群人数显示0人**(2处独立bug):
1. `CallHistoryView.tsx`按`peer_user_id`(真实usr id)去匹配聊天列表的
`contact.topic`(建立过真实会话后已经是服务端重命名过的p2p哈希),两个
不是同一个id空间,永远匹配不上,一直显示原始id。改成用`item.topic`
(已确认和chatlist的topic是同一个值)去匹配。
2. 实测发现`GET /groups`列表接口的`member_count`会返回0,但同一个群
`GET /groups/{topic}`单独查询正确返回4——两个接口的计数器不一致(后端
已知问题,见问题8),前端这次统一改成"读到0就不显示人数"而不是显示一
个已确认错误的数字。
以上改动均跑过`tsc -b --noEmit`/`oxlint`/`vite build`,除#26外全部用两个真实
账号(systest02/systest04)互相操作实机验证。
This commit is contained in:
@@ -28,9 +28,12 @@ function statusLabel(item: CallHistoryItem): string {
|
||||
/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in
|
||||
* NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls`
|
||||
* endpoint, whose response shape (see calls.ts's own comment) turned out completely different
|
||||
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id, unlike
|
||||
* `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to
|
||||
* a display name via the chat list, same lookup ChatListColumn itself uses.
|
||||
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id) is used
|
||||
* to navigate/re-dial (any valid Tinode P2P address works there, resolved by the SDK same as opening
|
||||
* a friend's profile does); `topic` (the same opaque `p2p...` form the chat list itself caches once
|
||||
* a conversation has real history) is what display-name resolution matches against instead -- the
|
||||
* two are different identifier spaces and matching name lookup against `peer_user_id` was a real,
|
||||
* live-confirmed bug (Zentao #27) that always fell through to the raw uid.
|
||||
*/
|
||||
export function CallHistoryView() {
|
||||
const navigate = useNavigate();
|
||||
@@ -80,7 +83,14 @@ export function CallHistoryView() {
|
||||
// something the viewer missed and should feel warned about. This used to redden EVERY
|
||||
// non-'finished' result alike.
|
||||
const isMissed = result === 'missed' && incoming;
|
||||
const name = contacts.find((c) => c.topic === item.peer_user_id)?.public?.fn || item.peer_user_id;
|
||||
// NOT `c.topic === item.peer_user_id` -- live-confirmed real bug (Zentao #27): `peer_user_id`
|
||||
// is a real `usr...` id, but an established P2P conversation's `contact.topic` is the SDK's
|
||||
// renamed, opaque `p2p...` form (see getP2PPeerUserId's own doc comment in topics.ts for the
|
||||
// full mechanism) -- the two are different identifier spaces and this comparison never
|
||||
// matched for any call with real history, always falling through to the raw uid. `item.topic`
|
||||
// is that same `p2p...` form already (confirmed live: identical string to what the chat list
|
||||
// caches for this conversation), so matching on it directly is the correct, direct fix.
|
||||
const name = contacts.find((c) => c.topic === item.topic)?.public?.fn || item.peer_user_id;
|
||||
const ts = item.started_at || item.created_at;
|
||||
return (
|
||||
<div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3">
|
||||
|
||||
@@ -96,7 +96,11 @@ export function GroupListView() {
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
{g.member_count} 人 · 群号 {g.topic}
|
||||
{/* `GET /groups`'s own `member_count` can come back as a bare 0 for a real, non-empty
|
||||
group (confirmed live -- same backend dual-counter split as ChatListColumn's own
|
||||
"N人" pill, 问题8 in the backend issue list) -- no real group has zero members, so
|
||||
an exact zero here means "don't trust this field", not "empty group". */}
|
||||
{g.member_count > 0 ? `${g.member_count} 人 · ` : null}群号 {g.topic}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
|
||||
|
||||
@@ -62,14 +62,16 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
let result: Awaited<ReturnType<typeof createGroup>>;
|
||||
// The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a combination
|
||||
// of member nicknames when left blank), but `name` has no such fallback server-side (unlike
|
||||
// `description`, which the protocol doc explicitly allows empty) -- confirmed live: submitting a
|
||||
// blank name gets rejected outright with MALFORMED_REQUEST, not silently defaulted. Declared
|
||||
// outside the try block since the catch below needs it too.
|
||||
const usedAutoName = !name.trim();
|
||||
try {
|
||||
// The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a
|
||||
// combination of member nicknames when left blank), but `name` has no such fallback
|
||||
// server-side (unlike `description`, which the protocol doc explicitly allows empty) --
|
||||
// confirmed live: submitting a blank name gets rejected outright with MALFORMED_REQUEST, not
|
||||
// silently defaulted. Truncated the same way the input field itself is (MAX_TITLE_LENGTH),
|
||||
// with an ellipsis when it had to cut names short, so this can't itself produce an
|
||||
// over-length name the server would also reject.
|
||||
// Truncated the same way the input field itself is (MAX_TITLE_LENGTH), with an ellipsis when
|
||||
// it had to cut names short, so this can't itself produce an over-length name the server would
|
||||
// also reject.
|
||||
const trimmedName = name.trim();
|
||||
let finalName = trimmedName;
|
||||
if (!finalName) {
|
||||
@@ -81,7 +83,16 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
|
||||
member_user_ids: [...selected.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建群聊失败');
|
||||
// Real, live-confirmed gap (Zentao #26): a name the user never typed themselves -- the
|
||||
// "未填写时默认为成员昵称组合" fallback just above -- can still get rejected (e.g. content
|
||||
// moderation flagging a purely-numeric nickname combination), and the generic error message
|
||||
// gives someone who left the field blank no clue that "the name" is even what's wrong, let
|
||||
// alone that they can fix it by typing one in themselves.
|
||||
if (usedAutoName) {
|
||||
setError('自动生成的群名未通过审核,请手动输入一个群名后重试');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : '创建群聊失败');
|
||||
}
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,6 +149,12 @@ export function ChatListColumn() {
|
||||
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
|
||||
const active = topicId === contact.topic;
|
||||
const isGroup = tinode.getTopic(contact.topic).isGroupType();
|
||||
// `GET /groups` (this file's own groupsQuery) can return `member_count: 0` for a group
|
||||
// the single-group `GET /groups/{topic}` correctly reports as several members -- the same
|
||||
// dual-counter split already tracked as 问题8 in the backend issue list, newly confirmed
|
||||
// live to surface as a full zero rather than a smaller mismatch. No real group has zero
|
||||
// members (the owner alone is one), so the `> 0` check below treats an exact zero as
|
||||
// "don't trust this" and hides the pill instead of showing a number already proven wrong.
|
||||
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
|
||||
const muted = !!contact.private?.mute_notifications;
|
||||
const avatarFileId =
|
||||
@@ -172,7 +178,7 @@ export function ChatListColumn() {
|
||||
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
|
||||
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right
|
||||
after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
|
||||
{typeof memberCount === 'number' ? (
|
||||
{typeof memberCount === 'number' && memberCount > 0 ? (
|
||||
<span className="flex-none rounded bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-4">{memberCount}人</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages';
|
||||
import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups';
|
||||
import { getUserProfile } from '@/lib/api/friends';
|
||||
import { getConversationContext, getUserProfile } from '@/lib/api/friends';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||
import { useClickOutside } from '@/lib/hooks';
|
||||
@@ -482,6 +482,28 @@ export function ConversationView() {
|
||||
queryFn: () => getUserProfile(peerUserId || ''),
|
||||
enabled: !!peerUserId,
|
||||
});
|
||||
// Live-confirmed real bug this closes (Zentao #28): sending into a P2P conversation blocked in
|
||||
// EITHER direction showed only the generic per-bubble "发送失败,点击重试", no reason, on both sides
|
||||
// -- reading tinode-sdk source confirmed the specific rejection reason can never be recovered
|
||||
// after a failed send (Topic#publishMessage's own .catch swallows it into a bare `_failed` flag and
|
||||
// never rethrows), so the only real fix is checking BEFORE attempting to send, not reacting after.
|
||||
// This endpoint is P2P-only and reports the relationship from either side (`blocked_by_me` on the
|
||||
// blocker's own view, `blocked_by_peer` on the blocked side) -- see getConversationContext's own
|
||||
// doc comment for why `getUserProfile`'s similar-looking `capabilities.can_send` can't be reused for
|
||||
// this (403s entirely from the blocked party's own perspective instead of reporting false).
|
||||
const conversationContextQuery = useQuery({
|
||||
queryKey: ['conversation-context', topicId],
|
||||
queryFn: () => getConversationContext(topicId || ''),
|
||||
enabled: !!topicId && !isGroup && !isSelf,
|
||||
});
|
||||
const blockedNotice =
|
||||
conversationContextQuery.data?.relationship === 'blocked_by_me'
|
||||
? '你已将对方加入黑名单,无法发送消息'
|
||||
: conversationContextQuery.data?.relationship === 'blocked_by_peer'
|
||||
? '对方已将你加入黑名单,暂时无法发送消息'
|
||||
: conversationContextQuery.data?.can_send === false
|
||||
? '暂时无法向对方发送消息'
|
||||
: null;
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['group-members', topicId],
|
||||
queryFn: () => getAllGroupMembers(topicId || ''),
|
||||
@@ -973,6 +995,10 @@ export function ConversationView() {
|
||||
</div>
|
||||
) : (
|
||||
<div ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
||||
{blockedNotice ? (
|
||||
<div className="flex h-18 items-center justify-center text-sm text-text-tertiary-3">{blockedNotice}</div>
|
||||
) : (
|
||||
<>
|
||||
{replyTo ? (
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
|
||||
<span className="truncate">
|
||||
@@ -1101,6 +1127,8 @@ export function ConversationView() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -143,6 +143,33 @@ export function getConversationSettings(topic: string) {
|
||||
return imGet<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`);
|
||||
}
|
||||
|
||||
/**
|
||||
* P2P-only. Confirmed live (both directions of a real block): `relationship` is `"blocked_by_me"` on
|
||||
* the blocker's own side and `"blocked_by_peer"` on the blocked side -- `can_send`/`capabilities.
|
||||
* can_send_text` are `false` on both. This is the fix for a real, live-reproduced bug (Zentao #28):
|
||||
* sending into a blocked P2P conversation showed only the generic per-bubble "发送失败,点击重试"
|
||||
* with no reason on EITHER side -- reading tinode-sdk source confirmed why a specific reason can
|
||||
* never be recovered after the fact: `Topic#publishMessage`'s own `.catch()` (tinode.dev.js ~line
|
||||
* 4709) swallows the server's rejection into a bare `_failed` flag on the message and never rethrows,
|
||||
* so there's no error object left for application code to read once a send has already failed. This
|
||||
* endpoint is the way around that -- check `can_send` BEFORE attempting to send, not after.
|
||||
* `getUserProfile()`'s own `capabilities.can_send` looked like it might cover this more cheaply (already
|
||||
* fetched elsewhere), but confirmed live it 403s entirely from the blocked party's own side instead of
|
||||
* returning a normal response, so it can't be used for this specific check either.
|
||||
*/
|
||||
export interface ConversationContext {
|
||||
topic: string;
|
||||
peer_user_id: string;
|
||||
display_name: string;
|
||||
can_send: boolean;
|
||||
relationship: 'blocked_by_me' | 'blocked_by_peer' | string;
|
||||
capabilities?: { can_send_text?: boolean; can_send_media?: boolean; can_send_card?: boolean; can_call?: boolean; can_add_friend?: boolean; can_handle_request?: boolean };
|
||||
}
|
||||
|
||||
export function getConversationContext(topic: string) {
|
||||
return imGet<ConversationContext>(`/conversations/${encodeURIComponent(topic)}/context`);
|
||||
}
|
||||
|
||||
export function updateConversationSettings(topic: string, body: Partial<Pick<ConversationSettings, 'pinned' | 'mute_notifications'>> & { version: number }) {
|
||||
return imPatch<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`, body);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,14 @@ export const IMAGE_THUMBNAIL_DIM = 36;
|
||||
export const VIDEO_THUMBNAIL_WIDTH = 48;
|
||||
|
||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
export const MAX_TITLE_LENGTH = 60;
|
||||
/** `im_be/server/im_http.go`'s `serveIMGroupCreate`: `len([]rune(name)) < 1 || > 32` rejects outright
|
||||
* with a bare MALFORMED_REQUEST (no `details.field`, unlike the description-length check right next
|
||||
* to it) -- was `60` here, a value that let CreateGroupModal's input accept and submit names the
|
||||
* backend always rejects, surfacing only the generic "内容不符合平台规范" (indistinguishable from an
|
||||
* actual content-moderation hit, since the backend response itself doesn't say which). Capping input
|
||||
* at the real limit prevents the over-length case from ever reaching the backend at all (live-
|
||||
* confirmed real bug, Zentao #26). */
|
||||
export const MAX_TITLE_LENGTH = 32;
|
||||
/** docs/IM-前后端对接文档.md:1445 -- "description 可省略或传空字符串,最长 200 字符". */
|
||||
export const MAX_TOPIC_DESCRIPTION_LENGTH = 200;
|
||||
export const MAX_PEER_TITLE_LENGTH = 24;
|
||||
|
||||
+15
-6
@@ -10,7 +10,7 @@ import {
|
||||
} from '@/lib/calls/agora-client';
|
||||
import { callContentOf, callStateOf, sendCallAccept, sendCallHangup, sendCallInvite, sendCallRinging, type CallWebrtcState } from '@/lib/tinode/calls';
|
||||
import { getTinode } from '@/lib/tinode/connection';
|
||||
import { getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics';
|
||||
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
export type CallPhase = 'idle' | 'outgoing' | 'incoming' | 'connected' | 'ended';
|
||||
@@ -243,12 +243,21 @@ export function initCallSignaling() {
|
||||
subscribeAnyTopicChange(() => {
|
||||
const myUserId = useSessionStore.getState().myUserId;
|
||||
if (!myUserId) return;
|
||||
// NOT `getTinode().getMeTopic().contacts()` -- live-confirmed real bug this fixes (user-reported:
|
||||
// a whole incoming-call test milestone blocked, WS packet capture confirmed the invite genuinely
|
||||
// arrives, but no incoming-call UI ever appeared): `contacts()` reads the `me` topic's OWN
|
||||
// internally-tracked subscription list, populated only once `me`'s `{meta sub}` sync reports this
|
||||
// specific contact -- but a P2P topic can be fully subscribed and sitting in the SDK's topic cache
|
||||
// (able to receive and store a real incoming call invite) well before that sync catches up. This
|
||||
// exact gap is already documented on `getChatListSnapshot`'s own comment (topics.ts) as the reason
|
||||
// that function uses `mapTopics` instead -- this call-signaling listener just never got the same
|
||||
// fix when it was written. Confirmed live: a real two-account call, `me.contacts()` on the callee's
|
||||
// own live session enumerated ONE contact (a group) and silently omitted the caller entirely, while
|
||||
// `getChatListSnapshot()`'s own list correctly showed both.
|
||||
const peerNames = new Map<string, string>();
|
||||
getTinode()
|
||||
.getMeTopic()
|
||||
.contacts((c) => {
|
||||
if (c.topic) peerNames.set(c.topic, c.public?.fn || c.topic);
|
||||
});
|
||||
for (const contact of getChatListSnapshot()) {
|
||||
if (contact.topic) peerNames.set(contact.topic, contact.public?.fn || contact.topic);
|
||||
}
|
||||
|
||||
for (const topicName of peerNames.keys()) {
|
||||
const messages = getMessagesSnapshot(topicName);
|
||||
|
||||
Reference in New Issue
Block a user