🚑 fix(calls): bootstrap call before kind dispatch in web-chat call page

CallApp returned <AudioCallStage> before the bootstrap effect ran: on first
render call was null, so call?.kind !== 'VIDEO' was always true and the audio
scene rendered with empty call/accessToken/iceServers — RTC never started and
the screen stayed idle. The invite banner and the call page also checked kind
inversely (kind !== 'AUDIO' vs kind !== 'VIDEO'), diverging whenever kind was
undefined, so the widget labelled an underdetermined call as video.

- api.ts: single isVideoCall(call) helper (default AUDIO); used by both the
  invite banner and the call page so they agree on the kind.
- CallApp: composition-only dispatcher — bootstrap (invite/access token,
  polling) runs unconditionally, all hooks called, branching only in JSX by
  isVideoCall. No early return from the middle of the hook body.
- VideoCallStage: video path factored out of CallApp (symmetric to
  AudioCallStage), reusing the loaded call/access/iceServers.
- useConnectionTimer: shared connection timer; removes the duplicate from
  AudioCallStage.
This commit is contained in:
Andrey committed 2026-07-31 21:35:16 +03:00
1 parent b434656d4b
commit c34b181681
6 files changed
+152 -136

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, type RefObject } from "react";
import type { CallInfo, WebConfig, WebMessage } from "./api";
import { isVideoCall, type CallInfo, type WebConfig, type WebMessage } from "./api";
export function ChatHeader({ accent, letter, title, statusLabel, statusDot, unavailable, onClose }: { accent: string; letter: string; title: string; statusLabel: string; statusDot: string; unavailable: boolean; onClose: () => void }) {
return (
@@ -85,7 +85,7 @@ export function ChatComposer({ accent, state, quickReplies, pendingCount, messag
export function CallInviteBanner({ call, accent, onAccept, onDecline }: { call: CallInfo; accent: string; onAccept: () => void; onDecline: () => void }) {
const phoneIcon = (rotated: boolean) => <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" style={rotated ? { transform: "rotate(135deg)" } : undefined}><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>;
const isVideo = call.kind !== "AUDIO";
const isVideo = isVideoCall(call);
const inviteIcon = isVideo
? <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M23 7l-7 5 7 5V7z" /><rect x="1" y="5" width="15" height="14" rx="2.5" /></svg>
: <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.34a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.74-1.27a2 2 0 0 1 2.11-.45c.74.34 1.53.57 2.34.7A2 2 0 0 1 22 16.92z" /></svg>;
+4
View File
@@ -27,6 +27,10 @@ export type CallInfo = {
durationSeconds?: number | null;
};
// Единая проверка типа звонка (default — AUDIO). Используется и баннером виджета,
// и страницей звонка, чтобы они не расходились при неопределённом kind.
export const isVideoCall = (call: CallInfo | null | undefined): boolean => call?.kind === "VIDEO";
export type CallBootstrap = { call: CallInfo; accessToken: string; iceServers: RTCIceServer[] };
export type CallStateEnvelope = { call: CallInfo; iceServers: RTCIceServer[] };
+1 -15
View File
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { acceptCall, declineCall, fetchCallState, type CallInfo } from "../api";
import { audioCallStatusLabel, buildAudioCallViewStatus, isTerminalCall, resolveAudioCallViewMode } from "./model";
import { useConnectionTimer } from "./useConnectionTimer";
type Props = {
call: CallInfo | null;
@@ -108,18 +109,3 @@ export function AudioCallStage({ call, accessToken, iceServers, loading, invalid
</main>
);
}
function useConnectionTimer(active: boolean) {
const [startedAt, setStartedAt] = useState<number | null>(null);
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (!active) return;
const origin = startedAt ?? Date.now();
if (startedAt == null) setStartedAt(origin);
const update = () => setSeconds(Math.floor((Date.now() - origin) / 1000));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [active, startedAt]);
return seconds;
}
+14 -119
View File
@@ -1,16 +1,22 @@
import { CallView, useCallRtcSession, useLoopingAudio } from "@edevs/ui";
import { useCallback, useEffect, useMemo, useState } from "react";
// Страница звонка /calls/<invite>: bootstrap (invite token / access token из
// ссылок TG/MAX или session-storage) и поллинг состояния, затем диспетчер по типу
// звонка — VIDEO → VideoCallStage, иначе → AudioCallStage (default AUDIO).
//
// Composition-only: RTC, UI-сцена и таймер живут в Stage-компонентах. Здесь только
// загрузка данных звонка и роутинг по kind. Все хуки вызваны безусловно — правила
// хуков соблюдены, ветвление только в JSX (ранних return из середины тела нет).
import { useCallback, useEffect, useState } from "react";
import {
acceptCall,
declineCall,
fetchCallState,
isVideoCall,
resolveCallInvite,
type CallBootstrap,
type CallInfo,
} from "../api";
import { AudioCallStage } from "./AudioCallStage";
import { buildCallViewStatus, callViewSubtitle, isTerminalCall, resolveCallViewMode } from "./model";
import { VideoCallStage } from "./VideoCallStage";
function storageKey() { return `edevs-call:${location.pathname}`; }
function inviteTokenFromPath() { return location.pathname.match(/\/calls\/([^/]+)/)?.[1] ?? ""; }
@@ -22,28 +28,6 @@ export function CallApp() {
const [call, setCall] = useState<CallInfo | null>(null);
const [accessToken, setAccessToken] = useState("");
const [iceServers, setIceServers] = useState<RTCIceServer[]>([]);
const [started, setStarted] = useState(false);
const [joining, setJoining] = useState(false);
const rtc = useCallRtcSession({
resetKey: call?.callId ?? "",
previewEnabled: Boolean(call && accessToken && !isTerminalCall(call.status) && !started),
accessToken,
side: "CUSTOMER",
iceServers,
onCallState: (state) => setCall((current) => current ? { ...current, ...state } : state),
});
const close = useCallback(() => {
if (started) rtc.end();
if (history.length > 1) history.back(); else window.close();
}, [rtc.end, started]);
// Тип звонка определяется из приглашения/состояния. Все хуки выше вызваны до
// условного return, поэтому их количество постоянно (rules of hooks). Аудио
// рендерится AudioCallStage (своим RTC); видеопуть — ниже без изменений.
if (call?.kind !== "VIDEO") {
return <AudioCallStage call={call} accessToken={accessToken} iceServers={iceServers} loading={loading} invalid={invalid} onCall={setCall} />;
}
const applyBootstrap = useCallback((value: CallBootstrap) => {
sessionStorage.setItem(storageKey(), value.accessToken);
@@ -76,96 +60,7 @@ export function CallApp() {
});
}, [applyBootstrap]);
useEffect(() => {
if (!accessToken || !call || isTerminalCall(call.status)) return;
const timer = setInterval(async () => {
const result = await fetchCallState(accessToken);
if (result) {
setCall(result.call);
if (result.iceServers) setIceServers(result.iceServers);
}
}, 2000);
return () => clearInterval(timer);
}, [accessToken, call?.status]);
useEffect(() => {
if (isTerminalCall(call?.status)) rtc.stop();
}, [call?.status, rtc.stop]);
async function join() {
if (!accessToken || joining) return;
setJoining(true);
if (call?.status === "REQUESTED" || call?.status === "RINGING") {
const accepted = await acceptCall(accessToken);
if (accepted) setCall(accepted);
}
setStarted(true);
rtc.start();
setJoining(false);
}
async function cancelPrecall() {
if (!accessToken) return;
if (call?.status === "REQUESTED" || call?.status === "RINGING") {
const declined = await declineCall(accessToken);
if (declined) setCall(declined);
return;
}
setStarted(true);
if (rtc.start()) rtc.end();
}
const mode = resolveCallViewMode({ loading, invalid, call, started, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue });
useLoopingAudio("/chat/audio/ringtone.mp3", !started && (call?.status === "REQUESTED" || call?.status === "RINGING"));
const status = useMemo(
() => buildCallViewStatus({ loading, invalid, call, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, close, retry: rtc.restart, prepare: rtc.prepare, join: () => void join() }),
[loading, invalid, call, joining, rtc.connectionPhase, rtc.mediaIssue, close, rtc.restart, rtc.prepare],
);
const elapsed = useConnectionTimer(rtc.connectionPhase === "connected");
const peerName = call?.staffName || "Оператор";
const initials = peerName.trim().split(/\s+/).map((part) => part[0]).slice(0, 2).join("").toUpperCase() || "ОП";
const mediaCaption = rtc.mediaIssue === "devices" ? "Нет доступа к камере и микрофону" : rtc.mediaIssue === "video" ? "Камера недоступна" : "Камера выключена";
return (
<main className="public-call-page">
<CallView
mode={mode}
peerName={peerName}
peerInitials={initials}
subtitle={callViewSubtitle(mode, status)}
localStream={rtc.localStream}
remoteStream={rtc.remoteStream}
micOn={rtc.micOn}
camOn={rtc.camOn}
remoteMicOn={rtc.remoteMicOn}
remoteCamOn={rtc.remoteCamOn}
mediaCaption={mediaCaption}
elapsedSeconds={elapsed}
status={status}
joining={joining || rtc.preparing}
cancelLabel="Отклонить"
onToggleMic={rtc.toggleMic}
onToggleCam={rtc.toggleCam}
onJoin={() => void join()}
onCancel={() => void cancelPrecall()}
onEnd={rtc.end}
onClose={close}
/>
</main>
);
}
function useConnectionTimer(active: boolean) {
const [startedAt, setStartedAt] = useState<number | null>(null);
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (!active) return;
const origin = startedAt ?? Date.now();
if (startedAt == null) setStartedAt(origin);
const update = () => setSeconds(Math.floor((Date.now() - origin) / 1000));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [active, startedAt]);
return seconds;
return isVideoCall(call)
? <VideoCallStage call={call} accessToken={accessToken} iceServers={iceServers} loading={loading} invalid={invalid} onCall={setCall} />
: <AudioCallStage call={call} accessToken={accessToken} iceServers={iceServers} loading={loading} invalid={invalid} onCall={setCall} />;
}
+113
View File
@@ -0,0 +1,113 @@
// Видеозвонок (клиент): видео-сцена страницы /calls/<invite>. Не делает bootstrap
// (он выполнен в CallApp) — переиспользует загруженные call/accessToken/iceServers,
// держит свой видео-RTC и поллинг состояния до терминала.
import { CallView, useCallRtcSession, useLoopingAudio } from "@edevs/ui";
import { useCallback, useEffect, useMemo, useState } from "react";
import { acceptCall, declineCall, fetchCallState, type CallInfo } from "../api";
import { buildCallViewStatus, callViewSubtitle, isTerminalCall, resolveCallViewMode } from "./model";
import { useConnectionTimer } from "./useConnectionTimer";
type Props = {
call: CallInfo | null;
accessToken: string;
iceServers: RTCIceServer[];
loading: boolean;
invalid: boolean;
onCall: (call: CallInfo) => void;
};
export function VideoCallStage({ call, accessToken, iceServers, loading, invalid, onCall }: Props) {
const [started, setStarted] = useState(false);
const [joining, setJoining] = useState(false);
const rtc = useCallRtcSession({
resetKey: call?.callId ?? "",
previewEnabled: Boolean(call && accessToken && !isTerminalCall(call.status) && !started),
accessToken,
side: "CUSTOMER",
iceServers,
onCallState: (state) => { if (call) onCall({ ...call, ...state }); else onCall(state); },
});
const close = useCallback(() => {
if (started) rtc.end();
if (history.length > 1) history.back(); else window.close();
}, [rtc.end, started]);
// Поллинг состояния до терминала.
useEffect(() => {
if (!accessToken || !call || isTerminalCall(call.status)) return;
const timer = setInterval(async () => {
const result = await fetchCallState(accessToken);
if (result) onCall(result.call);
}, 2000);
return () => clearInterval(timer);
}, [accessToken, call?.status, onCall]);
useEffect(() => {
if (isTerminalCall(call?.status)) rtc.stop();
}, [call?.status, rtc.stop]);
async function join() {
if (!accessToken || joining) return;
setJoining(true);
if (call?.status === "REQUESTED" || call?.status === "RINGING") {
const accepted = await acceptCall(accessToken);
if (accepted) onCall(accepted);
}
setStarted(true);
rtc.start();
setJoining(false);
}
async function cancelPrecall() {
if (!accessToken) return;
if (call?.status === "REQUESTED" || call?.status === "RINGING") {
const declined = await declineCall(accessToken);
if (declined) onCall(declined);
return;
}
setStarted(true);
if (rtc.start()) rtc.end();
}
const mode = resolveCallViewMode({ loading, invalid, call, started, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue });
useLoopingAudio("/chat/audio/ringtone.mp3", !started && (call?.status === "REQUESTED" || call?.status === "RINGING"));
const status = useMemo(
() => buildCallViewStatus({ loading, invalid, call, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, close, retry: rtc.restart, prepare: rtc.prepare, join: () => void join() }),
[loading, invalid, call, joining, rtc.connectionPhase, rtc.mediaIssue, close, rtc.restart, rtc.prepare],
);
const elapsed = useConnectionTimer(rtc.connectionPhase === "connected");
const peerName = call?.staffName || "Оператор";
const initials = peerName.trim().split(/\s+/).map((part) => part[0]).slice(0, 2).join("").toUpperCase() || "ОП";
const mediaCaption = rtc.mediaIssue === "devices" ? "Нет доступа к камере и микрофону" : rtc.mediaIssue === "video" ? "Камера недоступна" : "Камера выключена";
return (
<main className="public-call-page">
<CallView
mode={mode}
peerName={peerName}
peerInitials={initials}
subtitle={callViewSubtitle(mode, status)}
localStream={rtc.localStream}
remoteStream={rtc.remoteStream}
micOn={rtc.micOn}
camOn={rtc.camOn}
remoteMicOn={rtc.remoteMicOn}
remoteCamOn={rtc.remoteCamOn}
mediaCaption={mediaCaption}
elapsedSeconds={elapsed}
status={status}
joining={joining || rtc.preparing}
cancelLabel="Отклонить"
onToggleMic={rtc.toggleMic}
onToggleCam={rtc.toggleCam}
onJoin={() => void join()}
onCancel={() => void cancelPrecall()}
onEnd={rtc.end}
onClose={close}
/>
</main>
);
}
@@ -0,0 +1,18 @@
import { useEffect, useState } from "react";
// Секундомер активного соединения: стартует при `active=true`, фиксирует момент
// старта и тикает раз в секунду. Используется и аудио-, и видеостадией звонка.
export function useConnectionTimer(active: boolean): number {
const [startedAt, setStartedAt] = useState<number | null>(null);
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (!active) return;
const origin = startedAt ?? Date.now();
if (startedAt == null) setStartedAt(origin);
const update = () => setSeconds(Math.floor((Date.now() - origin) / 1000));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [active, startedAt]);
return seconds;
}