From c34b181681827e1a917da79f71ef92cdd48e2c38 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 31 Jul 2026 21:35:16 +0300 Subject: [PATCH] :ambulance: fix(calls): bootstrap call before kind dispatch in web-chat call page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CallApp returned 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. --- apps/web-chat/src/ChatView.tsx | 4 +- apps/web-chat/src/api.ts | 4 + apps/web-chat/src/call/AudioCallStage.tsx | 16 +-- apps/web-chat/src/call/CallApp.tsx | 133 ++----------------- apps/web-chat/src/call/VideoCallStage.tsx | 113 ++++++++++++++++ apps/web-chat/src/call/useConnectionTimer.ts | 18 +++ 6 files changed, 152 insertions(+), 136 deletions(-) create mode 100644 apps/web-chat/src/call/VideoCallStage.tsx create mode 100644 apps/web-chat/src/call/useConnectionTimer.ts diff --git a/apps/web-chat/src/ChatView.tsx b/apps/web-chat/src/ChatView.tsx index de500ff..5ede955 100644 --- a/apps/web-chat/src/ChatView.tsx +++ b/apps/web-chat/src/ChatView.tsx @@ -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) => ; - const isVideo = call.kind !== "AUDIO"; + const isVideo = isVideoCall(call); const inviteIcon = isVideo ? : ; diff --git a/apps/web-chat/src/api.ts b/apps/web-chat/src/api.ts index fdfcd53..3a0776d 100644 --- a/apps/web-chat/src/api.ts +++ b/apps/web-chat/src/api.ts @@ -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[] }; diff --git a/apps/web-chat/src/call/AudioCallStage.tsx b/apps/web-chat/src/call/AudioCallStage.tsx index 978943a..92b1089 100644 --- a/apps/web-chat/src/call/AudioCallStage.tsx +++ b/apps/web-chat/src/call/AudioCallStage.tsx @@ -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 ); } - -function useConnectionTimer(active: boolean) { - const [startedAt, setStartedAt] = useState(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; -} diff --git a/apps/web-chat/src/call/CallApp.tsx b/apps/web-chat/src/call/CallApp.tsx index 47bb691..e8ec95c 100644 --- a/apps/web-chat/src/call/CallApp.tsx +++ b/apps/web-chat/src/call/CallApp.tsx @@ -1,16 +1,22 @@ -import { CallView, useCallRtcSession, useLoopingAudio } from "@edevs/ui"; -import { useCallback, useEffect, useMemo, useState } from "react"; +// Страница звонка /calls/: 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(null); const [accessToken, setAccessToken] = useState(""); const [iceServers, setIceServers] = useState([]); - 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 ; - } 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 ( -
- void join()} - onCancel={() => void cancelPrecall()} - onEnd={rtc.end} - onClose={close} - /> -
- ); -} - -function useConnectionTimer(active: boolean) { - const [startedAt, setStartedAt] = useState(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) + ? + : ; } diff --git a/apps/web-chat/src/call/VideoCallStage.tsx b/apps/web-chat/src/call/VideoCallStage.tsx new file mode 100644 index 0000000..fbd9225 --- /dev/null +++ b/apps/web-chat/src/call/VideoCallStage.tsx @@ -0,0 +1,113 @@ +// Видеозвонок (клиент): видео-сцена страницы /calls/. Не делает 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 ( +
+ void join()} + onCancel={() => void cancelPrecall()} + onEnd={rtc.end} + onClose={close} + /> +
+ ); +} diff --git a/apps/web-chat/src/call/useConnectionTimer.ts b/apps/web-chat/src/call/useConnectionTimer.ts new file mode 100644 index 0000000..86c22d4 --- /dev/null +++ b/apps/web-chat/src/call/useConnectionTimer.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +// Секундомер активного соединения: стартует при `active=true`, фиксирует момент +// старта и тикает раз в секунду. Используется и аудио-, и видеостадией звонка. +export function useConnectionTimer(active: boolean): number { + const [startedAt, setStartedAt] = useState(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; +}