diff --git a/web/src/features/calls/CallHistoryView.tsx b/web/src/features/calls/CallHistoryView.tsx index 2597c908..e3bfe840 100644 --- a/web/src/features/calls/CallHistoryView.tsx +++ b/web/src/features/calls/CallHistoryView.tsx @@ -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 (
diff --git a/web/src/features/contacts/GroupListView.tsx b/web/src/features/contacts/GroupListView.tsx index d66a1771..9ee6aaa7 100644 --- a/web/src/features/contacts/GroupListView.tsx +++ b/web/src/features/contacts/GroupListView.tsx @@ -96,7 +96,11 @@ export function GroupListView() { ) : null} - {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} diff --git a/web/src/features/groups/CreateGroupModal.tsx b/web/src/features/groups/CreateGroupModal.tsx index e02d73ff..cc695f18 100644 --- a/web/src/features/groups/CreateGroupModal.tsx +++ b/web/src/features/groups/CreateGroupModal.tsx @@ -62,14 +62,16 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps) setSubmitting(true); setError(''); let result: Awaited>; + // 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; } diff --git a/web/src/features/messages/ChatListColumn.tsx b/web/src/features/messages/ChatListColumn.tsx index 89e15c63..e8465168 100644 --- a/web/src/features/messages/ChatListColumn.tsx +++ b/web/src/features/messages/ChatListColumn.tsx @@ -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() { {name} {/* 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 ? ( {memberCount}人 ) : null} diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 3eec38a4..580e9f1f 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -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() {
) : (
+ {blockedNotice ? ( +
{blockedNotice}
+ ) : ( + <> {replyTo ? (
@@ -1101,6 +1127,8 @@ export function ConversationView() {
)} + + )}
)} diff --git a/web/src/lib/api/friends.ts b/web/src/lib/api/friends.ts index 965a7984..7e65960b 100644 --- a/web/src/lib/api/friends.ts +++ b/web/src/lib/api/friends.ts @@ -143,6 +143,33 @@ export function getConversationSettings(topic: string) { return imGet(`/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(`/conversations/${encodeURIComponent(topic)}/context`); +} + export function updateConversationSettings(topic: string, body: Partial> & { version: number }) { return imPatch(`/conversations/${encodeURIComponent(topic)}/settings`, body); } diff --git a/web/src/lib/config.ts b/web/src/lib/config.ts index 7e054a7e..5ca1c16a 100644 --- a/web/src/lib/config.ts +++ b/web/src/lib/config.ts @@ -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; diff --git a/web/src/stores/calls.ts b/web/src/stores/calls.ts index e102db5b..b4ef59e9 100644 --- a/web/src/stores/calls.ts +++ b/web/src/stores/calls.ts @@ -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(); - 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);