mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
🚑 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:
1 parent
b434656d4b
commit
c34b181681
6 files changed
+152
-136
No files matched your search
@@ -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>;
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user