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>
80 lines
4.3 KiB
TypeScript
80 lines
4.3 KiB
TypeScript
import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk';
|
|
|
|
import { newClientMid } from '@/lib/tinode/send';
|
|
|
|
/**
|
|
* Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines
|
|
* 424-435/1945-2076): the invite is a plain-JSON `{pub}` (NOT Drafty -- `topic.createMessage` only
|
|
* auto-Drafty-wraps string/Drafty-shaped content, a plain `{callId,...}` object passes through as
|
|
* literal `content`), `head:{mime:"application/json", webrtc:"started", "x-im-type":"call"}`.
|
|
* Ringing/accept/hang-up then go over `topic.videoCall()` (thin wrapper around the SDK's native
|
|
* `{note what:"call"}`, see tinode-sdk.d.ts's own comment) -- NOT offer/answer/ice-candidate, which
|
|
* this app doesn't use (see calls.ts's own header comment in lib/api on the Agora/rtc-join media
|
|
* path this backend actually implements instead of raw SDP/ICE over the IM WebSocket).
|
|
*/
|
|
export interface CallContent {
|
|
callId: string;
|
|
callType: 'audio' | 'video';
|
|
audio: boolean;
|
|
video: boolean;
|
|
status?: string;
|
|
}
|
|
|
|
/** `head.webrtc` values a call message cycles through, replacing the SAME `seq` in place (doc line
|
|
* 1953) -- `started` is the live invite; the rest are terminal results the server writes over it. */
|
|
export type CallWebrtcState = 'started' | 'accepted' | 'finished' | 'disconnected' | 'missed' | 'declined';
|
|
|
|
export function callContentOf(msg: StoredMessage): CallContent | null {
|
|
const webrtc = msg.head?.webrtc;
|
|
if (typeof webrtc !== 'string' || !msg.content || typeof msg.content !== 'object') return null;
|
|
const content = msg.content as unknown as CallContent;
|
|
if (typeof content.callId !== 'string') return null;
|
|
return content;
|
|
}
|
|
|
|
export function callStateOf(msg: StoredMessage): CallWebrtcState | null {
|
|
const webrtc = msg.head?.webrtc;
|
|
return typeof webrtc === 'string' ? (webrtc as CallWebrtcState) : null;
|
|
}
|
|
|
|
export function callDurationMsOf(msg: StoredMessage): number {
|
|
const duration = msg.head?.['webrtc-duration'];
|
|
return typeof duration === 'number' ? duration : 0;
|
|
}
|
|
|
|
/**
|
|
* Sends the call invite. `noEcho: false` (matches send.ts's sendText) so the caller's own UI gets an
|
|
* immediate optimistic bubble via `publishDraft`, same as any other message.
|
|
*
|
|
* Confirmed live: an audio call's outbound packet never actually carries `video:false` on the wire
|
|
* -- the SDK's own `#send()` runs every outbound packet through a generic `simplify()` (tinode.dev.
|
|
* js:5881-5903) that recursively deletes any falsy field, with no way to mark one as intentional.
|
|
* This isn't fixable from the call site (it strips ANY false-valued content field this app might
|
|
* ever send this way, not just this one) and doesn't matter in practice here -- neither this file's
|
|
* own `callContentOf` nor CallRoot.tsx reads `content.audio`/`content.video`, only `content.
|
|
* callType` (always a non-empty string, never stripped), so a missing boolean never surfaces as a
|
|
* real bug -- but a future call site that DID need to trust an explicit `false` over "field absent"
|
|
* would silently lose that distinction.
|
|
*/
|
|
export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' | 'video'): Promise<{ code: number; params?: { seq: number } }> {
|
|
const content: CallContent = { callId, callType, audio: true, video: callType === 'video' };
|
|
// `createMessage`'s own type only admits Drafty content, but its real implementation (tinode.dev.
|
|
// js:7170-7182) passes any non-string content through untouched -- verified reading that source,
|
|
// not guessed. This cast is deliberately lying to the type system for that one confirmed reason.
|
|
const msg = topic.createMessage(content as unknown as DraftyContent, false);
|
|
msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() });
|
|
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
|
|
}
|
|
|
|
export function sendCallRinging(topic: Topic, seq: number, callId: string) {
|
|
return topic.videoCall('ringing', seq, { callId });
|
|
}
|
|
|
|
export function sendCallAccept(topic: Topic, seq: number, callId: string) {
|
|
return topic.videoCall('accept', seq, { callId });
|
|
}
|
|
|
|
export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) {
|
|
return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId });
|
|
}
|