Files
chatballs/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx
T
AndreyandClaude Fable 5.1 a891947016 🧹 chore!: продукт без привязок к вендору — домены, почта, продукты, скоупы, события виджета
- нет зашитых доменов: help-домен по умолчанию localhost (задаёт установщик через
  CHATBALLS_HELP_BASE_DOMAIN), фронт определяет портал помощи пробой /api/v1/help/,
  ALLOWED_HOSTS без вендорских хостов, CI environment url — из CHATBALLS_APP_DOMAIN
- DEFAULT_FROM_EMAIL по умолчанию no-reply@localhost; JWT-аудитория support-токена
  chatballs.support; загрузчик виджета: window.ChatballsChat, события chatballs-chat-*
- «Контакты»: продукты берутся из данных организации ({code, name}), цвет —
  детерминированно по коду; зашитый список продуктов вендора убран
- npm-скоупы @chatballs/*, тема chatballsTheme/buildTheme
- тестовый bootstrap: организация demo, сотрудник staff.member@example.org,
  продукты site/app; фикстуры на example.com
- убрана мёртвая привязка Shell к e-mail сотрудника вендора

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:33:22 +03:00

159 lines
7.0 KiB
TypeScript

// Аудиозвонок (оператор): antd Modal + AudioCallView (baseline «Аудиозвонок.dc.html»).
// Видео не запрашивается. Флоу: incoming/ringing → connecting → active → терминал,
// без pre-call (нет этапа проверки камеры). Параллель VideoCallOverlay, но под аудио.
import { AudioCallView, type AudioCallMode, buildAudioStatus, useCallRtcSession, useLoopingAudio } from "@chatballs/ui";
import { Modal } from "antd";
import { useEffect, useState } from "react";
import { providerMeta } from "../../shared/providers";
import { endCallByAccess, type ApiCall, type CallAccess } from "./model";
import type { ConversationListItem } from "./types";
import "./call.css";
type Props = {
open: boolean;
dialog: ConversationListItem | null;
call: ApiCall | null;
requestedKind?: ApiCall["kind"] | null;
access: CallAccess | null;
errorText: string;
onCallChange: (call: ApiCall) => void;
onCancel: () => void;
onRetry: () => void;
onClose: () => void;
};
export function AudioCallOverlay(props: Props) {
const call = props.call;
const [speakerOn, setSpeakerOn] = useState(true);
const rtc = useCallRtcSession({
resetKey: call?.id ?? "",
previewEnabled: false,
accessToken: props.access?.accessToken ?? "",
side: "STAFF",
iceServers: props.access?.iceServers ?? [],
videoEnabled: false,
onCallState: (state) => {
if (call) props.onCallChange({ ...call, status: state.status as ApiCall["status"], endedBy: state.endedBy ?? null, durationSeconds: state.durationSeconds ?? null });
},
});
const elapsed = useElapsed(call?.connectedAt ?? null, call?.status === "ACTIVE");
const mode = resolveAudioMode(call, props.errorText, rtc.connectionPhase, rtc.mediaIssue);
useLoopingAudio("/audio/ringtone.mp3", props.open && (mode === "incoming" || mode === "ringing"), 0.5);
useEffect(() => {
if (!props.open || !props.access || !call || rtc.connectionPhase !== "idle") return;
if (call.status === "ACCEPTED" || call.status === "CONNECTING") void rtc.start();
}, [props.open, props.access, call?.status, rtc.connectionPhase, rtc.start]);
if (!props.dialog) return null;
const channel = providerMeta[props.dialog.channel];
const subCaption = mode === "active" ? (rtc.micOn ? "Говорите" : "Ваш микрофон выключен") : undefined;
const builtStatus = buildAudioStatus(audioStatusKey(mode, call, props.errorText, rtc.connectionPhase, rtc.mediaIssue), props.dialog.name, call?.durationSeconds ?? undefined);
const status = props.errorText && builtStatus
? { ...builtStatus, caption: props.errorText }
: builtStatus;
const onAccept = () => { void rtc.start(); };
const finish = async () => {
const token = props.access?.accessToken;
if (!token || !call || isTerminal(call.status)) return;
try { props.onCallChange(await endCallByAccess(token)); }
finally { rtc.stop(); }
};
const onEnd = () => { void finish(); };
const endAndClose = async () => {
if (mode === "active" || mode === "reconnecting" || mode === "connecting") await finish();
props.onClose();
};
// retry для статус-экрана: при проблемах с устройствами/браузером — перепроверка,
// иначе — пересоздание звонка (props.onRetry) или рестарт соединения.
const onRetry = () => {
if (rtc.mediaIssue === "devices" || rtc.mediaIssue === "unsupported") void rtc.prepare();
else if (rtc.connectionPhase === "failed") void rtc.restart();
else props.onRetry();
};
return (
<Modal open={props.open} onCancel={() => void endAndClose()} footer={null} closable={false} width={428} className="call-modal audio-call-modal" destroyOnHidden>
<AudioCallView
mode={mode}
status={status ?? undefined}
peerName={props.dialog.name}
peerInitials={props.dialog.initials}
channelLabel={channel.label}
showChannel
micOn={rtc.micOn}
speakerOn={speakerOn}
remoteStream={rtc.remoteStream}
remoteMicOn={rtc.remoteMicOn}
elapsedSeconds={elapsed}
durationSeconds={call?.durationSeconds ?? null}
mediaIssue={rtc.mediaIssue}
statusLabel={statusLabel(mode)}
subCaption={subCaption}
onToggleMic={rtc.toggleMic}
onToggleSpeaker={() => setSpeakerOn((current) => !current)}
onAccept={onAccept}
onDecline={props.onCancel}
onCancel={mode === "ringing" ? props.onCancel : () => void endAndClose()}
onEnd={onEnd}
onClose={() => void endAndClose()}
onCallAgain={props.onRetry}
onRetry={onRetry}
/>
</Modal>
);
}
function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode {
if (errorText || !call) return "status";
if (mediaIssue === "devices" || mediaIssue === "unsupported") return "status";
if (call.status === "REQUESTED" || call.status === "RINGING") return call.status === "REQUESTED" ? "ringing" : "ringing";
if (call.status === "ACCEPTED") return connection === "connected" ? "active" : "connecting";
if (connection === "reconnecting") return "reconnecting";
if (connection === "failed") return "status";
if (call.status === "ACTIVE" || connection === "connected") return "active";
if (isTerminal(call.status)) return "status";
return "connecting";
}
function isTerminal(status: string) {
return ["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"].includes(status);
}
// Ключ статуса, который buildAudioStatus умеет превратить в статус-центр.
function audioStatusKey(mode: AudioCallMode, call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): string {
if (errorText) return "FAILED";
if (mediaIssue === "unsupported") return "unsupported";
if (mediaIssue === "devices") return "nodevice";
if (mode === "status") {
if (connection === "failed") return "FAILED";
return call?.status ?? "ENDED";
}
if (mode === "connecting" || mode === "reconnecting") return mode;
return call?.status ?? "ENDED";
}
function statusLabel(mode: AudioCallMode): string {
if (mode === "incoming") return "Входящий звонок";
if (mode === "ringing") return "Исходящий звонок";
if (mode === "active") return "Аудиозвонок";
if (mode === "connecting") return "Соединение";
if (mode === "reconnecting") return "Переподключение";
return "";
}
function useElapsed(connectedAt: string | null, active: boolean) {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (!connectedAt || !active) return;
const update = () => setSeconds(Math.max(0, Math.floor((Date.now() - new Date(connectedAt).getTime()) / 1000)));
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [active, connectedAt]);
return seconds;
}