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:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user