Files
im_webapp/web/src/lib/tinode/calls.ts
T
Your NameandClaude Sonnet 5 5c3f3b7653 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>
2026-09-09 20:16:33 +08:00

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 });
}