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
+58 -8
View File
@@ -3,16 +3,26 @@
// direct-message-view.jsx) -- verified there, not guessed.
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
/** Confirmed live shape (doc line 1417): `initial` is server-computed (handles pinyin-to-letter
* itself), so the contacts list groups by THIS field, not a client-side first-character guess like
* CreateGroupModal's friend picker had to fall back to (that one has no `initial` in its response). */
export interface Friend {
user_id: string;
display_name?: string;
remark?: string;
avatar_file_id?: string;
initial?: string;
topic?: string;
relationship?: string;
online_visibility?: string;
capabilities?: { can_send?: boolean; can_call?: boolean };
[key: string]: unknown;
}
export function getFriends(query = '', cursor = '') {
export function getFriends(query = '', cursor = '', initial = '') {
const qs = new URLSearchParams();
if (query) qs.set('query', query);
if (initial) qs.set('initial', initial);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: Friend[]; next_cursor?: string }>(`/friends${suffix ? `?${suffix}` : ''}`);
@@ -26,15 +36,22 @@ export function updateFriendRemark(userId: string, remark: string) {
return imPatch<void>(`/friends/${encodeURIComponent(userId)}`, { remark });
}
/** Confirmed live shape (doc line 1423): no `action` field as this file originally typed (there's
* nothing in the response describing what UI affordance to show -- that's derived client-side
* from `relationship`/`can_add` instead), and `avatar_file_id` is present. */
export interface UserSearchResult {
user_id: string;
display_name: string;
relationship: 'none' | 'friend' | 'pending' | string;
avatar_file_id?: string;
public_account?: string;
// docs/IM-前后端对接文档.md:1289 -- real enum is these 4 values, direction-specific, never a bare
// "pending" (a prior version of this type had that wrong, which meant no caller's `=== 'pending'`
// comparison could ever match either real pending state).
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
can_add: boolean;
action: string;
}
export function searchUsers(query: string, type: 'account' = 'account', cursor = '') {
export function searchUsers(query: string, type: 'account' | 'exact_account' | 'phone' = 'account', cursor = '') {
const qs = new URLSearchParams({ q: query, type });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: UserSearchResult[]; next_cursor?: string }>(`/users/search?${qs.toString()}`);
@@ -49,6 +66,9 @@ export function sendFriendRequest(targetUserId: string, message?: string, source
});
}
/** `source` and `created_at` (doc line 1419's worked example) were missing from this file's
* original type -- needed for the 新朋友 list's "今天 10:12 · 通过手机号搜索" line. `handled_at`
* is mentioned in the doc's prose but not its own worked example, kept optional either way. */
export interface FriendRequest {
request_id: string;
applicant_id: string;
@@ -56,12 +76,22 @@ export interface FriendRequest {
direction: 'inbound' | 'outbound';
status: string;
message?: string;
source?: string;
created_at: string;
handled_at?: string;
peer?: { user_id: string; display_name?: string };
topic: string;
topic?: string;
}
export function getFriendRequests() {
return imGet<{ items: FriendRequest[]; next_cursor?: string }>('/friend-requests');
/** Doc's confirmed query params (line 1277/1419) -- the original zero-arg version always hit the
* endpoint with no filters at all, whose default direction/status scope isn't documented; the
* design's 新朋友 list needs BOTH pending and already-handled inbound requests shown together
* (处理过的显示灰字"已通过/已拒绝"), so this defaults to exactly that rather than relying on
* whatever an unfiltered call happens to return. */
export function getFriendRequests(direction: 'inbound' | 'outbound' = 'inbound', status: 'pending' | 'all' = 'all', cursor = '') {
const qs = new URLSearchParams({ direction, status });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: FriendRequest[]; next_cursor?: string }>(`/friend-requests?${qs.toString()}`);
}
export function acceptFriendRequest(requestId: string, version?: number) {
@@ -72,6 +102,14 @@ export function rejectFriendRequest(requestId: string, version?: number) {
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/reject`, version !== undefined ? { version } : {});
}
/**
* Confirmed live (2026-09-08, real friend `usruF4Uil-jCKM`): `capabilities.can_add_friend`, not the
* doc's own `can_add` (line 1425) -- another confirmed field-name mismatch, not this file's guess.
* `remark` was also confirmed absent from a real friend's response even though the doc's worked
* example shows `"remark":""` present -- FriendProfileView.tsx's `profile.remark || profile.
* display_name` already degrades correctly either way, so this isn't a rendering bug, just a gap
* between the doc and what this deployment actually returns right now.
*/
export interface UserProfile {
user_id: string;
display_name: string;
@@ -81,8 +119,11 @@ export interface UserProfile {
region?: string;
public_account?: string;
signature?: string;
relationship: string;
// Same real enum as UserSearchResult's (docs/IM-前后端对接文档.md:1289) -- direction-specific
// pending states, never a bare "pending".
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
topic?: string;
capabilities?: { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean };
}
/** `identifier` accepts `user_id`, `username`, or `public_account` -- P2P topic names are the peer's
@@ -114,6 +155,15 @@ export function unblockUser(userId: string) {
return imDelete<void>(`/blocks/${encodeURIComponent(userId)}`);
}
/** docs/IM-前后端对接文档.md:1380,1431 -- up to 100 ids per call, per-item result in `items[]` (this
* backend's usual batch-endpoint shape, see fetchAllPages's own header comment on the class of bug
* that comes from assuming a 200 means every item succeeded). */
export function batchUnblockUsers(userIds: string[]) {
return imPost<{ items: Array<{ user_id: string; blocked: boolean }>; failed?: Array<{ user_id: string; reason: string }> }>('/blocks/batch-delete', {
user_ids: userIds,
});
}
export interface BlockedUser {
user: { user_id: string; display_name?: string; avatar_file_id?: string };
blocked_at: string;