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);