feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes

Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+431
View File
@@ -0,0 +1,431 @@
// Group REST endpoints (docs/IM-前后端对接文档.md ch.6). Path/field shapes ported from the old app's
// src/lib/im-api.js (lines 686-759), whose own header states they were verified directly against the
// deployed backend -- its inline comments flag three real doc/server mismatches this file follows
// instead of the doc where they conflict (see each function's own comment):
// - reject-join-request has no `reason` field (server 400s if sent, DisallowUnknownFields)
// - group-files search param is `q`, not `query`
// - addGroupFile needs more fields than the doc's minimal example, or it registers blank metadata
// Unlike P2P conversation settings, almost the entire group management surface is REST, not native
// Tinode WS -- confirmed against the old app's actually-shipped UI (group-management-view.jsx), which
// never calls topic.setMeta({desc}) or {set sub private} for groups despite the protocol doc showing
// WS equivalents exist on the wire. Only message send/receive/typing/receipts stay on WS (topics.ts).
import { fetchAllPages } from './client';
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
export interface GroupPermissions {
can_edit_profile?: boolean;
can_invite_members?: boolean;
can_manage_members?: boolean;
can_manage_settings?: boolean;
can_leave?: boolean;
can_mention_all?: boolean;
}
export interface GroupSummaryItem {
topic: string;
name: string;
avatar_file_id?: string;
member_count: number;
my_role: GroupRole;
updated_at?: string;
}
export function getGroups(category: 'owned' | 'managed' | 'joined' | '' = '', query = '', cursor = '') {
const qs = new URLSearchParams();
if (category) qs.set('category', category);
if (query) qs.set('query', query);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: GroupSummaryItem[]; next_cursor?: string; has_more?: boolean }>(`/groups${suffix ? `?${suffix}` : ''}`);
}
export interface GroupProfile {
topic: string;
name: string;
avatar_file_id?: string;
description?: string;
owner?: string;
member_count: number;
version: number;
permissions?: GroupPermissions;
}
export function createGroup(body: { name: string; description?: string; member_user_ids: string[]; avatar_file_id?: string }) {
return imPost<GroupProfile>('/groups', body);
}
export function getGroup(topic: string) {
return imGet<GroupProfile>(`/groups/${encodeURIComponent(topic)}`);
}
export function updateGroup(topic: string, patch: { name?: string; avatar_file_id?: string; description?: string; version: number }) {
return imPatch<GroupProfile>(`/groups/${encodeURIComponent(topic)}`, patch);
}
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): each field's "can I edit this" flag is
* a flat `{field}_editable` sibling, NOT a nested `editable: {...}` object as the doc's phrasing
* ("每个字段返回 editable") could be read either way -- the doc itself never shows a worked
* example for this response, so this was a guess until checked against the real payload. */
export interface GroupGovernance {
join_approval: boolean;
join_approval_editable?: boolean;
mute_all: boolean;
mute_all_editable?: boolean;
member_upload_files: boolean;
member_upload_files_editable?: boolean;
member_private_chat: boolean;
member_private_chat_editable?: boolean;
member_invite: boolean;
member_invite_editable?: boolean;
version: number;
}
/**
* Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`) against three original guesses in this
* type: there is no `announcement` field at all (the summary endpoint carries that instead, see
* GroupDetailColumn.tsx/ConversationView.tsx's own comments); profile fields (`name`, `description`,
* `avatar_file_id`, `version`) come back FLAT on this object, not nested under a `profile` key; and
* `governance` is the FULL `GroupGovernance` shape (every `*_editable` flag and its own `version`),
* not the 3-field `Pick` this originally guessed. `owner`/`message_retention` are real fields this
* file hadn't typed at all until this pass.
*/
export interface GroupSettings {
topic: string;
name: string;
description?: string;
avatar_file_id?: string;
owner: string;
my_role: GroupRole;
permissions?: GroupPermissions;
member_count: number;
pinned: boolean;
mute_notifications: boolean;
message_retention?: string;
governance?: GroupGovernance;
version: number;
}
export function getGroupSettings(topic: string) {
return imGet<GroupSettings>(`/groups/${encodeURIComponent(topic)}/settings`);
}
export interface GroupMemberSettings {
pinned: boolean;
mute_notifications: boolean;
version: number;
updated_at: string;
}
/** No `version` in the request body -- confirmed live 2026-09-09: the backend struct for this
* endpoint only declares `pinned`/`mute_notifications`, and strict unknown-field JSON decoding
* 400s MALFORMED_REQUEST on anything else, `version` included. This endpoint doesn't do optimistic
* locking at all (the server bumps its own stored version unconditionally on every call); the
* `version` in the RESPONSE is read-only bookkeeping, not something the client should echo back.
* This one bug made every group's 置顶聊天/消息免打扰 toggle unusable, on every account/role tested. */
export function updateGroupMemberSettings(topic: string, patch: { pinned?: boolean; mute_notifications?: boolean }) {
return imPatch<GroupMemberSettings>(`/groups/${encodeURIComponent(topic)}/member-settings`, patch);
}
export function leaveGroup(topic: string) {
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
}
/** doc-flagged 待实现 (not yet implemented server-side) -- kept typed and ready for when it lands. */
export function disbandGroup(topic: string) {
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`);
}
export type GroupRole = 'owner' | 'admin' | 'member';
export interface GroupMember {
user_id: string;
display_name?: string;
avatar_file_id?: string;
role: GroupRole;
mentionable?: boolean;
eligible?: boolean;
/** Confirmed live (2026-09-08): present when `eligible:false`, e.g. `"owner"` -- not in the
* protocol doc's worked examples at all. Not yet surfaced in any UI (the admin/mute/owner-
* transfer pickers just filter ineligible candidates out rather than showing them grayed out
* with a reason), but real and worth keeping typed since it's cheap and already observed. */
ineligible_reason?: string;
acs?: { want?: string; given?: string; mode?: string };
}
/** `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) --
* only getGroupMemberProfile() does. Don't read nickname off this list. */
export function getGroupMembers(
topic: string,
opts: { query?: string; cursor?: string; role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
) {
const qs = new URLSearchParams();
if (opts.query) qs.set('query', opts.query);
if (opts.cursor) qs.set('cursor', opts.cursor);
if (opts.role) qs.set('role', opts.role);
if (opts.eligibleRole) qs.set('eligible_role', opts.eligibleRole);
if (opts.mentionable) qs.set('mentionable', 'true');
if (opts.muteEligible) qs.set('mute_eligible', 'true');
const suffix = qs.toString();
return imGet<{ items: GroupMember[]; next_cursor?: string; permissions?: { can_mention_all?: boolean } }>(
`/groups/${encodeURIComponent(topic)}/members${suffix ? `?${suffix}` : ''}`,
);
}
export function getAllGroupMembers(
topic: string,
opts: { role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
) {
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
}
export interface GroupMemberProfile {
topic: string;
user_id: string;
display_name?: string;
group_nickname?: string;
role: GroupRole;
relationship?: string;
capabilities?: { can_send?: boolean; can_call?: boolean };
}
/** `topic` must be the `grp...` id, never a `usr...` id -- confirmed by the protocol doc's own warning.
* Do NOT call this with your own user_id: the backend computes a self/other relationship context
* for this generic path that errors out when both sides are the same uid (confirmed live 2026-09-09,
* stable 400 MALFORMED_REQUEST). Use `getMyGroupProfile()` for your own group nickname instead --
* the backend added a dedicated `members/me/profile` route that sidesteps this entirely. */
export function getGroupMemberProfile(topic: string, userId: string) {
return imGet<GroupMemberProfile>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}/profile`);
}
/** Dedicated self-profile route -- unlike getGroupMemberProfile(topic, myUserId), this one actually
* works (see that function's own warning) and, as a bonus, returns the real optimistic-lock `version`
* on every read, so callers no longer need to remember the last save's version between edits. */
export function getMyGroupProfile(topic: string) {
return imGet<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`);
}
export function updateMyGroupNickname(topic: string, nickname: string, version: number) {
return imPut<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`, {
nickname,
version,
});
}
export function getGroupRoleLimits(topic: string) {
return imGet<{ max_admin_count: number; current_admin_count: number }>(`/groups/${encodeURIComponent(topic)}/role-limits`);
}
export function addGroupAdmins(topic: string, userIds: string[], version: number) {
return imPost<{
succeeded: Array<{ user_id: string; role: GroupRole }>;
failed?: Array<{ user_id: string; reason: string }>;
admins: GroupMember[];
version: number;
}>(`/groups/${encodeURIComponent(topic)}/admins/batch`, { user_ids: userIds, version });
}
export function removeGroupAdmin(topic: string, userId: string, version: number) {
return imDelete<{ user_id: string; role: GroupRole; updated_at: string }>(
`/groups/${encodeURIComponent(topic)}/admins/${encodeURIComponent(userId)}`,
{ headers: { 'If-Match': String(version) } },
);
}
export interface GroupMute {
user: { user_id: string; display_name?: string };
muted_by?: string;
muted_at: string;
expires_at: string | null;
}
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
// NOT `/mutes/batch` -- confirmed against the real backend (im_be/server/im_http.go's routing
// switch has no such case, so that path 405s unconditionally) -- the real batch-mute endpoint is
// plain `POST .../mutes` (the same base path `getGroupMutes` GETs from), body shape unchanged.
return imPost<{ items: Array<{ user_id: string; muted_by: string; muted_at: string; expires_at?: string; reason?: string; version: number }> }>(
`/groups/${encodeURIComponent(topic)}/mutes`,
{ members, reason },
);
}
export function getGroupMutes(topic: string, cursor = '') {
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
}
export function unmuteGroupMember(topic: string, userId: string) {
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
}
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- not yet
* wired since no risk-challenge flow is built anywhere in this app yet; calling this will 409 until
* that's added. Kept typed so the group-management UI can be built ahead of that. */
export function transferGroupOwner(topic: string, targetUserId: string, version: number) {
return imPost<{ owner: string; previous_owner: string; version: number }>(`/groups/${encodeURIComponent(topic)}/owner-transfer`, {
target_user_id: targetUserId,
version,
});
}
export function getGroupGovernance(topic: string) {
return imGet<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`);
}
export function updateGroupGovernance(
topic: string,
patch: Partial<Pick<GroupGovernance, 'join_approval' | 'mute_all' | 'member_upload_files' | 'member_private_chat' | 'member_invite'>> & { version: number },
) {
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
}
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
return imPost<{ items: Array<{ user_id: string; status: string; invitation_id?: string }> }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
user_ids: userIds,
message,
});
}
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
* display name anywhere on it at all, plus `inviter_id`/`message`/`handled_by` fields the doc's
* example doesn't show. Reading `req.applicant.user_id` against this real shape would throw
* (`applicant` is `undefined`) the first time this modal ever rendered a real request -- caught by
* live-calling `getGroupJoinRequests` directly, not by reading the doc or the mock. */
export interface GroupJoinRequest {
request_id: string;
user_id: string;
/** Non-empty only when `source` reflects an invite (mock's "王工 邀请" rows) -- empty string for
* a self-initiated request (search/QR). */
inviter_id: string;
/** The applicant's own join message, if any (mock: `"我是新来的测试同学"`). */
message?: string;
status: string;
source: string;
created_at: string;
handled_at?: string;
handled_by?: string;
version: number;
}
/** docs/IM-前后端对接文档.md:1591,1740 -- submitting a join request (the requester's own side; every
* other function in this block is the admin's reviewing side). `source` and `qr_token` are only
* relevant for the QR-scan path (GroupQRCodeModal/QRScannerModal's `join_group` purpose) -- a plain
* "request to join" with no QR code involved would omit both, though this app doesn't have another
* UI entry point that reaches a group's join-requests without already being a member (no public
* group directory/search exists anywhere in this codebase or its design handoff). */
export function submitGroupJoinRequest(topic: string, body: { message?: string; source?: 'qr' | string; qr_token?: string } = {}) {
return imPost<{ request_id: string; topic: string; status: string; created_at: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/join-requests`,
body,
);
}
export function getGroupJoinRequests(topic: string, status: 'pending' | 'all' = 'pending', cursor = '') {
const qs = new URLSearchParams({ status });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: GroupJoinRequest[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/join-requests?${qs.toString()}`);
}
export function acceptGroupJoinRequest(topic: string, requestId: string, version: number) {
return imPost<{ request_id: string; status: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/accept`,
{ version },
);
}
/** Body is `{version}` ONLY -- no `reason` field. The protocol doc's own example shows a `reason`
* key, but the old app's im-api.js confirmed live that the real server struct doesn't have one and
* every request body is validated with DisallowUnknownFields(), so sending `reason` 400s the call. */
export function rejectGroupJoinRequest(topic: string, requestId: string, version: number) {
return imPost<{ request_id: string; status: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/reject`,
{ version },
);
}
/** Confirmed live: `announcement` is always a present object, even with nothing published --
* every field just comes back zero-valued (`content:""` etc), never `null`. Check `content` for
* emptiness, not object existence -- same gotcha as `getLatestGroupAnnouncement` below. */
export function getGroupSummary(topic: string) {
return imGet<{
pending_join_request_count: number;
announcement?: { announcement_id: string; content: string; author: string; published_at: string; version: number } | null;
permissions?: GroupPermissions;
}>(`/groups/${encodeURIComponent(topic)}/summary`);
}
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
* the `published_by` object this file originally guessed -- resolve it against the group's member
* list for a display name/role, same as any other bare user id in this API. `read_count`/
* `member_count` are confirmed ABSENT from the real response (not just unconfirmed), and when
* nothing has been published yet the endpoint still returns `200` with every field zero-valued
* (`announcement_id:"", author:"", content:"", published_at:"", version:0`), never `404`/`null` --
* callers must check `content` for emptiness, not object existence. */
export interface GroupAnnouncement {
announcement_id: string;
topic: string;
content: string;
version: number;
published_at: string;
author: string;
permissions?: { can_edit?: boolean };
}
export function getLatestGroupAnnouncement(topic: string) {
return imGet<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/latest`);
}
export function updateCurrentGroupAnnouncement(topic: string, content: string, version: number) {
return imPut<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/current`, { content, version });
}
export function readGroupAnnouncement(topic: string, announcementId: string) {
return imPost<{ announcement_id: string; read_at: string }>(
`/groups/${encodeURIComponent(topic)}/announcements/${encodeURIComponent(announcementId)}/read`,
{},
);
}
/** Confirmed live (2026-09-08, real registered file `gfile_PNof3SOlWz0`): fields are FLAT
* `file_name`/`file_size`/`mime_type` (not the doc-adjacent `name`/`size`/`mime` this type
* previously declared -- undetected until now because this list only ever rendered its own
* `暂无群文件` empty state, never a real row), `uploader_id` is a bare string (no nested `uploader`
* object, no `display_name` alongside it), and there is no `permissions` object at all -- gating a
* delete affordance needs a different signal (see GroupFilesModal's own comment on how it copes). */
export interface GroupFile {
group_file_id: string;
file_id: string;
file_name: string;
file_size: number;
mime_type: string;
uploader_id?: string;
created_at: string;
download_state?: string;
}
/** Search param is `q`, not `query` as the protocol doc's own example URL shows -- confirmed live by
* the old app that the real server reads `q` and silently no-ops a `query` param instead. */
export function getGroupFiles(topic: string, q = '', type = '', cursor = '') {
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (type) qs.set('type', type);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: GroupFile[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/files${suffix ? `?${suffix}` : ''}`);
}
/**
* The doc's minimal `{file_id}` body registers a file with blank name/0 size, since every other
* field is server-side `omitempty` -- confirmed live by the old app, which always sends the full
* set below.
*/
export function addGroupFile(
topic: string,
file: { file_id: string; file_name: string; mime_type: string; file_type: string; file_size: number; source_topic?: string; source_seq?: number },
) {
return imPost<GroupFile>(`/groups/${encodeURIComponent(topic)}/files`, file);
}
export function deleteGroupFile(topic: string, groupFileId: string) {
return imDelete<void>(`/groups/${encodeURIComponent(topic)}/files/${encodeURIComponent(groupFileId)}`);
}