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
+137
View File
@@ -0,0 +1,137 @@
import type { IAgoraRTCClient, ICameraVideoTrack, IMicrophoneAudioTrack, IRemoteVideoTrack } from 'agora-rtc-sdk-ng';
import type { RtcJoinResult } from '@/lib/api/calls';
/**
* Thin wrapper around the Agora Web SDK -- this app's actual media transport (see lib/api/calls.ts's
* header comment for why: confirmed live that `POST /v1/im/calls/rtc-join` is the real, working
* path, not raw WebRTC/SDP-over-Tinode-`{note}`). Kept as module-level state, not Zustand -- an
* `IAgoraRTCClient`/media-track instance isn't meaningful React state (nothing should re-render
* because a client object's internal fields changed), and playing a video track is an imperative
* `track.play(domElement)` call, not a declarative prop.
*
* The SDK itself (~1.6MB) is dynamically imported inside `joinCall`, not at module top level --
* loading it eagerly would put a large media SDK in every page's initial bundle even though most
* sessions never place a call.
*/
let client: IAgoraRTCClient | null = null;
let localAudioTrack: IMicrophoneAudioTrack | null = null;
let localVideoTrack: ICameraVideoTrack | null = null;
let remoteVideoTrack: IRemoteVideoTrack | null = null;
// `createCameraVideoTrack()` (see joinCall below) opens whatever camera the browser/OS defaults to
// without asking -- normally the front ("user") one, so this starts in sync with that assumption
// rather than guessing wrong on the very first flip.
let usingBackCamera = false;
export interface JoinedCall {
localVideoTrack: ICameraVideoTrack | null;
}
/** Joins the Agora channel named by the rtc-join response and publishes local mic (+ camera, for a
* video call) tracks. Requesting `getUserMedia`-equivalent access here, not earlier -- matches the
* old app's own real timing (captures media once the call is actually about to carry it, not on
* page load). */
export async function joinCall(join: RtcJoinResult, callType: 'audio' | 'video', onRemoteUser: (hasVideo: boolean) => void): Promise<JoinedCall> {
const { default: AgoraRTC } = await import('agora-rtc-sdk-ng');
client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
client.on('user-published', async (user, mediaType) => {
await client!.subscribe(user, mediaType);
if (mediaType === 'video' && user.videoTrack) {
remoteVideoTrack = user.videoTrack;
onRemoteUser(true);
}
if (mediaType === 'audio' && user.audioTrack) {
user.audioTrack.play();
}
});
client.on('user-unpublished', (_user, mediaType) => {
if (mediaType === 'video') {
remoteVideoTrack = null;
onRemoteUser(false);
}
});
await client.join(join.app_id, join.channel_name, join.token, join.uid);
localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
const tracks: Array<IMicrophoneAudioTrack | ICameraVideoTrack> = [localAudioTrack];
if (callType === 'video') {
localVideoTrack = await AgoraRTC.createCameraVideoTrack();
tracks.push(localVideoTrack);
}
await client.publish(tracks);
return { localVideoTrack };
}
export function playRemoteVideo(el: HTMLElement) {
remoteVideoTrack?.play(el);
}
export function playLocalVideo(el: HTMLElement) {
localVideoTrack?.play(el);
}
// Was fire-and-forget (`.setEnabled()`'s own promise never returned) -- a rejection (track already
// closed, permission revoked mid-call) had nowhere to go but an unhandled rejection, and the caller
// (stores/calls.ts's toggleMute) had no way to know it failed and roll the button back. Made async +
// re-thrown so it can.
export async function setMuted(muted: boolean) {
if (!localAudioTrack) return;
await localAudioTrack.setEnabled(!muted);
}
export async function setCameraOff(off: boolean) {
if (!localVideoTrack) return;
await localVideoTrack.setEnabled(!off);
}
/** Front/back camera switch (06-calls.dc.html #1f's `flip_camera_ios` mobile header icon --
* previously a `notImplemented()` placeholder, see Part G's requirements-diff findings). Uses
* `setDevice({facingMode})` rather than enumerating `AgoraRTC.getCameras()` and guessing which
* device is "the other one" by its (unstandardized, often-empty-until-permission-granted) label --
* `facingMode` is the browser's own signal for which physical camera to use, no guessing needed.
* NOT verified against a real device: this sandboxed environment has no camera, the same
* documented limitation that's kept full 2-way video verification out of reach for this whole
* feature (see the plan file's calls section) -- correct per the SDK's own typed API, unverified
* live. */
export async function switchCamera(): Promise<void> {
if (!localVideoTrack) return;
const next = !usingBackCamera;
usingBackCamera = next;
try {
await localVideoTrack.setDevice({ facingMode: next ? 'environment' : 'user' });
} catch (err) {
// Roll the optimistic flip back and rethrow -- matches setMuted/setCameraOff's own contract
// (this file stays a thin, rethrowing wrapper; stores/calls.ts's own toggleMute/toggleCamera
// are what catch and surface `mediaError`, and switchCamera's caller needs the same treatment,
// not a second, inconsistent error-handling convention just for this one control).
usingBackCamera = !next;
throw err;
}
}
export function hasRemoteVideo(): boolean {
return !!remoteVideoTrack;
}
/** Tears down everything -- call on hang-up from either side, or on error. Safe to call more than
* once (every step is guarded by the ref still being set). */
export async function leaveCall() {
localAudioTrack?.close();
localAudioTrack = null;
localVideoTrack?.close();
localVideoTrack = null;
remoteVideoTrack = null;
usingBackCamera = false;
// Capture and null out the module-level ref BEFORE awaiting -- a rapid hang-up-then-redial can let
// a new joinCall() reassign `client` to a fresh instance while this leave() is still in flight;
// without capturing it first, the two lines after the await would operate on the NEW call's
// client instead of the one actually being left.
const c = client;
client = null;
if (c) {
await c.leave().catch(() => {});
c.removeAllListeners();
}
}