diff --git a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx index ed2f881..e29207c 100644 --- a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx @@ -2,7 +2,7 @@ // Видео не запрашивается. Флоу: incoming/ringing → connecting → active → терминал, // без pre-call (нет этапа проверки камеры). Параллель VideoCallOverlay, но под аудио. -import { AudioCallView, type AudioCallMode, buildAudioStatus, useCallRtcSession, useLoopingAudio } from "@chatballs/ui"; +import { AudioCallView, type AudioCallMode, buildAudioStatus, isTerminalCallStatus, useCallRtcSession, useLoopingAudio } from "@chatballs/ui"; import { Modal } from "antd"; import { useEffect, useState } from "react"; @@ -58,7 +58,7 @@ export function AudioCallOverlay(props: Props) { const onAccept = () => { void rtc.start(); }; const finish = async () => { const token = props.access?.accessToken; - if (!token || !call || isTerminal(call.status)) return; + if (!token || !call || isTerminalCallStatus(call.status)) return; try { props.onCallChange(await endCallByAccess(token)); } finally { rtc.stop(); } }; @@ -110,19 +110,16 @@ export function AudioCallOverlay(props: Props) { 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 === "REQUESTED" || call.status === "RINGING") return "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"; + if (isTerminalCallStatus(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"; diff --git a/apps/internal-ui/src/features/conversations/call.css b/apps/internal-ui/src/features/conversations/call.css index d088d8e..3676b87 100644 --- a/apps/internal-ui/src/features/conversations/call.css +++ b/apps/internal-ui/src/features/conversations/call.css @@ -22,6 +22,10 @@ max-width: none; max-height: none; aspect-ratio: 9 / 16; + /* Сцена внутри модалки всегда живёт по пропорции, а не по высоте вьюпорта: + на узком окне оператора мобильная ветка audio-call.css иначе растянула бы + карточку на весь экран прямо внутри antd Modal. */ + height: auto; border-radius: 0; box-shadow: none; } diff --git a/apps/internal-ui/src/features/conversations/useConversationCall.ts b/apps/internal-ui/src/features/conversations/useConversationCall.ts index 2d4390e..761993c 100644 --- a/apps/internal-ui/src/features/conversations/useConversationCall.ts +++ b/apps/internal-ui/src/features/conversations/useConversationCall.ts @@ -1,3 +1,4 @@ +import { isTerminalCallStatus } from "@chatballs/ui"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -11,8 +12,6 @@ import { type CallKind, } from "./model"; -const TERMINAL = new Set(["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"]); - type Options = { conversationId: number | null; onConversationChanged: () => void; @@ -38,7 +37,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O useEffect(() => reset(), [conversationId, reset]); useEffect(() => { - if (!open || !call || TERMINAL.has(call.status)) return; + if (!open || !call || isTerminalCallStatus(call.status)) return; const timer = setInterval(async () => { try { setCall(await fetchCall(call.id)); } catch { /* transient */ } }, 2000); diff --git a/apps/web-chat/src/call/AudioCallStage.tsx b/apps/web-chat/src/call/AudioCallStage.tsx index 14701ea..6ebf893 100644 --- a/apps/web-chat/src/call/AudioCallStage.tsx +++ b/apps/web-chat/src/call/AudioCallStage.tsx @@ -106,6 +106,15 @@ export function AudioCallStage({ call, accessToken, iceServers, loading, invalid () => buildAudioCallViewStatus({ loading, invalid, call, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, errorText, close }), [loading, invalid, call, rtc.connectionPhase, rtc.mediaIssue, errorText, close], ); + // «Повторить» на статус-экране: при проблеме с микрофоном/браузером — перепроверка + // устройств, при сорвавшемся соединении — рестарт RTC (раньше и там, и там звался + // prepare, и кнопка на упавшем соединении ничего не чинила). + const onRetry = useCallback(() => { + setErrorText(""); + if (rtc.mediaIssue === "devices" || rtc.mediaIssue === "unsupported") void rtc.prepare(); + else void rtc.restart(); + }, [rtc.mediaIssue, rtc.prepare, rtc.restart]); + 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() || "ОП"; @@ -133,7 +142,7 @@ export function AudioCallStage({ call, accessToken, iceServers, loading, invalid onCancel={() => void onCancel()} onEnd={() => void onEnd()} onClose={() => void close()} - onRetry={() => { setErrorText(""); void rtc.prepare(); }} + onRetry={onRetry} /> ); diff --git a/apps/web-chat/src/call/CallApp.tsx b/apps/web-chat/src/call/CallApp.tsx index fc285a5..1e65a09 100644 --- a/apps/web-chat/src/call/CallApp.tsx +++ b/apps/web-chat/src/call/CallApp.tsx @@ -55,6 +55,10 @@ export function CallApp() { void fetchCallState(token).then((result) => { if (!result) { setInvalid(true); setLoading(false); return; } if (!callKindOf(result.call)) { setInvalid(true); setLoading(false); return; } + // Токен из хеша надо сохранить: хеш уже срезан replaceState, а в пути + // лежит callId, а не invite-токен — после F5 звонок было не восстановить + // (invite одноразовый, resolve на callId отвечает «недействительно»). + sessionStorage.setItem(storageKey(), token); setCall(result.call); setIceServers(result.iceServers ?? []); setLoading(false); diff --git a/apps/web-chat/src/call/model.test.ts b/apps/web-chat/src/call/model.test.ts index 48d9a56..a97b853 100644 --- a/apps/web-chat/src/call/model.test.ts +++ b/apps/web-chat/src/call/model.test.ts @@ -39,6 +39,31 @@ describe("call view model", () => { })).toBe("incoming"); }); + it("keeps a started audio call in reconnecting so the hang-up stays reachable", () => { + expect(resolveAudioCallViewMode({ + loading: false, + invalid: false, + call: { ...call, kind: "AUDIO", status: "ACTIVE" }, + started: true, + connection: "reconnecting", + mediaIssue: "none", + })).toBe("reconnecting"); + }); + + it("never offers a call-back the customer cannot make", () => { + for (const status of ["DECLINED", "MISSED", "EXPIRED", "CANCELLED", "ENDED"]) { + const view = buildAudioCallViewStatus({ + loading: false, + invalid: false, + call: { ...call, kind: "AUDIO", status }, + connection: "idle", + mediaIssue: "none", + close: action, + }); + expect(view?.bar, status).toBe("close"); + } + }); + it("turns an audio action failure into a classified retry state", () => { const status = buildAudioCallViewStatus({ loading: false, diff --git a/apps/web-chat/src/call/model.ts b/apps/web-chat/src/call/model.ts index 47c9b22..4adcbbc 100644 --- a/apps/web-chat/src/call/model.ts +++ b/apps/web-chat/src/call/model.ts @@ -1,4 +1,4 @@ -import { type AudioCallMode, type AudioCallStatus, type CallViewMode, type CallViewStatus, buildAudioStatus } from "@chatballs/ui"; +import { type AudioBarKind, type AudioCallMode, type AudioCallStatus, type CallViewMode, type CallViewStatus, buildAudioStatus, isTerminalCallStatus } from "@chatballs/ui"; import type { CallInfo } from "../api"; @@ -11,7 +11,7 @@ const TERMINAL: Record Boolean(status && TERMINAL[status]); +export const isTerminalCall = isTerminalCallStatus; export function resolveCallViewMode(state: { loading: boolean; invalid: boolean; call: CallInfo | null; started: boolean; connection: string; mediaIssue: string; errorText?: string }): CallViewMode { if (state.loading || state.invalid || state.errorText || !state.call || isTerminalCall(state.call.status) || state.connection === "failed" || state.mediaIssue === "devices" || state.mediaIssue === "unsupported") return "status"; @@ -59,15 +59,24 @@ export function resolveAudioCallViewMode(state: { loading: boolean; invalid: boo return "connecting"; } +// Клиент не инициирует звонки (см. resolveAudioCallViewMode), поэтому «Позвонить +// снова» ему показывать нечем: обработчика нет, и кнопка выходила мёртвой — а на +// DECLINED/MISSED/EXPIRED она была единственной, и с экрана было не уйти. +const clientBar = (bar: AudioBarKind): AudioBarKind => (bar === "retrySingle" || bar === "ended" ? "close" : bar); + +function clientStatus(status: AudioCallStatus | null): AudioCallStatus | undefined { + return status ? { ...status, bar: clientBar(status.bar) } : undefined; +} + export function buildAudioCallViewStatus(state: { loading: boolean; invalid: boolean; call: CallInfo | null; connection: string; mediaIssue: string; errorText?: string; close: () => void }): AudioCallStatus | undefined { - if (state.loading) return { icon: "clock", tone: "warn", title: "Проверяем приглашение", caption: "Секунду…", bar: "ended" }; - if (state.invalid || !state.call) return { icon: "clock", tone: "warn", title: "Приглашение недействительно", caption: "Ссылка устарела или уже была использована. Запросите новое приглашение в чате.", bar: "ended" }; + if (state.loading) return { icon: "clock", tone: "warn", title: "Проверяем приглашение", caption: "Секунду…", bar: "close" }; + if (state.invalid || !state.call) return { icon: "clock", tone: "warn", title: "Приглашение недействительно", caption: "Ссылка устарела или уже была использована. Запросите новое приглашение в чате.", bar: "close" }; if (state.errorText) return { icon: "alert", tone: "error", title: "Не удалось выполнить действие", caption: state.errorText, bar: "retryClose" }; - if (state.mediaIssue === "unsupported") return buildAudioStatus("unsupported", state.call.staffName || "Оператор") ?? undefined; - if (state.mediaIssue === "devices") return buildAudioStatus("nodevice", state.call.staffName || "Оператор") ?? undefined; - if (state.connection === "failed") return buildAudioStatus("FAILED", state.call.staffName || "Оператор") ?? undefined; + if (state.mediaIssue === "unsupported") return clientStatus(buildAudioStatus("unsupported", state.call.staffName || "Оператор")); + if (state.mediaIssue === "devices") return clientStatus(buildAudioStatus("nodevice", state.call.staffName || "Оператор")); + if (state.connection === "failed") return clientStatus(buildAudioStatus("FAILED", state.call.staffName || "Оператор")); const key = state.call.status === "ACCEPTED" || state.call.status === "CONNECTING" ? "connecting" : state.call.status; - return buildAudioStatus(key, state.call.staffName || "Оператор", state.call.durationSeconds ?? undefined) ?? undefined; + return clientStatus(buildAudioStatus(key, state.call.staffName || "Оператор", state.call.durationSeconds ?? undefined)); } export function audioCallStatusLabel(mode: AudioCallMode, status?: AudioCallStatus): string { diff --git a/apps/web-chat/src/styles.css b/apps/web-chat/src/styles.css index 95100f0..2a07601 100644 --- a/apps/web-chat/src/styles.css +++ b/apps/web-chat/src/styles.css @@ -41,6 +41,7 @@ body, .public-call-page { min-height: 100vh; + min-height: 100dvh; display: flex; align-items: center; justify-content: center; @@ -53,6 +54,15 @@ body, font-family: var(--font-sans); } +/* Портретная сцена 9:16 высотой 711px не влезает в низкое окно, и кнопки приёма + уезжали за фолд. Сжимаем по высоте вьюпорта — ширина ведущая, поэтому + пропорция остаётся ровно 9:16 (высоту считает aspect-ratio). */ +@media (min-width: 481px) { + .public-call-page.is-audio .hub-audio-view { + width: min(100%, calc((100dvh - 32px) * 9 / 16)); + } +} + @media (max-width: 480px) { .public-call-page { padding: 0; diff --git a/packages/shared/src/callRtc.ts b/packages/shared/src/callRtc.ts index 5dca4e2..024c5dc 100644 --- a/packages/shared/src/callRtc.ts +++ b/packages/shared/src/callRtc.ts @@ -164,6 +164,10 @@ export class CallRtcClient { case "participant.connection_state": if (message.state === "DISCONNECTED" || message.state === "RECONNECTING") { this.options.handlers.onConnection?.("reconnecting"); + } else if (message.state === "CONNECTED" && this.pc?.connectionState === "connected") { + // Обрыв был у собеседника: наш pc не менял состояния, и без этой ветки + // UI навсегда оставался в «Переподключении» после его возвращения. + this.options.handlers.onConnection?.("connected"); } return; default: diff --git a/packages/ui/src/call/AudioCallView.tsx b/packages/ui/src/call/AudioCallView.tsx index 14fbeb6..4d5c517 100644 --- a/packages/ui/src/call/AudioCallView.tsx +++ b/packages/ui/src/call/AudioCallView.tsx @@ -59,7 +59,7 @@ export function AudioCallView(props: Props) {
{props.statusLabel ?? defaultStatusLabel(props.mode, status)} - {props.showChannel !== false && {props.channelLabel ?? ""}} + {props.showChannel !== false && props.channelLabel && {props.channelLabel}}
{isActive && {timer}}
@@ -101,6 +101,9 @@ export function AudioCallView(props: Props) { {props.mode === "connecting" && ( } onClick={props.onCancel ?? props.onEnd} /> )} + {props.mode === "reconnecting" && ( + } onClick={props.onEnd} /> + )} {isActive && (
@@ -185,6 +188,13 @@ function StatusBar({ status, onClose, onCallAgain, onRetry, onEnd }: { status: A if (bar === "reconnect") { return } onClick={onEnd} />; } + if (bar === "close") { + return ( +
+ Закрыть +
+ ); + } if (bar === "retrySingle") { return (
diff --git a/packages/ui/src/call/AudioCallWaveform.tsx b/packages/ui/src/call/AudioCallWaveform.tsx index f82be6b..af59636 100644 --- a/packages/ui/src/call/AudioCallWaveform.tsx +++ b/packages/ui/src/call/AudioCallWaveform.tsx @@ -65,8 +65,10 @@ export function AudioCallWaveform({ stream, micOn = true, active }: Props) { } const speaking = active && micOn; const t = ts / 1000; - const base = speaking ? SPEAK_AMP : IDLE_AMP; - const ampScale = speaking ? (0.35 + 0.65 * level) : 1; + // В тишине волна ровно такая же, как idle: амплитуда растёт от IDLE_AMP к + // SPEAK_AMP строго по громкости. Раньше базой всегда был SPEAK_AMP, и линия + // заметно колыхалась, даже когда собеседник молчал. + const base = speaking ? IDLE_AMP + (SPEAK_AMP - IDLE_AMP) * level : IDLE_AMP; ctx.lineWidth = 3; ctx.lineJoin = "round"; @@ -75,7 +77,7 @@ export function AudioCallWaveform({ stream, micOn = true, active }: Props) { for (let x = 0; x <= W; x += 4) { const p = x / W; const env = Math.sin(p * Math.PI); // taper ends - const amp = env * H * 0.36 * base * ampScale * (0.6 + 0.4 * Math.sin(t * 2.1 + p * 3)); + const amp = env * H * 0.36 * base * (0.6 + 0.4 * Math.sin(t * 2.1 + p * 3)); const y = mid + Math.sin(p * 22 + t * 6) * amp + Math.sin(p * 9 - t * 3.3) * amp * 0.5; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); diff --git a/packages/ui/src/call/audio-call.css b/packages/ui/src/call/audio-call.css index 3203f71..502eba2 100644 --- a/packages/ui/src/call/audio-call.css +++ b/packages/ui/src/call/audio-call.css @@ -146,6 +146,17 @@ .hub-audio-pill.solid { flex: 1; border: none; background: #fff; color: #141414; } .hub-audio-pill.ghost { flex: 1; border: 1px solid rgba(255, 255, 255, .3); background: rgba(255, 255, 255, .08); color: #fff; } +/* На телефоне сцена занимает весь экран. Высота берётся от вьюпорта, а не `100%`: + родитель страницы звонка задаёт только `min-height`, его высота неопределённа, + и процент схлопывался в высоту по контенту — окно выходило ~412x427 вместо + 412x915. Пара vh/dvh — как в call-view.css у видеозвонка. */ @media (max-width: 480px) { - .hub-audio-view { max-width: 100%; max-height: none; aspect-ratio: auto; height: 100%; border-radius: 0; } + .hub-audio-view { + max-width: 100%; + max-height: none; + aspect-ratio: auto; + height: 100vh; + height: 100dvh; + border-radius: 0; + } } diff --git a/packages/ui/src/call/audioCallStates.ts b/packages/ui/src/call/audioCallStates.ts index 0f82e1a..faec10e 100644 --- a/packages/ui/src/call/audioCallStates.ts +++ b/packages/ui/src/call/audioCallStates.ts @@ -19,12 +19,15 @@ export type AudioCallStatus = { }; // Какой нижний бар рисовать для текущего состояния (вне active/incoming/ringing/connecting). -export type AudioBarKind = "ended" | "retrySingle" | "retryClose" | "reconnect" | "retryCheck"; +// "close" — только «Закрыть»: сторона, которая не может инициировать звонок (клиент). +export type AudioBarKind = "ended" | "close" | "retrySingle" | "retryClose" | "reconnect" | "retryCheck"; export type AudioStatusEntry = AudioCallStatus; // Терминальные состояния, приходящие из домена звонков (TERMINAL_CALL_STATUSES). -export const AUDIO_TERMINAL_STATUSES = new Set([ +// Единственный источник правды для фронтенда: и аудио-, и видеозвонок, и оператор, +// и RTC-сессия сверяются с этим набором — раньше он был скопирован в четыре места. +export const TERMINAL_CALL_STATUSES = new Set([ "DECLINED", "CANCELLED", "MISSED", @@ -33,7 +36,7 @@ export const AUDIO_TERMINAL_STATUSES = new Set([ "EXPIRED", ]); -export const isAudioTerminal = (status?: string) => Boolean(status && AUDIO_TERMINAL_STATUSES.has(status)); +export const isTerminalCallStatus = (status?: string) => Boolean(status && TERMINAL_CALL_STATUSES.has(status)); function dur(prefix: string, duration?: number | null) { return duration != null && duration > 0 ? `${prefix}Длительность ${formatDuration(duration)}. ` : prefix; diff --git a/packages/ui/src/call/useCallRtcSession.ts b/packages/ui/src/call/useCallRtcSession.ts index 261b831..c06ffe0 100644 --- a/packages/ui/src/call/useCallRtcSession.ts +++ b/packages/ui/src/call/useCallRtcSession.ts @@ -1,6 +1,8 @@ import { CallRtcClient, type CallSide, type PublicCallState, type RtcConnectionPhase } from "@chatballs/shared"; import { useCallback, useEffect, useRef, useState } from "react"; +import { isTerminalCallStatus } from "./audioCallStates"; + export type CallMediaIssue = "none" | "video" | "devices" | "unsupported"; type Options = { @@ -13,8 +15,6 @@ type Options = { onCallState: (call: PublicCallState) => void; }; -const TERMINAL = new Set(["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"]); - export function useCallRtcSession(options: Options) { const [localStream, setLocalStream] = useState(null); const [remoteStream, setRemoteStream] = useState(null); @@ -135,7 +135,7 @@ export function useCallRtcSession(options: Options) { handlers: { onCallState: (call) => { onCallStateRef.current(call); - if (TERMINAL.has(call.status)) queueMicrotask(stop); + if (isTerminalCallStatus(call.status)) queueMicrotask(stop); }, onRemoteStream: setRemoteStream, onRemoteMedia: ({ mic, cam }) => { @@ -157,23 +157,21 @@ export function useCallRtcSession(options: Options) { return pending; }, [options.accessToken, options.iceServers, options.side, prepare, stop]); + // Побочные эффекты (сигналинг + track.enabled) держим снаружи updater'а: React в + // StrictMode вызывает updater дважды, а внутри него они бы отработали по два раза. const toggleMic = useCallback(() => { - setMicOn((current) => { - const next = !current; - clientRef.current?.setMediaState(next, camOn); - localRef.current?.getAudioTracks().forEach((track) => { track.enabled = next; }); - return next; - }); - }, [camOn]); + const next = !micOn; + setMicOn(next); + clientRef.current?.setMediaState(next, camOn); + localRef.current?.getAudioTracks().forEach((track) => { track.enabled = next; }); + }, [camOn, micOn]); const toggleCam = useCallback(() => { - setCamOn((current) => { - const next = !current; - clientRef.current?.setMediaState(micOn, next); - localRef.current?.getVideoTracks().forEach((track) => { track.enabled = next; }); - return next; - }); - }, [micOn]); + const next = !camOn; + setCamOn(next); + clientRef.current?.setMediaState(micOn, next); + localRef.current?.getVideoTracks().forEach((track) => { track.enabled = next; }); + }, [camOn, micOn]); const end = useCallback(() => clientRef.current?.end(), []); const restart = useCallback(async () => { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d8d5465..95899ee 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,7 +1,7 @@ export { buildTheme, chatballsTheme } from "./theme"; export { CallView, type CallViewMode, type CallViewStatus } from "./call/CallView"; export { AudioCallView } from "./call/AudioCallView"; -export { type AudioCallMode, type AudioCallStatus, buildAudioStatus, isAudioTerminal, formatDuration as formatAudioDuration } from "./call/audioCallStates"; +export { type AudioBarKind, type AudioCallMode, type AudioCallStatus, buildAudioStatus, isTerminalCallStatus, TERMINAL_CALL_STATUSES, formatDuration as formatAudioDuration } from "./call/audioCallStates"; export { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession"; export { useAudioCue, useLoopingAudio } from "./audio/useAudio"; export { Loader } from "./loader/Loader";