diff --git a/apps/backend/hub_platform/calls/migrations/0005_callsession_kind.py b/apps/backend/hub_platform/calls/migrations/0005_callsession_kind.py new file mode 100644 index 0000000..e7b23ca --- /dev/null +++ b/apps/backend/hub_platform/calls/migrations/0005_callsession_kind.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('calls', '0004_alter_callinvite_organization_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='callsession', + name='kind', + field=models.CharField( + choices=[('AUDIO', 'Аудиозвонок'), ('VIDEO', 'Видеозвонок')], + db_index=True, + default='AUDIO', + max_length=8, + ), + ), + ] diff --git a/apps/backend/hub_platform/calls/models.py b/apps/backend/hub_platform/calls/models.py index 3a190a8..74644b2 100644 --- a/apps/backend/hub_platform/calls/models.py +++ b/apps/backend/hub_platform/calls/models.py @@ -70,12 +70,18 @@ class CallConnectionType(models.TextChoices): UNKNOWN = "UNKNOWN", "Неизвестно" +class CallKind(models.TextChoices): + AUDIO = "AUDIO", "Аудиозвонок" + VIDEO = "VIDEO", "Видеозвонок" + + class CallSession(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="call_sessions") conversation = models.ForeignKey("conversations.Conversation", on_delete=models.PROTECT, related_name="call_sessions") initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="initiated_calls") status = models.CharField(max_length=16, choices=CallStatus.choices, default=CallStatus.REQUESTED, db_index=True) + kind = models.CharField(max_length=8, choices=CallKind.choices, default=CallKind.AUDIO, db_index=True) requested_at = models.DateTimeField(auto_now_add=True) accepted_at = models.DateTimeField(null=True, blank=True) connected_at = models.DateTimeField(null=True, blank=True) diff --git a/apps/backend/hub_platform/calls/serializers.py b/apps/backend/hub_platform/calls/serializers.py index 9730e25..a6923af 100644 --- a/apps/backend/hub_platform/calls/serializers.py +++ b/apps/backend/hub_platform/calls/serializers.py @@ -32,6 +32,7 @@ def call_payload(call: CallSession) -> dict: "id": str(call.id), "conversationId": call.conversation_id, "status": call.status, + "kind": call.kind, "initiatedByUserId": call.initiated_by_id, "deliveryConnectionId": call.delivery_connection_id, "requestedAt": _iso(call.requested_at), @@ -50,9 +51,9 @@ def public_invite_payload(call: CallSession, expires_at) -> dict: return { "callId": str(call.id), "status": call.status, + "kind": call.kind, "expiresAt": expires_at.isoformat(), "staffName": _staff_label(call), - "capabilities": {"audio": True, "video": True}, } @@ -84,6 +85,7 @@ def public_call_state_payload(call: CallSession) -> dict: return { "callId": str(call.id), "status": call.status, + "kind": call.kind, "staffName": _staff_label(call), "endedBy": call.ended_by or None, "durationSeconds": call.duration_seconds, diff --git a/apps/backend/hub_platform/calls/services.py b/apps/backend/hub_platform/calls/services.py index 2a95232..81abcc6 100644 --- a/apps/backend/hub_platform/calls/services.py +++ b/apps/backend/hub_platform/calls/services.py @@ -20,6 +20,7 @@ from hub_platform.calls.models import ( UNFINISHED_CALL_STATUSES, CallEndedBy, CallInvite, + CallKind, CallParticipant, CallSession, CallStatus, @@ -113,7 +114,9 @@ def _check_call_creation_conflicts(*, conversation: Conversation, initiator) -> @transaction.atomic -def create_call_request(*, context: TenantContext, conversation_id: int) -> CreatedCall: +def create_call_request( + *, context: TenantContext, conversation_id: int, kind: str = CallKind.AUDIO +) -> CreatedCall: initiator = context.actor_user if initiator is None or context.membership is None: raise CallAccessDenied("Для звонка требуется контекст сотрудника") @@ -140,6 +143,7 @@ def create_call_request(*, context: TenantContext, conversation_id: int) -> Crea conversation=conversation, initiated_by=initiator, delivery_connection_id=conversation.connection_id, + kind=kind, ) except IntegrityError as error: raise CallConflict("Не удалось создать второй незавершённый звонок") from error @@ -180,10 +184,11 @@ def create_call_request(*, context: TenantContext, conversation_id: int) -> Crea aggregate_id=str(call.id), ) initiator_label = getattr(initiator, "full_name", "") or initiator.email + call_word = "аудиозвонок" if call.kind == CallKind.AUDIO else "видеозвонок" Message.objects.create( conversation=conversation, author_type=MessageAuthor.SYSTEM, - text=f"Оператор {initiator_label} запросил онлайн-звонок", + text=f"Оператор {initiator_label} запросил {call_word}", ) if conversation.connection.provider == IntegrationProvider.WEB: # Web Chat: приглашение забирает виджет поллингом, внешней отправки нет. diff --git a/apps/backend/hub_platform/calls/tests/test_api.py b/apps/backend/hub_platform/calls/tests/test_api.py index 50bb924..e067704 100644 --- a/apps/backend/hub_platform/calls/tests/test_api.py +++ b/apps/backend/hub_platform/calls/tests/test_api.py @@ -70,7 +70,7 @@ class PublicInviteApiTests(CallTestCase): self.assertEqual(response["Cache-Control"], "no-store") self.assertEqual( set(payload["call"]), - {"callId", "status", "expiresAt", "staffName", "capabilities"}, + {"callId", "status", "kind", "expiresAt", "staffName"}, ) self.assertNotIn("conversation", json.dumps(payload)) self.assertNotIn(created.invite_token, json.dumps(payload)) diff --git a/apps/backend/hub_platform/calls/tests/test_flow.py b/apps/backend/hub_platform/calls/tests/test_flow.py index 402b916..6f01161 100644 --- a/apps/backend/hub_platform/calls/tests/test_flow.py +++ b/apps/backend/hub_platform/calls/tests/test_flow.py @@ -135,7 +135,7 @@ class CustomerAccessApiTests(CallTestCase): self.assertIn("iceServers", body) payload = body["call"] self.assertEqual(payload["status"], CallStatus.CANCELLED) - self.assertEqual(set(payload), {"callId", "status", "staffName", "endedBy", "durationSeconds"}) + self.assertEqual(set(payload), {"callId", "status", "kind", "staffName", "endedBy", "durationSeconds"}) def test_accept_after_cancel_is_rejected(self) -> None: token = self._customer_token() @@ -325,7 +325,7 @@ class TimelineEventTests(CallTestCase): create_call_request(conversation_id=self.conversation.id, initiator=self.owner) self.assertEqual( Message.objects.filter( - conversation=self.conversation, text__icontains="запросил онлайн-звонок" + conversation=self.conversation, text__icontains="запросил аудиозвонок" ).count(), 1, ) diff --git a/apps/backend/hub_platform/calls/views.py b/apps/backend/hub_platform/calls/views.py index 6dcd5f8..c401836 100644 --- a/apps/backend/hub_platform/calls/views.py +++ b/apps/backend/hub_platform/calls/views.py @@ -6,7 +6,7 @@ from rest_framework.views import APIView from hub_platform.api.permissions import HasEntitlement from hub_platform.calls.errors import CallAccessDenied, CallConflict, CallTokenError -from hub_platform.calls.models import CallSession +from hub_platform.calls.models import CallKind, CallSession from hub_platform.calls.permissions import ensure_call_access, ensure_conversation_call_access from hub_platform.calls.serializers import ( call_payload, @@ -48,9 +48,12 @@ class CallCreateView(APIView): required_entitlement = "p2p_calls" def post(self, request: Request, conversation_id: int) -> Response: + kind = str(request.data.get("kind", CallKind.AUDIO)) + if kind not in CallKind.values: + return Response({"detail": "Недопустимый тип звонка"}, status=400) try: created = create_call_request( - context=request.tenant_context, conversation_id=conversation_id + context=request.tenant_context, conversation_id=conversation_id, kind=kind ) except Conversation.DoesNotExist: return Response({"detail": "Диалог не найден"}, status=404) @@ -68,7 +71,7 @@ class CallCreateView(APIView): organization=call.organization, object_type="CallSession", object_id=str(call.id), - payload={"conversation_id": call.conversation_id}, + payload={"conversation_id": call.conversation_id, "kind": call.kind}, request=request, ) return _token_response( diff --git a/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx new file mode 100644 index 0000000..128eeda --- /dev/null +++ b/apps/internal-ui/src/features/conversations/AudioCallOverlay.tsx @@ -0,0 +1,143 @@ +// Аудиозвонок (оператор): antd Modal + AudioCallView (baseline «Аудиозвонок.dc.html»). +// Видео не запрашивается. Флоу: incoming/ringing → connecting → active → терминал, +// без pre-call (нет этапа проверки камеры). Параллель VideoCallOverlay, но под аудио. + +import { AudioCallView, type AudioCallMode, buildAudioStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui"; +import { Modal } from "antd"; +import { useEffect, useState } from "react"; + +import { providerMeta } from "../../shared/providers"; +import type { ApiCall, CallAccess } from "./model"; +import type { ConversationListItem } from "./types"; +import "./call.css"; + +type Props = { + open: boolean; + dialog: ConversationListItem | null; + call: ApiCall | 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 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); + + if (!props.dialog) return null; + const channel = providerMeta[props.dialog.channel]; + const subCaption = mode === "active" ? (rtc.micOn ? "Говорите" : "Ваш микрофон выключен") : undefined; + const status = buildAudioStatus(audioStatusKey(mode, call, props.errorText, rtc.connectionPhase, rtc.mediaIssue), props.dialog.name, call?.durationSeconds ?? undefined); + + const onAccept = () => { void rtc.start(); }; + const onEnd = () => { rtc.end(); }; + const endAndClose = () => { + if (mode === "active" || mode === "reconnecting" || mode === "connecting") rtc.end(); + props.onClose(); + }; + const toggleSpeaker = () => { /* громкость/динамик управляется браузером; индикация переключается без реального мьюта */ }; + // 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 ( + + + + ); +} + +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; +} diff --git a/apps/internal-ui/src/features/conversations/CallOverlay.tsx b/apps/internal-ui/src/features/conversations/CallOverlay.tsx index cc16ca9..5ecce8a 100644 --- a/apps/internal-ui/src/features/conversations/CallOverlay.tsx +++ b/apps/internal-ui/src/features/conversations/CallOverlay.tsx @@ -1,20 +1,11 @@ -import { CallView, type CallViewMode, type CallViewStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui"; -import { Modal } from "antd"; -import { useEffect, useMemo, useState } from "react"; +// Диспетчер звонка по типу (call.kind): VIDEO → видеозвонок (VideoCallOverlay, +// существующий путь), AUDIO → аудиозвонок (AudioCallOverlay, baseline +// «Аудиозвонок.dc.html»). Сам по себе ничего не рендерит — composition-only. -import { providerMeta } from "../../shared/providers"; +import { AudioCallOverlay } from "./AudioCallOverlay"; +import { VideoCallOverlay } from "./VideoCallOverlay"; import type { ApiCall, CallAccess } from "./model"; import type { ConversationListItem } from "./types"; -import "./call.css"; - -const TERMINAL: Record string; tone: "neutral" | "error" | "warn"; retry?: string }> = { - DECLINED: { icon: "declined", title: "Звонок отклонён", caption: (name) => `${name} отклонил(а) вызов.`, tone: "neutral", retry: "Позвонить снова" }, - MISSED: { icon: "missed", title: "Пропущенный звонок", caption: (name) => `${name} не ответил(а) на вызов.`, tone: "warn", retry: "Перезвонить" }, - EXPIRED: { icon: "clock", title: "Время ожидания истекло", caption: () => "Никто не ответил вовремя. Попробуйте позвонить снова.", tone: "warn", retry: "Позвонить снова" }, - CANCELLED: { icon: "declined", title: "Звонок отменён", caption: () => "Приглашение отменено.", tone: "neutral" }, - FAILED: { icon: "alert", title: "Не удалось соединиться", caption: () => "Проверьте интернет-соединение и попробуйте снова.", tone: "error", retry: "Повторить" }, - ENDED: { icon: "declined", title: "Звонок завершён", caption: () => "Разговор завершён.", tone: "neutral" }, -}; type Props = { open: boolean; @@ -29,130 +20,8 @@ type Props = { }; export function CallOverlay(props: Props) { - const call = props.call; - const rtc = useCallRtcSession({ - resetKey: call?.id ?? "", - previewEnabled: props.open && call?.status === "ACCEPTED", - accessToken: props.access?.accessToken ?? "", - side: "STAFF", - iceServers: props.access?.iceServers ?? [], - 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 = resolveMode(call, props.errorText, rtc.connectionPhase, rtc.mediaIssue); - useLoopingAudio("/audio/ringtone.mp3", props.open && mode === "ringing", 0.5); - const status = useMemo( - () => buildStatus(props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start), - [props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start], - ); - - if (!props.dialog) return null; - const channel = providerMeta[props.dialog.channel]; - const subtitle = subtitleFor(mode, call, status); - const mediaCaption = rtc.mediaIssue === "devices" ? "Нет доступа к камере и микрофону" : rtc.mediaIssue === "video" ? "Камера недоступна" : "Камера выключена"; - const endAndClose = () => { - if (mode === "active" || mode === "reconnecting" || mode === "connecting") rtc.end(); - props.onClose(); - }; - - return ( - - {channel.label}} - 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={rtc.preparing || rtc.connectionPhase === "connecting"} - onToggleMic={rtc.toggleMic} - onToggleCam={rtc.toggleCam} - onJoin={rtc.start} - onCancel={mode === "ringing" ? props.onCancel : endAndClose} - onEnd={rtc.end} - onClose={endAndClose} - /> - - ); -} - -function resolveMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): CallViewMode { - if (errorText || !call || TERMINAL[call.status] || connection === "failed" || mediaIssue === "devices" || mediaIssue === "unsupported") return "status"; - if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing"; - if (call.status === "ACCEPTED" && connection === "idle") return "precall"; - if (connection === "reconnecting") return "reconnecting"; - if (call.status === "ACTIVE" || connection === "connected") return "active"; - return "connecting"; -} - -function buildStatus(props: Props, connection: string, mediaIssue: string, onReconnect: () => void, onPrepare: () => void, onJoin: () => void): CallViewStatus | undefined { - if (props.errorText) return { icon: "alert", tone: "error", title: "Не удалось запросить звонок", caption: props.errorText }; - if (mediaIssue === "unsupported") return { icon: "unsupported", tone: "error", title: "Видеозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge." }; - if (mediaIssue === "devices") return { - icon: "alert", - tone: "error", - title: "Нет доступа к камере и микрофону", - caption: "Разрешите доступ к устройствам в настройках браузера и повторите.", - actions: [ - { label: "Повторить проверку", kind: "primary", onClick: onPrepare }, - { label: "Без видео", kind: "secondary", onClick: onJoin }, - ], - }; - if (connection === "failed") return { - icon: "alert", - tone: "error", - title: "Не удалось соединиться", - caption: "Проверьте интернет-соединение и попробуйте снова.", - actions: [{ label: "Повторить", kind: "primary", onClick: onReconnect }], - }; - if (!props.call) return undefined; - if (props.call.status === "ACCEPTED" || props.call.status === "CONNECTING") return { icon: "spinner", tone: "neutral", title: "Соединяем звонок", caption: "Устанавливаем защищённое соединение…" }; - const terminal = TERMINAL[props.call.status]; - if (!terminal) return undefined; - const duration = props.call.status === "ENDED" && props.call.durationSeconds != null ? `Длительность ${formatDuration(props.call.durationSeconds)}. ` : ""; - return { - icon: terminal.icon, - tone: terminal.tone, - title: terminal.title, - caption: `${duration}${terminal.caption(props.dialog?.name ?? "Клиент")}`, - actions: [ - ...(terminal.retry ? [{ label: terminal.retry, kind: "primary" as const, onClick: props.onRetry }] : []), - { label: "Закрыть", kind: "secondary" as const, onClick: props.onClose }, - ], - }; -} - -function subtitleFor(mode: CallViewMode, call: ApiCall | null, status?: CallViewStatus) { - if (mode === "ringing") return call?.status === "REQUESTED" ? "Отправляем приглашение" : "Ожидание ответа"; - if (mode === "precall") return "Проверьте камеру и микрофон"; - if (mode === "active") return "Активный звонок"; - if (mode === "reconnecting") return "Переподключение"; - return status?.title ?? "Соединение"; -} - -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; -} - -function formatDuration(seconds: number) { - return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; + // Активный/существующий звонок возвращается из API с kind; новый запрос + // создан под выбранный тип. Дефолт — аудио (как и requestCall / модели). + if (props.call?.kind === "VIDEO") return ; + return ; } diff --git a/apps/internal-ui/src/features/conversations/ConversationThread.tsx b/apps/internal-ui/src/features/conversations/ConversationThread.tsx index 3f3c063..9c9c9ed 100644 --- a/apps/internal-ui/src/features/conversations/ConversationThread.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationThread.tsx @@ -13,7 +13,7 @@ function fmtTime(value: string): string { return new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); } -export function ConversationThread({ controlMode, dialog, detail, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; onClaim: () => void; onCall: () => void; onClose: () => void; onSpam: () => Promise }) { +export function ConversationThread({ controlMode, dialog, detail, onClaim, onCall, onClose, onSpam }: { controlMode: ControlMode; dialog: ConversationListItem | null; detail: ApiConversation | null; onClaim: () => void; onCall: (kind: "AUDIO" | "VIDEO") => void; onClose: () => void; onSpam: () => Promise }) { const timelineRef = useRef(null); const messages = detail?.messages ?? []; const lastMessageId = messages.length ? messages[messages.length - 1].id : 0; @@ -43,7 +43,10 @@ export function ConversationThread({ controlMode, dialog, detail, onClaim, onCal {controlMode === "waiting" && } {controlMode === "ai" && } {detail?.lifecycle === "OPEN" && dialog.channel !== "EMAIL" && ( - + <> + + + )} diff --git a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx index 6e654e4..b1b8327 100644 --- a/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx +++ b/apps/internal-ui/src/features/conversations/ConversationWorkspace.tsx @@ -153,7 +153,7 @@ export function ConversationWorkspace({ department, listTitle, searchPlaceholder setSelectedId={setSelectedId} />
- void callController.start()} onClose={onClose} onSpam={onSpam} /> + void callController.start(kind)} onClose={onClose} onSpam={onSpam} /> {(detailError || actionError) &&
{detailError || actionError}
} string; tone: "neutral" | "error" | "warn"; retry?: string }> = { + DECLINED: { icon: "declined", title: "Звонок отклонён", caption: (name) => `${name} отклонил(а) вызов.`, tone: "neutral", retry: "Позвонить снова" }, + MISSED: { icon: "missed", title: "Пропущенный звонок", caption: (name) => `${name} не ответил(а) на вызов.`, tone: "warn", retry: "Перезвонить" }, + EXPIRED: { icon: "clock", title: "Время ожидания истекло", caption: () => "Никто не ответил вовремя. Попробуйте позвонить снова.", tone: "warn", retry: "Позвонить снова" }, + CANCELLED: { icon: "declined", title: "Звонок отменён", caption: () => "Приглашение отменено.", tone: "neutral" }, + FAILED: { icon: "alert", title: "Не удалось соединиться", caption: () => "Проверьте интернет-соединение и попробуйте снова.", tone: "error", retry: "Повторить" }, + ENDED: { icon: "declined", title: "Звонок завершён", caption: () => "Разговор завершён.", tone: "neutral" }, +}; + +type Props = { + open: boolean; + dialog: ConversationListItem | null; + call: ApiCall | null; + access: CallAccess | null; + errorText: string; + onCallChange: (call: ApiCall) => void; + onCancel: () => void; + onRetry: () => void; + onClose: () => void; +}; + +export function VideoCallOverlay(props: Props) { + const call = props.call; + const rtc = useCallRtcSession({ + resetKey: call?.id ?? "", + previewEnabled: props.open && call?.status === "ACCEPTED", + accessToken: props.access?.accessToken ?? "", + side: "STAFF", + iceServers: props.access?.iceServers ?? [], + 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 = resolveMode(call, props.errorText, rtc.connectionPhase, rtc.mediaIssue); + useLoopingAudio("/audio/ringtone.mp3", props.open && mode === "ringing", 0.5); + const status = useMemo( + () => buildStatus(props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start), + [props, rtc.connectionPhase, rtc.mediaIssue, rtc.restart, rtc.prepare, rtc.start], + ); + + if (!props.dialog) return null; + const channel = providerMeta[props.dialog.channel]; + const subtitle = subtitleFor(mode, call, status); + const mediaCaption = rtc.mediaIssue === "devices" ? "Нет доступа к камере и микрофону" : rtc.mediaIssue === "video" ? "Камера недоступна" : "Камера выключена"; + const endAndClose = () => { + if (mode === "active" || mode === "reconnecting" || mode === "connecting") rtc.end(); + props.onClose(); + }; + + return ( + + {channel.label}} + 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={rtc.preparing || rtc.connectionPhase === "connecting"} + onToggleMic={rtc.toggleMic} + onToggleCam={rtc.toggleCam} + onJoin={rtc.start} + onCancel={mode === "ringing" ? props.onCancel : endAndClose} + onEnd={rtc.end} + onClose={endAndClose} + /> + + ); +} + +function resolveMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): CallViewMode { + if (errorText || !call || TERMINAL[call.status] || connection === "failed" || mediaIssue === "devices" || mediaIssue === "unsupported") return "status"; + if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing"; + if (call.status === "ACCEPTED" && connection === "idle") return "precall"; + if (connection === "reconnecting") return "reconnecting"; + if (call.status === "ACTIVE" || connection === "connected") return "active"; + return "connecting"; +} + +function buildStatus(props: Props, connection: string, mediaIssue: string, onReconnect: () => void, onPrepare: () => void, onJoin: () => void): CallViewStatus | undefined { + if (props.errorText) return { icon: "alert", tone: "error", title: "Не удалось запросить звонок", caption: props.errorText }; + if (mediaIssue === "unsupported") return { icon: "unsupported", tone: "error", title: "Видеозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge." }; + if (mediaIssue === "devices") return { + icon: "alert", + tone: "error", + title: "Нет доступа к камере и микрофону", + caption: "Разрешите доступ к устройствам в настройках браузера и повторите.", + actions: [ + { label: "Повторить проверку", kind: "primary", onClick: onPrepare }, + { label: "Без видео", kind: "secondary", onClick: onJoin }, + ], + }; + if (connection === "failed") return { + icon: "alert", + tone: "error", + title: "Не удалось соединиться", + caption: "Проверьте интернет-соединение и попробуйте снова.", + actions: [{ label: "Повторить", kind: "primary", onClick: onReconnect }], + }; + if (!props.call) return undefined; + if (props.call.status === "ACCEPTED" || props.call.status === "CONNECTING") return { icon: "spinner", tone: "neutral", title: "Соединяем звонок", caption: "Устанавливаем защищённое соединение…" }; + const terminal = TERMINAL[props.call.status]; + if (!terminal) return undefined; + const duration = props.call.status === "ENDED" && props.call.durationSeconds != null ? `Длительность ${formatDuration(props.call.durationSeconds)}. ` : ""; + return { + icon: terminal.icon, + tone: terminal.tone, + title: terminal.title, + caption: `${duration}${terminal.caption(props.dialog?.name ?? "Клиент")}`, + actions: [ + ...(terminal.retry ? [{ label: terminal.retry, kind: "primary" as const, onClick: props.onRetry }] : []), + { label: "Закрыть", kind: "secondary" as const, onClick: props.onClose }, + ], + }; +} + +function subtitleFor(mode: CallViewMode, call: ApiCall | null, status?: CallViewStatus) { + if (mode === "ringing") return call?.status === "REQUESTED" ? "Отправляем приглашение" : "Ожидание ответа"; + if (mode === "precall") return "Проверьте камеру и микрофон"; + if (mode === "active") return "Активный звонок"; + if (mode === "reconnecting") return "Переподключение"; + return status?.title ?? "Соединение"; +} + +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; +} + +function formatDuration(seconds: number) { + return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; +} diff --git a/apps/internal-ui/src/features/conversations/model.ts b/apps/internal-ui/src/features/conversations/model.ts index d4d533f..0a6a7ba 100644 --- a/apps/internal-ui/src/features/conversations/model.ts +++ b/apps/internal-ui/src/features/conversations/model.ts @@ -137,10 +137,13 @@ export const fetchWaitingCount = () => api<{ waiting: number }>("/api/v1/convers // --- Онлайн-звонки (SPEC-HUB-0013): запрос из диалога, ожидание, отмена --- +export type CallKind = "AUDIO" | "VIDEO"; + export type ApiCall = { id: string; conversationId: number; status: "REQUESTED" | "RINGING" | "ACCEPTED" | "CONNECTING" | "ACTIVE" | "DECLINED" | "CANCELLED" | "MISSED" | "ENDED" | "FAILED" | "EXPIRED"; + kind: CallKind; requestedAt: string; acceptedAt: string | null; connectedAt: string | null; @@ -154,8 +157,8 @@ export type CallAccess = { accessToken: string; iceServers: RTCIceServer[] }; export type CreatedCall = { call: ApiCall; access: CallAccess }; // Запрос звонка: при режиме AI backend атомарно выполняет takeover (§6). -export const requestCall = (conversationId: number) => - api<{ call: ApiCall; staffAccessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/conversations/${conversationId}/`, { method: "POST" }) +export const requestCall = (conversationId: number, kind: CallKind = "AUDIO") => + api<{ call: ApiCall; staffAccessToken: string; iceServers: RTCIceServer[] }>(`/api/v1/calls/conversations/${conversationId}/`, { method: "POST", body: JSON.stringify({ kind }) }) .then((r): CreatedCall => ({ call: r.call, access: { accessToken: r.staffAccessToken, iceServers: r.iceServers } })); export const fetchActiveCall = (conversationId: number) => api<{ call: ApiCall | null }>(`/api/v1/calls/conversations/${conversationId}/active/`).then((r) => r.call); diff --git a/apps/internal-ui/src/features/conversations/useConversationCall.ts b/apps/internal-ui/src/features/conversations/useConversationCall.ts index b1c0d92..6a9ee0f 100644 --- a/apps/internal-ui/src/features/conversations/useConversationCall.ts +++ b/apps/internal-ui/src/features/conversations/useConversationCall.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { cancelCall, @@ -8,6 +8,7 @@ import { requestCall, type ApiCall, type CallAccess, + type CallKind, } from "./model"; const TERMINAL = new Set(["DECLINED", "CANCELLED", "MISSED", "ENDED", "FAILED", "EXPIRED"]); @@ -23,6 +24,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O const [access, setAccess] = useState(null); const [errorText, setErrorText] = useState(""); const [busy, setBusy] = useState(false); + const lastKind = useRef("AUDIO"); const reset = useCallback(() => { setOpen(false); @@ -47,8 +49,9 @@ export function useConversationCall({ conversationId, onConversationChanged }: O return next; }, []); - const start = useCallback(async () => { + const start = useCallback(async (kind: CallKind = "AUDIO") => { if (conversationId == null || busy) return; + lastKind.current = kind; setBusy(true); setErrorText(""); try { @@ -57,7 +60,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O setCall(active); await ensureAccess(active.id); } else { - const created = await requestCall(conversationId); + const created = await requestCall(conversationId, kind); setCall(created.call); setAccess(created.access); } @@ -87,7 +90,7 @@ export function useConversationCall({ conversationId, onConversationChanged }: O setAccess(null); setErrorText(""); try { - const created = await requestCall(conversationId); + const created = await requestCall(conversationId, lastKind.current); setCall(created.call); setAccess(created.access); onConversationChanged(); diff --git a/apps/internal-ui/src/shared/icons.tsx b/apps/internal-ui/src/shared/icons.tsx index d0b2a81..2f35b36 100644 --- a/apps/internal-ui/src/shared/icons.tsx +++ b/apps/internal-ui/src/shared/icons.tsx @@ -35,7 +35,7 @@ export function MaxLogo({ size = 24 }: { size?: number }) { ); } -export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "folder" | "grip"; size?: number }) { +export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | "box" | "robot" | "plug" | "settings" | "bell" | "chevron" | "plus" | "arrow" | "paperclip" | "send" | "phone" | "video" | "lock" | "mail" | "eye" | "eyeOff" | "logout" | "user" | "refresh" | "transfer" | "warning" | "bolt" | "shop" | "search" | "more" | "external" | "key" | "pause" | "percent" | "save" | "check" | "copy" | "clock" | "message" | "cart" | "columns" | "download" | "list" | "split" | "gitBranch" | "route" | "expand" | "edit" | "trash" | "wrench" | "xCircle" | "folder" | "grip"; size?: number }) { const common = { width: size, height: size, fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" } as const; const paths: Record = { grid: <>, @@ -73,6 +73,8 @@ export function Icon({ name, size = 17 }: { name: "grid" | "building" | "team" | paperclip: , send: <>, phone: , + // Tabler ti-video: видеокамера — видеозвонок. + video: <>, refresh: <>, transfer: <>, warning: <>, diff --git a/apps/web-chat/src/ChatView.tsx b/apps/web-chat/src/ChatView.tsx index 4ad7b0b..de500ff 100644 --- a/apps/web-chat/src/ChatView.tsx +++ b/apps/web-chat/src/ChatView.tsx @@ -85,7 +85,12 @@ 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) => ; - return
Входящий видеозвонок
{call.staffName || "Оператор"} приглашает вас на онлайн-звонок
; + const isVideo = call.kind !== "AUDIO"; + const inviteIcon = isVideo + ? + : ; + const title = isVideo ? "Входящий видеозвонок" : "Входящий аудиозвонок"; + return
{inviteIcon}
{title}
{call.staffName || "Оператор"} приглашает вас на онлайн-звонок
; } function Bubble({ author, text, accent, pendingState }: { author: "client" | "ai" | "operator"; text: string; accent: string; pendingState?: boolean }) { diff --git a/apps/web-chat/src/api.ts b/apps/web-chat/src/api.ts index 319b3b5..fdfcd53 100644 --- a/apps/web-chat/src/api.ts +++ b/apps/web-chat/src/api.ts @@ -16,9 +16,11 @@ export type WebConfig = { export type WebMessage = { id: number; author: "client" | "ai" | "operator" | "system"; kind?: string; text: string; createdAt: string }; // Приглашение/состояние онлайн-звонка (SPEC-HUB-0013). +export type CallKind = "AUDIO" | "VIDEO"; export type CallInfo = { callId: string; status: string; + kind?: CallKind; expiresAt?: string; staffName?: string; endedBy?: string | null; diff --git a/apps/web-chat/src/call/AudioCallStage.tsx b/apps/web-chat/src/call/AudioCallStage.tsx new file mode 100644 index 0000000..978943a --- /dev/null +++ b/apps/web-chat/src/call/AudioCallStage.tsx @@ -0,0 +1,125 @@ +// Аудиозвонок (клиент): audio-сцена страницы /calls/. Не делает bootstrap +// (он выполнен в CallApp) — переиспользует загруженные call/accessToken/iceServers, +// держит свой аудио-RTC (videoEnabled:false) и поллинг состояния до терминала. + +import { AudioCallView, type AudioCallMode, type AudioCallStatus, useCallRtcSession, useLoopingAudio } from "@edevs/ui"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { acceptCall, declineCall, fetchCallState, type CallInfo } from "../api"; +import { audioCallStatusLabel, buildAudioCallViewStatus, isTerminalCall, resolveAudioCallViewMode } from "./model"; + +type Props = { + call: CallInfo | null; + accessToken: string; + iceServers: RTCIceServer[]; + loading: boolean; + invalid: boolean; + onCall: (call: CallInfo) => void; +}; + +export function AudioCallStage({ call, accessToken, iceServers, loading, invalid, onCall }: Props) { + const [started, setStarted] = useState(false); + const [joining, setJoining] = useState(false); + + const rtc = useCallRtcSession({ + resetKey: call?.callId ?? "", + previewEnabled: false, + accessToken, + side: "CUSTOMER", + iceServers, + videoEnabled: false, + onCallState: (state) => { if (call) onCall({ ...call, ...state }); }, + }); + + // Поллинг состояния до терминала. + 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]); + + const close = useCallback(() => { + if (started) rtc.end(); + if (history.length > 1) history.back(); else window.close(); + }, [rtc.end, started]); + + 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); + } + + const onCancel = useCallback(async () => { + 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(); + }, [accessToken, call?.status, onCall, rtc]); + + const mode: AudioCallMode = resolveAudioCallViewMode({ loading, invalid, call, started, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue }); + useLoopingAudio("/chat/audio/ringtone.mp3", !started && (call?.status === "REQUESTED" || call?.status === "RINGING")); + const status: AudioCallStatus | undefined = useMemo( + () => buildAudioCallViewStatus({ loading, invalid, call, connection: rtc.connectionPhase, mediaIssue: rtc.mediaIssue, close }), + [loading, invalid, call, rtc.connectionPhase, rtc.mediaIssue, close], + ); + 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() || "ОП"; + + return ( +
+ void join()} + onCancel={() => void onCancel()} + 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; +} diff --git a/apps/web-chat/src/call/CallApp.tsx b/apps/web-chat/src/call/CallApp.tsx index 2a39e70..47bb691 100644 --- a/apps/web-chat/src/call/CallApp.tsx +++ b/apps/web-chat/src/call/CallApp.tsx @@ -9,6 +9,7 @@ import { type CallBootstrap, type CallInfo, } from "../api"; +import { AudioCallStage } from "./AudioCallStage"; import { buildCallViewStatus, callViewSubtitle, isTerminalCall, resolveCallViewMode } from "./model"; function storageKey() { return `edevs-call:${location.pathname}`; } @@ -37,6 +38,13 @@ export function CallApp() { 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); setAccessToken(value.accessToken); diff --git a/apps/web-chat/src/call/model.ts b/apps/web-chat/src/call/model.ts index d4fba3a..fcfca92 100644 --- a/apps/web-chat/src/call/model.ts +++ b/apps/web-chat/src/call/model.ts @@ -1,4 +1,4 @@ -import type { CallViewMode, CallViewStatus } from "@edevs/ui"; +import { type AudioCallMode, type AudioCallStatus, type CallViewMode, type CallViewStatus, buildAudioStatus } from "@edevs/ui"; import type { CallInfo } from "../api"; @@ -42,6 +42,39 @@ export function callViewSubtitle(mode: CallViewMode, status?: CallViewStatus) { return status?.title ?? "Соединение"; } +// --- Аудиозвонок (baseline «Аудиозвонок.dc.html»): incoming → active → терминал, +// без pre-call. Статус-центр — через общий buildAudioStatus из @edevs/ui. --- + +export function resolveAudioCallViewMode(state: { loading: boolean; invalid: boolean; call: CallInfo | null; started: boolean; connection: string; mediaIssue: string }): AudioCallMode { + if (state.loading || state.invalid || !state.call) return "status"; + if (state.mediaIssue === "devices" || state.mediaIssue === "unsupported") return "status"; + if (isTerminalCall(state.call.status) || state.connection === "failed") return "status"; + if (!state.started) return state.call.status === "RINGING" || state.call.status === "REQUESTED" ? "ringing" : "incoming"; + if (state.connection === "reconnecting") return "reconnecting"; + if (state.connection === "connected" || state.call.status === "ACTIVE") return "active"; + return "connecting"; +} + +export function buildAudioCallViewStatus(state: { loading: boolean; invalid: boolean; call: CallInfo | null; connection: string; mediaIssue: 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.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; + 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; +} + +export function audioCallStatusLabel(mode: AudioCallMode, status?: AudioCallStatus): string { + if (status) return status.title; + if (mode === "incoming") return "Входящий звонок"; + if (mode === "ringing") return "Соединение"; + if (mode === "active") return "Аудиозвонок"; + if (mode === "connecting") return "Соединение"; + if (mode === "reconnecting") return "Переподключение"; + return "Звонок завершён"; +} + function formatDuration(seconds: number) { return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } diff --git a/compose.yaml b/compose.yaml index 74aa2f4..bff4cbd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -242,7 +242,11 @@ services: - --cert=/etc/coturn/certs/fullchain.pem - --pkey=/etc/coturn/certs/privkey.pem - --min-port=${CUS_TURN_MIN_PORT:-49160} - - --max-port=${CUS_TURN_MAX_PORT:-49200} + # Relay-диапазон UDP (1 порт на media-endpoint; ~2 порта на звонок). + # 49160-49999 = 840 портов ≈ 420 одновременных TURN-звонков на хосте. + # Расширяется через CUS_TURN_MIN_PORT/CUS_TURN_MAX_PORT; все порты диапазона + # должны быть открыты на firewall и не пересекаться с ephemeral-диапазоном ОС. + - --max-port=${CUS_TURN_MAX_PORT:-49999} # Запрет анонимного и внутрисетевого relay (SPEC §11: без пересечения с хостом). - --no-multicast-peers - --no-tcp-relay diff --git a/design/baseline/# UI для онлайн звонка/.thumbnail b/design/baseline/# UI для онлайн звонка/.thumbnail new file mode 100644 index 0000000..0914090 Binary files /dev/null and b/design/baseline/# UI для онлайн звонка/.thumbnail differ diff --git a/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_adherence.oxlintrc.json b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_adherence.oxlintrc.json new file mode 100644 index 0000000..8419c5c --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/_ds/edevs-hub-design-system-e4c9df12-82c8-42b3-b819-63d92dfb8702/_adherence.oxlintrc.json @@ -0,0 +1,508 @@ +{ + "plugins": [ + "react", + "import" + ], + "rules": { + "react/forbid-elements": [ + "warn", + { + "forbid": [] + } + ], + "no-restricted-imports": [ + "warn", + { + "patterns": [ + { + "group": [ + "components/cards/**", + "components/core/**", + "components/inbox/**", + "components/status/**", + "ui_kits/checkout/**", + "ui_kits/internal-hub/**", + "ui_kits/web-chat/**" + ], + "message": "Import design-system components from 'index.js', not component internals." + } + ] + } + ], + "no-restricted-syntax": [ + "warn", + { + "selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]", + "message": "Raw hex color — use a design-system color token via var()." + }, + { + "selector": "Literal[value=/\\b\\d+px\\b/]", + "message": "Raw px value — use a design-system spacing token via var()." + }, + { + "selector": "JSXOpeningElement[name.name='ActorBadge'] > JSXAttribute > JSXIdentifier[name!=/^(?:actor|initials|color|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: actor, initials, color." + }, + { + "selector": "JSXOpeningElement[name.name='ActorBadge'] > JSXAttribute[name.name='actor'] > Literal[value!=/^(?:ai|human)$/]", + "message": " actor must be one of 'ai' | 'human'." + }, + { + "selector": "JSXOpeningElement[name.name='AttentionStatus'] > JSXAttribute > JSXIdentifier[name!=/^(?:level|label|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: level, label." + }, + { + "selector": "JSXOpeningElement[name.name='AttentionStatus'] > JSXAttribute[name.name='level'] > Literal[value!=/^(?:ok|attention|critical)$/]", + "message": " level must be one of 'ok' | 'attention' | 'critical'." + }, + { + "selector": "JSXOpeningElement[name.name='Avatar'] > JSXAttribute > JSXIdentifier[name!=/^(?:initials|color|size|key|ref|className|style|children)$/]", + "message": " doesn't accept that prop. Declared props: initials, color, size." + }, + { + "selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|size|disabled|onClick|type|key|ref|className|style|children)$/]", + "message": "' + + ' ' + + ''; + this._frame = root.querySelector('.frame'); + this._ring = root.querySelector('.ring'); + this._img = root.querySelector('.frame img'); + this._empty = root.querySelector('.empty'); + this._cap = root.querySelector('.cap'); + this._sub = root.querySelector('.sub'); + this._spill = root.querySelector('.spill'); + this._ctl = root.querySelector('.ctl'); + this._credit = root.querySelector('.credit'); + this._attrError = root.querySelector('.attr-error'); + // Credit clicks open the link, not browse/reframe. + this._credit.addEventListener('click', (e) => e.stopPropagation()); + this._credit.addEventListener('dblclick', (e) => e.stopPropagation()); + this._ghost = root.querySelector('.ghost'); + this._err = null; + this._input = root.querySelector('input'); + this._depth = 0; + this._gen = 0; + // Encode-in-flight marker (the owning _ingest generation): while set, + // the same-src "nothing in flight" clear in _render must not fire — + // the stored value still points at the OLD image until the encode + // lands, so that clear would unmask the stale image mid-replace. + this._swapGen = 0; + // Render-owned swap in flight: set when _render assigns a new src, + // cleared only by the img's own load/error (or the empty branch). + // img.complete CANNOT stand in for this — setting src only QUEUES + // the current-request swap (a microtask), so synchronously after an + // assignment, complete still reports the OLD settled request. The + // pick path does exactly that: the host sets src, credit, and + // credit-href back-to-back in one task, and renders #2/#3 would + // read the stale complete === true and drop the mask one render + // after it was set. + this._loadPending = false; + // See _render's empty branch: a transient attribution-error wipe of a + // showing image must make the follow-up render a replacement (spinner), + // not a first fill (blank frame). + this._hidShowing = false; + this._view = { s: 1, x: 0, y: 0 }; + this._subFn = () => this._render(); + // Shadow-DOM listeners live with the shadow DOM — bound once here so + // disconnect/reconnect (e.g. React remount) doesn't stack handlers. + this._empty.addEventListener('click', () => this._input.click()); + root.addEventListener('click', (e) => { + const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + // The hidden controls are opacity-0 but still tabbable — without + // this gate a keyboard user could drive them on a read-only share + // link (mirrors the dblclick handler's editable gate). + if (!this.hasAttribute('data-editable')) return; + if (act === 'replace') { + this._exitReframe(true); + // Host-owned picker (Unsplash modal; it also offers local import). + this.dispatchEvent(new CustomEvent('image-slot:pick', { + bubbles: true, composed: true, detail: { id: this.id || null } + })); + } + if (act === 'edit') { + if (!this._reframes()) return; + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + } + }); + this._input.addEventListener('change', () => { + const f = this._input.files && this._input.files[0]; + if (f) this._ingest(f); + this._input.value = ''; + }); + // naturalWidth/Height aren't known until load — re-apply so the cover + // baseline is computed from real dimensions, not the 100%×100% fallback. + // load/error also release the replacement-in-flight mask (via the + // single discipline in _releaseMask): the swap is only revealed once + // the new image can actually paint (on error the frame shows its + // background, same as a fresh slot with a broken src). + this._img.addEventListener('load', () => { + this._loadPending = false; + this._releaseMask(true); + this._applyView(); + }); + this._img.addEventListener('error', () => { + this._loadPending = false; + this._releaseMask(true); + }); + // Gated only on editable — any filled slot can be repositioned/scaled, + // regardless of fit. Share links (no writeFile) stay static. + this.addEventListener('dblclick', (e) => { + if (!this.hasAttribute('data-editable') || !this._reframes()) return; + e.preventDefault(); + if (this.hasAttribute('data-reframe')) this._exitReframe(true); + else this._enterReframe(); + }); + // Pan + resize both originate on the spill layer. A handle pointerdown + // drives an aspect-locked resize anchored at the opposite corner; any + // other pointerdown on the spill pans. Offsets are frame-% so a + // reframed slot survives responsive resize / PPTX export. + this._spill.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; + e.preventDefault(); + e.stopPropagation(); + this._spill.setPointerCapture(e.pointerId); + const rect = this.getBoundingClientRect(); + const fw = rect.width || 1, fh = rect.height || 1; + const corner = e.target.getAttribute && e.target.getAttribute('data-c'); + let move; + if (corner) { + // Resize about the OPPOSITE corner. Viewport-px throughout (rect + // fw/fh, not clientWidth) so the math survives a transform:scale() + // ancestor — deck_stage renders slides scaled-to-fit. + const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain ? Math.min(fw / iw, fh / ih) : Math.max(fw / iw, fh / ih); + const sx = corner.includes('e') ? 1 : -1; + const sy = corner.includes('s') ? 1 : -1; + const s0 = this._view.s; + const w0 = iw * base * s0, h0 = ih * base * s0; + const cx0 = (50 + this._view.x) / 100 * fw; + const cy0 = (50 + this._view.y) / 100 * fh; + const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2; + const diag0 = Math.hypot(w0, h0); + const ux = sx * w0 / diag0, uy = sy * h0 / diag0; + move = (ev) => { + const proj = (ev.clientX - rect.left - ox) * ux + + (ev.clientY - rect.top - oy) * uy; + const s = clampS(s0 * proj / diag0); + const d = diag0 * s / s0; + this._view.s = s; + this._view.x = (ox + ux * d / 2) / fw * 100 - 50; + this._view.y = (oy + uy * d / 2) / fh * 100 - 50; + this._clampView(); + this._applyView(); + }; + } else { + this.setAttribute('data-panning', ''); + const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y }; + move = (ev) => { + this._view.x = start.x + (ev.clientX - start.px) / fw * 100; + this._view.y = start.y + (ev.clientY - start.py) / fh * 100; + this._clampView(); + this._applyView(); + }; + } + const up = () => { + try { this._spill.releasePointerCapture(e.pointerId); } catch {} + this._spill.removeEventListener('pointermove', move); + this._spill.removeEventListener('pointerup', up); + this._spill.removeEventListener('pointercancel', up); + this.removeAttribute('data-panning'); + this._dragUp = null; + }; + // Stashed so _exitReframe (Escape / outside-click mid-drag) can + // tear the capture + listeners down synchronously. + this._dragUp = up; + this._spill.addEventListener('pointermove', move); + this._spill.addEventListener('pointerup', up); + this._spill.addEventListener('pointercancel', up); + }); + // Wheel zoom stays available inside reframe mode as a trackpad nicety — + // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). + this.addEventListener('wheel', (e) => { + if (!this.hasAttribute('data-reframe')) return; + e.preventDefault(); + const r = this.getBoundingClientRect(); + const cx = (e.clientX - r.left) / r.width * 100 - 50; + const cy = (e.clientY - r.top) / r.height * 100 - 50; + const prev = this._view.s; + const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); + if (next === prev) return; + const k = next / prev; + this._view.s = next; + this._view.x = cx * (1 - k) + this._view.x * k; + this._view.y = cy * (1 - k) + this._view.y * k; + this._clampView(); + this._applyView(); + }, { passive: false }); + } + + connectedCallback() { + // Warn once per page — an id-less slot works for the session but + // cannot persist, and two id-less slots would share nothing. + if (!this.id && !ImageSlot._warned) { + ImageSlot._warned = true; + console.warn(' without an id will not persist its dropped image.'); + } + this.addEventListener('dragenter', this); + this.addEventListener('dragover', this); + this.addEventListener('dragleave', this); + this.addEventListener('drop', this); + subs.add(this._subFn); + // The host may inject window.omelette.writeFile AFTER the first render; + // re-render on hover so the editable-gated controls reliably appear. + this.addEventListener('pointerenter', this._subFn); + // width%/height% in _applyView encode the frame aspect at call time — + // a host resize (responsive grid, pane divider) would stretch the + // image until the next _render. Re-render on size change: _render() + // re-seeds _view from stored before clamp/apply, so a shrink→grow + // cycle round-trips instead of ratcheting x/y toward the narrower + // frame's clamp range. + this._ro = new ResizeObserver(() => this._render()); + this._ro.observe(this); + load(); + this._render(); + } + + disconnectedCallback() { + subs.delete(this._subFn); + this.removeEventListener('pointerenter', this._subFn); + this.removeEventListener('dragenter', this); + this.removeEventListener('dragover', this); + this.removeEventListener('dragleave', this); + this.removeEventListener('drop', this); + if (this._ro) { this._ro.disconnect(); this._ro = null; } + // commit=false: a disconnect is not a user intent — committing here + // would persist whatever half-finished drag a React remount or DOM + // splice happened to interrupt. Deliberate exits commit on their own + // paths (Escape/click-out/toggle), and unloads commit via pagehide. + this._exitReframe(false); + } + + _enterReframe() { + if (this.hasAttribute('data-reframe')) return; + this.setAttribute('data-reframe', ''); + this._signalReframe(true); + // Best-effort commit when the document unloads mid-reframe (a host + // navigation racing the enter signal, a manual reload, tab close): + // the sidecar write rides the host bridge, which outlives this + // document, so the crop survives even though the mode dies with the + // DOM. Held on the instance so _exitReframe detaches exactly what + // was attached. + this._pagehide = () => { this._exitReframe(true); flushNow(); }; + window.addEventListener('pagehide', this._pagehide); + // Promote spill to the top layer, then keep it pinned over the frame: + // scroll/resize cover the common cases, and a per-frame rect check + // catches layout shifts that fire neither (an image above finishing + // load, streamed DOM pushing the slot down, an ancestor transform + // change) so the overlay can't detach from the frame. + try { this._spill.showPopover(); } catch {} + // After the spill, so the controls stack above it in the top layer. + try { this._ctl.showPopover(); } catch {} + this._reposition = () => { if (this.hasAttribute('data-reframe')) this._applyView(); }; + window.addEventListener('scroll', this._reposition, true); + window.addEventListener('resize', this._reposition); + this._lastRect = ''; + this._watch = () => { + if (!this.hasAttribute('data-reframe')) return; + const r = this.getBoundingClientRect(); + const key = r.left + ',' + r.top + ',' + r.width + ',' + r.height; + if (key !== this._lastRect) { this._lastRect = key; this._applyView(); } + this._watchId = requestAnimationFrame(this._watch); + }; + this._watchId = requestAnimationFrame(this._watch); + this._applyView(); + // Close on click outside (the spill handler stopPropagation()s so + // in-image drags don't reach this) and on Escape. Listeners are held + // on the instance so _exitReframe / disconnectedCallback can detach + // exactly what was attached. + this._outside = (e) => { + if (e.composedPath && e.composedPath().includes(this)) return; + this._exitReframe(true); + }; + this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); }; + document.addEventListener('pointerdown', this._outside, true); + document.addEventListener('keydown', this._esc, true); + } + + _exitReframe(commit) { + if (!this.hasAttribute('data-reframe')) return; + if (this._dragUp) this._dragUp(); + this.removeAttribute('data-reframe'); + this.removeAttribute('data-panning'); + if (this._outside) document.removeEventListener('pointerdown', this._outside, true); + if (this._esc) document.removeEventListener('keydown', this._esc, true); + this._outside = this._esc = null; + if (this._reposition) { + window.removeEventListener('scroll', this._reposition, true); + window.removeEventListener('resize', this._reposition); + this._reposition = null; + } + if (this._watchId) { cancelAnimationFrame(this._watchId); this._watchId = 0; } + if (this._pagehide) { + window.removeEventListener('pagehide', this._pagehide); + this._pagehide = null; + } + try { this._spill.hidePopover(); } catch {} + try { this._ctl.hidePopover(); } catch {} + this._ctl.style.left = ''; this._ctl.style.top = ''; + if (commit) this._commitView(); + this._signalReframe(false); + } + + // Reframe state lives only in this DOM until commit, invisible to the + // host's dirty signals — announce enter/exit so the host can hold + // auto-reloads for exactly the gesture (the guest bundle forwards + // image-slot:reframe to the host as imageSlotReframe). Dispatched on + // the element (composed, so it escapes shadow roots) while connected; + // a disconnected exit (disconnectedCallback) falls back to document so + // the host still hears it. + _signalReframe(active) { + const target = this.isConnected ? this : document; + target.dispatchEvent(new CustomEvent('image-slot:reframe', { + bubbles: true, composed: true, + detail: { active: active, id: this.id || null } + })); + } + + // Public: host's "Import from computer" calls this to run local browse. + openFilePicker() { this._exitReframe(true); this._input.click(); } + + attributeChangedCallback() { if (this.shadowRoot) this._render(); } + + // handleEvent — one listener object for all four drag events keeps the + // add/remove symmetric and the depth counter correct. + handleEvent(e) { + if (e.type === 'dragenter' || e.type === 'dragover') { + // Without preventDefault the browser never fires 'drop'. + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + if (e.type === 'dragenter') this._depth++; + this.setAttribute('data-over', ''); + } else if (e.type === 'dragleave') { + // dragenter/leave fire for every descendant crossing — count depth + // so hovering the icon inside the empty state doesn't flicker. + if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); } + } else if (e.type === 'drop') { + e.preventDefault(); + e.stopPropagation(); + this._depth = 0; + this.removeAttribute('data-over'); + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) this._ingest(f); + } + } + + async _ingest(file) { + this._setError(null); + if (!file || ACCEPT.indexOf(file.type) < 0) { + this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); + return; + } + // toDataUrl can take hundreds of ms on a large photo. A Clear or a + // newer drop during that window would be clobbered when this await + // resumes — bump + capture a generation so stale encodes bail. + const gen = ++this._gen; + // Replacing a shown image: surface the swap through the encode too, + // not just the decode — otherwise the old photo sits there with no + // feedback while the canvas re-encode runs. An empty slot keeps its + // placeholder (no spinner) until the encode lands, as before. + // _swapGen guards the mask against re-renders DURING the encode + // (pointerenter, ResizeObserver, another slot's store write): the + // stored value still resolves to the old image there, so _render's + // same-src clear would otherwise unmask it mid-replace. + if (this.hasAttribute('data-filled')) { + this.setAttribute('data-swapping', ''); + this._swapGen = gen; + } + try { + const w = this.clientWidth || this.offsetWidth || MAX_DIM; + const url = await toDataUrl(file, w); + if (gen !== this._gen) return; + // Only exit reframe once the new image is in hand — a rejected type + // or decode failure leaves the in-progress crop untouched. + this._exitReframe(false); + // Clear BEFORE setSlot: its synchronous re-render must see no + // pending encode, so a byte-identical re-upload (same data URL, no + // load event coming) still clears the mask via the complete branch. + this._swapGen = 0; + const val = { u: url, s: 1, x: 0, y: 0 }; + setSlot(this.id || '', val); + // Keep a session-local copy for id-less slots so the drop still + // shows, even though it cannot persist. + if (!this.id) { this._local = val; this._render(); } + } catch (err) { + if (gen !== this._gen) return; + this._swapGen = 0; + // Reveal the kept old image — unless another replacement (a + // remote pick's src swap) is still in flight, in which case the + // mask stays until THAT image settles (its load/error releases). + this._releaseMask(); + this._setError('Could not read that image.'); + console.warn(' ingest failed:', err); + } + } + + _setError(msg) { + if (this._err) { this._err.remove(); this._err = null; } + if (!msg) return; + const d = document.createElement('div'); + d.className = 'err'; d.textContent = msg; + this.shadowRoot.appendChild(d); + this._err = d; + setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000); + } + + // Reframing (pan/resize) is available on any filled slot — the user can + // always reposition/scale. `fit` only sets the initial baseline (see + // _geom): contain starts fully-visible, cover starts frame-filling. + _reframes() { + return this.hasAttribute('data-filled'); + } + + // The single release discipline for the replacement-in-flight mask + // (data-swapping). The mask comes off only when BOTH hold: + // - no encode is pending (_swapGen) — mid-encode the stored value + // still resolves to the old image, so any reveal paints it; + // - the frame img has settled on its current src — an unsettled src + // means some replacement is still in flight (e.g. a remote pick), + // whoever started it, and revealing would paint the previous + // frame. The load/error listeners pass settled=true (the event IS + // the settlement signal, per spec complete is true by then); + // other callers rely on the complete flag (covers loaded AND + // failed). + // Every release path funnels through here EXCEPT _render's empty + // branch (the img is being cleared — nothing will ever settle). + _releaseMask(settled) { + if ( + !this._swapGen && + !this._loadPending && + (settled || this._img.complete) + ) { + this.removeAttribute('data-swapping'); + } + } + + // Baseline geometry, shared by clamp/apply/resize. `base` is the scale at + // view-scale s=1: cover = fill the frame (overflow on the looser axis), + // contain = fit fully inside (letterboxed). Zooming a contain image past + // s where it overflows naturally becomes a crop. Null until the img has + // loaded (naturalWidth is 0 before that) or when the slot has no layout + // box — ResizeObserver fires with a 0×0 rect under display:none, and + // clamping against a degenerate 1×1 frame would silently pull the stored + // pan toward zero. + _geom() { + const iw = this._img.naturalWidth, ih = this._img.naturalHeight; + const fw = this.clientWidth, fh = this.clientHeight; + if (!iw || !ih || !fw || !fh) return null; + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + const base = contain + ? Math.min(fw / iw, fh / ih) + : Math.max(fw / iw, fh / ih); + return { iw, ih, fw, fh, base }; + } + + _clampView() { + // Pan range on each axis is half the overflow past the frame edge. + const g = this._geom(); + if (!g) return; + const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); + const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); + this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); + this._view.y = Math.max(-my, Math.min(my, this._view.y)); + } + + _applyView() { + const g = this._geom(); + // Top-layer controls: pin to the frame's top-right in viewport px + // (the same 8px inset as the in-frame layout; unscaled — top-layer UI + // reads as chrome, not page content). BEFORE the geometry branch: + // placement needs only the frame rect, and a not-yet-loaded or broken + // src must not leave the promoted strip floating unpositioned. Gated + // on the popover actually being open: without the Popover API, + // showPopover() threw (swallowed in _enterReframe), .ctl stays in + // its in-frame absolute layout, and viewport-px coordinates would + // shove it off-frame — and matches(':popover-open') itself throws + // there (unknown pseudo-class), hence the try/catch. + if (this.hasAttribute('data-reframe')) { + let onTop = false; + try { onTop = this._ctl.matches(':popover-open'); } catch {} + if (onTop) { + const r = this.getBoundingClientRect(); + this._ctl.style.left = (r.right - 8) + 'px'; + this._ctl.style.top = (r.top + 8) + 'px'; + } + } + if (!g) { + // Dimensions not known yet (before img load) — centered fit so there + // is no flash of an unpositioned image before the geometry lands. + const contain = (this.getAttribute('fit') || 'cover').toLowerCase() === 'contain'; + this._img.style.width = '100%'; + this._img.style.height = '100%'; + this._img.style.left = '50%'; + this._img.style.top = '50%'; + this._img.style.objectFit = contain ? 'contain' : 'cover'; + return; + } + // Baseline (cover-fill or contain-fit) × view scale. Width/height and + // left/top are all frame-% — depends only on the frame aspect ratio, so + // a responsive resize keeps the same crop. The spill layer mirrors the + // same box so its corners = image corners. + const k = g.base * this._view.s; + const w = (g.iw * k / g.fw * 100) + '%'; + const h = (g.ih * k / g.fh * 100) + '%'; + const l = (50 + this._view.x) + '%'; + const t = (50 + this._view.y) + '%'; + this._img.style.width = w; this._img.style.height = h; + this._img.style.left = l; this._img.style.top = t; + this._img.style.objectFit = ''; + if (this.hasAttribute('data-reframe')) { + // Top-layer spill: position in viewport px over the frame. The top + // layer escapes ancestor transforms entirely, so EVERY term must be + // in viewport units: getBoundingClientRect gives the frame's scaled + // origin AND size, and the rect/layout ratio rescales the ghost — + // sizing from layout px alone renders it 1/scale too large under a + // scaled deck slide. Inner ghost + handles stay box-relative. + const r = this.getBoundingClientRect(); + const sx = g.fw ? r.width / g.fw : 1; + const sy = g.fh ? r.height / g.fh : 1; + this._spill.style.width = (g.iw * k * sx) + 'px'; + this._spill.style.height = (g.ih * k * sy) + 'px'; + this._spill.style.left = (r.left + (50 + this._view.x) / 100 * r.width) + 'px'; + this._spill.style.top = (r.top + (50 + this._view.y) / 100 * r.height) + 'px'; + } + } + + _commitView() { + const v = { s: this._view.s, x: this._view.x, y: this._view.y }; + if (this._userUrl) v.u = this._userUrl; + // Framing-only (no u) persists too so an author-src slot remembers its + // crop; clearing the sidecar still falls through to src=. + if (this.id) setSlot(this.id, v); + else { this._local = v; } + } + + _render() { + // Shape / mask. Presets use border-radius so the dashed ring can + // follow the rounded outline; clip-path is only applied for an + // explicit `mask` (the ring is hidden there since a rectangle + // dashed border chopped by an arbitrary polygon looks broken). + const mask = this.getAttribute('mask'); + const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); + let radius = ''; + if (shape === 'circle') radius = '50%'; + else if (shape === 'pill') radius = '9999px'; + else if (shape === 'rounded') { + const n = parseFloat(this.getAttribute('radius')); + radius = (Number.isFinite(n) ? n : 12) + 'px'; + } + this._frame.style.borderRadius = mask ? '' : radius; + this._frame.style.clipPath = mask || ''; + this._ring.style.borderRadius = mask ? '' : radius; + this._ring.style.display = mask ? 'none' : ''; + + // Controls and reframe entry gate on this so share links stay read-only. + const editable = !!(window.omelette && window.omelette.writeFile); + this.toggleAttribute('data-editable', editable); + this._sub.style.display = editable ? '' : 'none'; + + // Content. The sidecar is also writable by the agent's write_file + // tool, so its value isn't guaranteed canvas-originated — only accept + // data:image/ URLs from it. The `src` attribute is author-controlled + // (Claude wrote it into the HTML) so it passes through unchanged. + let stored = this.id ? getSlot(this.id) : this._local; + if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null; + const srcAttr = this.getAttribute('src') || ''; + this._userUrl = (stored && stored.u) || null; + const url = this._userUrl || srcAttr; + // Don't clobber an in-flight reframe with a store-triggered re-render. + if (!this.hasAttribute('data-reframe')) { + this._view = { + s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, + x: stored && Number.isFinite(stored.x) ? stored.x : 0, + y: stored && Number.isFinite(stored.y) ? stored.y : 0, + }; + } + this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; + // Toggle via style.display — the [hidden] attribute alone loses to + // the display:flex / display:block rules in the stylesheet above. + // An Unsplash src with no credit attribute must NOT render — showing + // the photo uncredited is the Unsplash-terms violation itself. The + // error tile replaces the photo until the credit is written. A + // user-dropped image is the user's own content and always renders. + // Trimmed: credit is agent/user-editable content, and a whitespace- + // only value must count as missing — otherwise it would suppress the + // error tile AND render an empty credit box (no text, no links), + // exactly the unattributed state this gate exists to prevent. + const credit = (this.getAttribute('credit') || '').trim(); + const attrError = !!( + !credit && !this._userUrl && srcAttr && isUnsplashHost(srcAttr) + ); + this.toggleAttribute('data-attribution-error', attrError); + if (url && !attrError) { + const prev = this._img.getAttribute('src'); + if (prev !== url) { + // Replacing an already-shown image: mark the swap BEFORE setting + // src so the stale frame is never revealed (see the data-swapping + // stylesheet rules). First fill (prev empty) keeps the existing + // placeholder-until-load behavior — no spinner. _hidShowing + // covers the pick path's transient attribution-error wipe: prev + // is gone, but an image WAS showing, so this is a replacement. + if (prev || this._hidShowing) this.setAttribute('data-swapping', ''); + // Mark the swap BEFORE assigning src: complete keeps reporting + // the old settled request until the browser's + // update-the-image-data microtask runs, so same-task re-renders + // (the pick path's credit/credit-href setAttributes) need this + // flag, not complete, to know a load is in flight. + this._loadPending = true; + this._img.src = url; + this._ghost.src = url; + } else { + // Same-src re-render — release if settled, so an ingest-set + // spinner can't stick after a byte-identical re-upload (same + // data URL, no further load event ever fires). + this._releaseMask(); + } + this._hidShowing = false; + this._img.style.display = 'block'; + this._empty.style.display = 'none'; + this.setAttribute('data-filled', ''); + this._clampView(); + this._applyView(); + } else { + this.removeAttribute('data-swapping'); + // The src is being removed — no load/error will ever fire for it. + this._loadPending = false; + // A transient attribution-error wipe of a showing image happens on + // the pick path: the host sets src one setAttribute before credit, + // so render N hides the old image (attrError) and render N+1 + // restores a URL. Remember the wipe so that restore renders as a + // replacement (spinner), not a first fill (blank frame). + this._hidShowing = attrError && !!this._img.getAttribute('src'); + this._img.style.display = 'none'; + this._img.removeAttribute('src'); + this._ghost.removeAttribute('src'); + // The error tile owns the blocked-photo state; .empty stays for + // the genuinely-empty slot. + this._empty.style.display = attrError ? 'none' : 'flex'; + this.removeAttribute('data-filled'); + } + + // Credit belongs to the author src, so a user drop hides it. + // textContent + the http(s)-only funnel keep external strings inert. + const showCredit = !!(url && credit && !this._userUrl && !attrError); + this._credit.textContent = ''; + if (showCredit) { + // Validate once (resolved against the document, http(s) only), + // then append the terms-required utm referral params to links + // that point back at unsplash.com. + let href = ''; + const rawHref = this.getAttribute('credit-href') || ''; + if (rawHref) { + try { + const u = new URL(rawHref, document.baseURI); + if (u.protocol === 'http:' || u.protocol === 'https:') { + href = withReferral(u.href); + } + } catch {} + } + const mkLink = (text, linkHref) => { + const a = document.createElement('a'); + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + a.setAttribute('href', linkHref); + a.textContent = text; + return a; + }; + // Unsplash's prescribed credit is TWO links — the photographer's + // name to their profile (credit-href) and 'Unsplash' to the + // homepage. Render that split whenever the text has the canonical + // shape; other text keeps the legacy single-link rendering. + const m = /^Photo by (.+) on Unsplash$/.exec(credit); + if (m) { + this._credit.appendChild(document.createTextNode('Photo by ')); + this._credit.appendChild( + href ? mkLink(m[1], href) : document.createTextNode(m[1]) + ); + this._credit.appendChild(document.createTextNode(' on ')); + this._credit.appendChild(mkLink('Unsplash', UNSPLASH_HOMEPAGE_HREF)); + } else if (href) { + this._credit.appendChild(mkLink(credit, href)); + } else { + this._credit.textContent = credit; + } + } + this.toggleAttribute('data-credit', showCredit); + } + } + + if (!customElements.get('image-slot')) { + customElements.define('image-slot', ImageSlot); + } +})(); diff --git a/design/baseline/# UI для онлайн звонка/support.js b/design/baseline/# UI для онлайн звонка/support.js new file mode 100644 index 0000000..6ca00e3 --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/support.js @@ -0,0 +1,1768 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const kids = hasContent ? walkChildren(el, host) : []; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+ +
+
+
+
+
+
+ +
+ +
+ {{ statusLabel }} + + {{ channelLabel }} + +
+ + {{ timer }} + +
+ +
+ +
+ + + + +
{{ initials }}
+
+
+
{{ peerName }}
+
{{ subCaption }}
+
+
+ + + + +
+ + Длительность {{ finalDuration }} +
+
+
+
+ + +
+
{{ statusIcon }}
+
+
{{ statusTitle }}
+
{{ statusCaption }}
+
+
+
+
+ + +
+
+ + Отклонить +
+
+ + Принять +
+
+
+ + +
+
+
+ + Микрофон +
+
+ + Динамик +
+
+
+ +
+
+
+ + +
+ + Отменить +
+
+ + +
+ + +
+
+ + +
+ +
+
+ + +
+ + +
+
+ + +
+
+ + Восстанавливаем связь… +
+
+ +
+
+
+ + +
+ + +
+
+ + +
+ + Отменить +
+
+ +
+
+ +
Полноэкранный аудиозвонок · тёмная тема, живая осциллограмма собеседника. Переключайте состояния выше.
+ +
+
+ + + diff --git a/design/baseline/# UI для онлайн звонка/Публичная страница звонка.dc.html b/design/baseline/# UI для онлайн звонка/Публичная страница звонка.dc.html new file mode 100644 index 0000000..c3569c9 --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/Публичная страница звонка.dc.html @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
E
+ Edevs +
+ Защищённое соединение +
+ +
+
+ +
+
+
+ + +
{{ initials }}
+
+
{{ orgName }}
+
приглашает вас на видеозвонок
+
+ +
+
+
{{ agentInitials }}
+
+
{{ agentName }}
+
{{ agentRole }} · {{ orgName }}
+
+
+ Онлайн +
+ +
+
Готовы присоединиться?
+
Проверьте камеру и микрофон перед входом. Мы не записываем видео без предупреждения.
+
+ +
+
+ + Микрофон +
+
+ + Камера +
+
+ +
+ + +
+ +
+ + Звонок защищён сквозным шифрованием +
+
+
+
+ +
Работает на платформе Edevs Hub
+ +
+
+ + + diff --git a/design/baseline/# UI для онлайн звонка/Экран звонка.dc.html b/design/baseline/# UI для онлайн звонка/Экран звонка.dc.html new file mode 100644 index 0000000..49a10f3 --- /dev/null +++ b/design/baseline/# UI для онлайн звонка/Экран звонка.dc.html @@ -0,0 +1,410 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
Экран звонка
+
Встраиваемый компонент видеозвонка оператора и клиента — для модалки или страницы Hub. Полный жизненный цикл вызова.
+
+ +
+
+
+ Сторона +
+ + +
+
+
+ Основные состояния +
+ + + +
+
+
+
+ Ошибки и завершение +
+ + + +
+
+ +
+ Собеседник + + +
+
+
+ +
+ +
+
{{ peerInitials }}
+
+
+ {{ peerName }} + + + +
+ {{ subtitle }} +
+
+ +
+ + {{ timer }} +
+
+ +
+ +
+ + + + + + +
+
+ + + + +
{{ mainInitials }}
+
+
+
{{ mainName }}
+
{{ mainCaption }}
+
+
+
+ + +
+ + Связь прервана · восстанавливаем соединение… +
+
+ + +
+
{{ statusIcon }}
+
+
{{ statusTitle }}
+
{{ statusCaption }}
+
+ +
+ + + +
+
+
+
+ + +
+ + + + {{ peerName }} +
+
+ + +
+ + + + +
+
ВЫ
+
+
+ Вы +
+
+ +
+ + +
+
+ + Отклонить +
+
+ + Принять +
+
+
+ + +
+ + Отменить +
+
+ + +
+
+
+ + Микрофон +
+
+ + Камера +
+
+
+ + +
+
+
+ + +
+ + + + +
+
+ +
+ +
Переключайте сторону и состояние выше. В активном звонке доступны тумблеры «Собеседник», чтобы проверить индикаторы удалённого микрофона и камеры.
+ +
+
+ + + diff --git a/packages/ui/src/call/AudioCallIcons.tsx b/packages/ui/src/call/AudioCallIcons.tsx new file mode 100644 index 0000000..4389760 --- /dev/null +++ b/packages/ui/src/call/AudioCallIcons.tsx @@ -0,0 +1,70 @@ +// Иконки аудиозвонка (baseline «Аудиозвонок.dc.html»): stroke=currentColor, +// 1.85, round caps. Микрофон/динамик (аудио не имеет камеры), завершение и +// status-набор для процессов/ошибок/терминалов. + +import type { ReactNode } from "react"; + +export type DeviceName = "mic" | "speaker"; + +const svg = + (width = 23) => + (children: ReactNode) => + ( + + {children} + + ); + +export function MicIcon({ on }: { on: boolean }) { + const s = svg(23); + return on + ? s(<>) + : s(<>); +} + +export function SpeakerIcon({ on }: { on: boolean }) { + const s = svg(23); + return on + ? s(<>) + : s(<>); +} + +// Трубка; rotated=true → повёрнута на 135° (отклонить/завершить/отменить). +export function PhoneIcon({ rotated = false, width = 26 }: { rotated?: boolean; width?: number }) { + const s = svg(width); + return s( + + ); +} + +export function SpinnerIcon({ width = 34 }: { width?: number }) { + const s = svg(width); + return s(); +} + +export type AudioStatusIconName = "spinner" | "declined" | "missed" | "clock" | "alert" | "nodevice" | "unsupported"; + +export function AudioStatusIcon({ name }: { name: AudioStatusIconName }) { + const s = svg(34); + switch (name) { + case "spinner": + return ; + case "declined": + return s(); + case "missed": + return s(<>); + case "clock": + return s(<>); + case "alert": + return s(<>); + case "nodevice": + return s(<>); + case "unsupported": + return s(<>); + default: + return null; + } +} diff --git a/packages/ui/src/call/AudioCallView.tsx b/packages/ui/src/call/AudioCallView.tsx new file mode 100644 index 0000000..d9ccbf3 --- /dev/null +++ b/packages/ui/src/call/AudioCallView.tsx @@ -0,0 +1,240 @@ +// Аудиозвонок — полноэкранный портретный UI (baseline «Аудиозвонок.dc.html»). +// Тёмный градиент 150deg #0958d9→#722ed1→#c41d7f + два дрейфующих блоба, +// glass-аватар 126px с двумя волнами edv-ring2, живая осциллограмма собеседника. +// Никакой камеры/видео/PiP — это честный аудио-режим. + +import { AudioCallWaveform } from "./AudioCallWaveform"; +import { AudioStatusIcon } from "./AudioCallIcons"; +import { MicIcon, PhoneIcon, SpeakerIcon } from "./AudioCallIcons"; +import { type AudioCallMode, type AudioCallStatus, formatDuration } from "./audioCallStates"; +import "./audio-call.css"; + +type Props = { + mode: AudioCallMode; + status?: AudioCallStatus; + peerName: string; + peerInitials: string; + channelLabel?: string; + showChannel?: boolean; + micOn?: boolean; + speakerOn?: boolean; + remoteStream?: MediaStream | null; + remoteMicOn?: boolean; + elapsedSeconds?: number; + durationSeconds?: number | null; + mediaIssue?: string; + statusLabel?: string; + subCaption?: string; + onToggleMic?: () => void; + onToggleSpeaker?: () => void; + onAccept?: () => void; + onDecline?: () => void; + onCancel?: () => void; + onEnd?: () => void; + onClose?: () => void; + onCallAgain?: () => void; + onRetry?: () => void; +}; + +export function AudioCallView(props: Props) { + const status = props.status; + const isStatus = props.mode === "status" && !!status; + const showAvatarCenter = !isStatus; + const ringsVisible = props.mode === "incoming" || props.mode === "ringing"; + const isActive = props.mode === "active"; + const timer = formatDuration(props.elapsedSeconds ?? 0); + + return ( +
+
+
+
+
+
+
+ +
+
+ {props.statusLabel ?? defaultStatusLabel(props.mode, status)} + {props.showChannel !== false && {props.channelLabel ?? ""}} +
+ {isActive && {timer}} +
+ +
+ {showAvatarCenter && ( + <> +
+ {ringsVisible && ( + <> + + + + )} +
{props.peerInitials}
+
+
+
{props.peerName}
+
{props.subCaption ?? defaultSubCaption(props.mode, props.micOn)}
+
+
+ {isActive && } + {!isActive && durationPill(props.durationSeconds)} +
+ + )} + {isStatus && status && ( + + )} +
+ +
+ {props.mode === "incoming" && ( + }} right={{ label: "Принять", tone: "accept", icon: }} onLeft={props.onDecline} onRight={props.onAccept} /> + )} + {props.mode === "ringing" && ( + } onClick={props.onCancel ?? props.onEnd} /> + )} + {props.mode === "connecting" && ( + } onClick={props.onCancel ?? props.onEnd} /> + )} + {isActive && ( +
+
+ } on={props.micOn !== false} onClick={props.onToggleMic} /> + } on={props.speakerOn !== false} onClick={props.onToggleSpeaker} /> +
+ } onClick={props.onEnd} /> +
+ )} + {isStatus && status && ( + + )} +
+
+
+ ); +} + +function StatusCenter({ status }: { status: AudioCallStatus }) { + return ( +
+ +
+
{status.title}
+
{status.caption}
+
+
+ ); +} + +function DeviceControl({ label, icon, on, onClick }: { label: string; icon: React.ReactNode; on: boolean; onClick?: () => void }) { + return ( +
+ + {label} +
+ ); +} + +function SingleRoundBar({ label, tone, icon, onClick }: { label: string; tone: "decline"; icon: React.ReactNode; onClick?: () => void }) { + return ( +
+ + {label} +
+ ); +} + +function PairBar({ left, right, onLeft, onRight }: { left: BarEnd; right: BarEnd; onLeft?: () => void; onRight?: () => void }) { + return ( +
+ + +
+ ); +} + +type BarEnd = { label: string; tone: "decline" | "accept"; icon: React.ReactNode }; +function EndStack({ end, onClick }: { end: BarEnd; onClick?: () => void }) { + return ( +
+ + {end.label} +
+ ); +} + +// Бар для терминальных/ошибочных статусов: кнопки 48px, как в дизайне ended. +function StatusBar({ status, onClose, onCallAgain, onRetry, onEnd }: { status: AudioCallStatus; onClose?: () => void; onCallAgain?: () => void; onRetry?: () => void; onEnd?: () => void }) { + const bar = status.bar; + if (bar === "reconnect") { + return } onClick={onEnd} />; + } + if (bar === "retrySingle") { + return ( +
+ Позвонить снова +
+ ); + } + if (bar === "retryClose") { + return ( +
+ Закрыть + Повторить +
+ ); + } + if (bar === "retryCheck") { + return ( +
+ Закрыть + Повторить проверку +
+ ); + } + // ended / connecting + return ( +
+ Закрыть + Позвонить снова +
+ ); +} + +function PillButton({ kind, onClick, children }: { kind: "solid" | "ghost"; onClick?: () => void; children: React.ReactNode }) { + return ; +} + +function durationPill(duration?: number | null) { + if (duration == null || duration <= 0) return null; + return ( +
+ + Длительность {formatDuration(duration)} +
+ ); +} + +function bgState(mode: AudioCallMode): string { + if (mode === "active") return "active"; + if (mode === "status" || mode === "reconnecting") return "dim"; + return "full"; +} + +function defaultStatusLabel(mode: AudioCallMode, status?: AudioCallStatus): string { + if (status) return status.title; + if (mode === "incoming") return "Входящий звонок"; + if (mode === "ringing") return "Исходящий звонок"; + if (mode === "active") return "Аудиозвонок"; + if (mode === "connecting") return "Соединение"; + if (mode === "reconnecting") return "Переподключение"; + return "Звонок завершён"; +} + +function defaultSubCaption(mode: AudioCallMode, micOn?: boolean): string { + if (mode === "active") return micOn ? "Говорите" : "Ваш микрофон выключен"; + if (mode === "incoming" || mode === "ringing") return ""; + return ""; +} diff --git a/packages/ui/src/call/AudioCallWaveform.tsx b/packages/ui/src/call/AudioCallWaveform.tsx new file mode 100644 index 0000000..3b2c017 --- /dev/null +++ b/packages/ui/src/call/AudioCallWaveform.tsx @@ -0,0 +1,96 @@ +// Живая осциллограмма аудиозвонка (baseline «Аудиозвонок.dc.html»). +// Белая линия rgba(255,255,255,.92), lineWidth 3, конусное затухание по краям. +// Амплитуда берётся из AnalyserNode удалённого потока — волна реагирует на +// голос собеседника. Нет потока/тишина → мягкая анимированная idle-волна. + +import { useEffect, useRef } from "react"; + +type Props = { + stream?: MediaStream | null; + micOn?: boolean; + active: boolean; +}; + +const IDLE_AMP = 0.12; +const SPEAK_AMP = 0.5; + +export function AudioCallWaveform({ stream, micOn = true, active }: Props) { + const canvasRef = useRef(null); + const streamRef = useRef(stream); + streamRef.current = stream; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const W = canvas.width; + const H = canvas.height; + const mid = H / 2; + + // AnalyserNode на удалённом аудио, чтобы измерять громкость собеседника. + let audioCtx: AudioContext | null = null; + let analyser: AnalyserNode | null = null; + let source: MediaStreamAudioSourceNode | null = null; + let levels: Uint8Array | null = null; + + try { + const target = streamRef.current; + if (target && target.getAudioTracks().length > 0) { + audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); + analyser = audioCtx.createAnalyser(); + analyser.fftSize = 1024; + source = audioCtx.createMediaStreamSource(target); + source.connect(analyser); + levels = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount)); + } + } catch { + // AudioContext недоступен (нет аудио/нет прав) — работаем idle-волной. + } + + let raf = 0; + const draw = (ts: number) => { + raf = requestAnimationFrame(draw); + ctx.clearRect(0, 0, W, H); + // Уровень громкости собеседника 0..1 (RMS по bins). + let level = 0; + if (active && micOn && analyser && levels) { + analyser.getByteTimeDomainData(levels); + let sum = 0; + for (let i = 0; i < levels.length; i++) { + const v = (levels[i] - 128) / 128; + sum += v * v; + } + level = Math.min(1, Math.sqrt(sum / levels.length) * 2.4); + } + const speaking = active && micOn; + const t = ts / 1000; + const base = speaking ? SPEAK_AMP : IDLE_AMP; + const ampScale = speaking ? (0.35 + 0.65 * level) : 1; + + ctx.lineWidth = 3; + ctx.lineJoin = "round"; + ctx.strokeStyle = "rgba(255,255,255,0.92)"; + ctx.beginPath(); + 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 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); + } + ctx.stroke(); + }; + raf = requestAnimationFrame(draw); + + return () => { + cancelAnimationFrame(raf); + source?.disconnect(); + analyser?.disconnect(); + void audioCtx?.close(); + }; + }, [active, micOn]); + + return ; +} diff --git a/packages/ui/src/call/audio-call.css b/packages/ui/src/call/audio-call.css new file mode 100644 index 0000000..0fdfba8 --- /dev/null +++ b/packages/ui/src/call/audio-call.css @@ -0,0 +1,149 @@ +/* Аудиозвонок (baseline «Аудиозвонок.dc.html»). Тёмная портретная сцена с + живым градиентом и glass-элементами. Токены совпадают с макетом 1-в-1. */ + +@keyframes hub-audio-ring2 { 0% { transform: scale(1); opacity: .5; } 100% { transform: scale(1.9); opacity: 0; } } +@keyframes hub-audio-drift { 0% { transform: translate(0,0) scale(1); } 50% { transform: translate(4%,-3%) scale(1.08); } 100% { transform: translate(0,0) scale(1); } } +@keyframes hub-audio-drift2 { 0% { transform: translate(0,0) scale(1.05); } 50% { transform: translate(-5%,4%) scale(1); } 100% { transform: translate(0,0) scale(1.05); } } +@keyframes hub-audio-spin { to { transform: rotate(360deg); } } + +.hub-audio-view { + position: relative; + width: 100%; + max-width: 400px; + aspect-ratio: 9 / 16; + max-height: 760px; + margin: 0 auto; + border-radius: var(--radius-4xl, 28px); + overflow: hidden; + background: #0d0d16; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55); +} + +.hub-audio-bg { position: absolute; inset: 0; z-index: 0; overflow: hidden; } +.hub-audio-grad { + position: absolute; inset: 0; + background: linear-gradient(150deg, #0958d9 0%, #722ed1 52%, #c41d7f 100%); + opacity: 1; transition: opacity .6s; +} +[data-bg="active"] .hub-audio-grad { opacity: .85; } +[data-bg="dim"] .hub-audio-grad { opacity: .5; } + +.hub-audio-blob { position: absolute; border-radius: 50%; } +.hub-audio-blob.a { + width: 75%; height: 55%; left: -12%; top: -10%; + background: radial-gradient(circle, #597ef7, transparent 68%); + filter: blur(18px); opacity: .75; + animation: hub-audio-drift 9s ease-in-out infinite; +} +.hub-audio-blob.b { + width: 70%; height: 52%; right: -14%; bottom: -8%; + background: radial-gradient(circle, #c41d7f, transparent 68%); + filter: blur(20px); opacity: .6; + animation: hub-audio-drift2 11s ease-in-out infinite; +} +.hub-audio-veil { + position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(11, 11, 18, .15) 0%, rgba(11, 11, 18, .05) 45%, rgba(11, 11, 18, .55) 100%); +} + +.hub-audio-body { + position: relative; z-index: 2; height: 100%; + box-sizing: border-box; display: flex; flex-direction: column; + padding: 26px 24px 30px; color: #fff; +} + +.hub-audio-top { display: flex; align-items: center; gap: 9px; } +.hub-audio-status-label { font-size: 13px; font-weight: 600; letter-spacing: .02em; color: rgba(255, 255, 255, .9); } +.hub-audio-channel { + display: inline-flex; align-items: center; gap: 5px; + padding: 3px 9px; border-radius: var(--radius-pill, 999px); + background: rgba(255, 255, 255, .16); backdrop-filter: blur(6px); + font-size: 11.5px; font-weight: 500; color: #fff; +} +.hub-audio-top-spacer { flex: 1; } +.hub-audio-timer { + font-size: 14px; font-weight: 600; letter-spacing: .04em; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #fff; +} + +.hub-audio-center { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 22px; +} +.hub-audio-avatar-wrap { position: relative; display: flex; align-items: center; justify-content: center; } +.hub-audio-ring { + position: absolute; width: 126px; height: 126px; border-radius: 50%; + border: 2px solid rgba(255, 255, 255, .45); + animation: hub-audio-ring2 2s ease-out infinite; +} +.hub-audio-ring.delay { animation-delay: 1s; } +.hub-audio-avatar { + width: 126px; height: 126px; border-radius: 50%; + background: rgba(255, 255, 255, .14); backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, .28); + display: flex; align-items: center; justify-content: center; + font-size: 42px; font-weight: 600; color: #fff; + box-shadow: 0 8px 30px rgba(0, 0, 0, .3); +} +.hub-audio-name-block { text-align: center; } +.hub-audio-name { font-size: 25px; font-weight: 600; letter-spacing: -0.01em; color: #fff; } +.hub-audio-sub { font-size: 14px; color: rgba(255, 255, 255, .72); margin-top: 6px; } + +.hub-audio-wave-slot { height: 56px; width: 100%; max-width: 260px; display: flex; align-items: center; justify-content: center; } +.hub-audio-wave { width: 100%; height: 56px; } + +.hub-audio-status-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; text-align: center; } +.hub-audio-status-tile { + width: 92px; height: 92px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + border: 1px solid rgba(255, 255, 255, .22); backdrop-filter: blur(10px); + box-shadow: 0 8px 30px rgba(0, 0, 0, .3); +} +.hub-audio-status-tile.neutral { background: rgba(255, 255, 255, .14); color: #fff; } +.hub-audio-status-tile.warn { background: rgba(250, 173, 20, .18); color: #ffc53d; } +.hub-audio-status-tile.error { background: rgba(255, 77, 79, .2); color: #ff7875; } +.hub-audio-status-title { font-size: 23px; font-weight: 600; letter-spacing: -0.01em; color: #fff; line-height: 1.25; } +.hub-audio-status-caption { font-size: 14px; color: rgba(255, 255, 255, .72); margin-top: 8px; line-height: 1.5; max-width: 280px; } + +.hub-audio-duration-pill { + display: inline-flex; align-items: center; gap: 8px; + padding: 8px 16px; border-radius: var(--radius-pill, 999px); + background: rgba(255, 255, 255, .12); font-size: 13.5px; color: #fff; +} + +.hub-audio-bottom { display: flex; flex-direction: column; gap: 18px; } + +.hub-audio-pair { display: flex; justify-content: center; gap: 64px; } +.hub-audio-round-stack { display: flex; flex-direction: column; align-items: center; gap: 9px; } +.hub-audio-round { + width: 64px; height: 64px; border-radius: 50%; border: none; cursor: pointer; + display: flex; align-items: center; justify-content: center; color: #fff; +} +.hub-audio-round.decline { background: var(--error, #ff4d4f); box-shadow: 0 6px 20px rgba(255, 77, 79, .45); } +.hub-audio-round.accept { background: var(--success, #52c41a); box-shadow: 0 6px 20px rgba(82, 196, 26, .45); } +.hub-audio-round-label { font-size: 12.5px; color: rgba(255, 255, 255, .75); } + +.hub-audio-active { display: flex; flex-direction: column; gap: 18px; } +.hub-audio-device-row { display: flex; justify-content: center; gap: 40px; } +.hub-audio-device-control { display: flex; flex-direction: column; align-items: center; gap: 8px; } +.hub-audio-device { + width: 60px; height: 60px; border-radius: 50%; cursor: pointer; + border: 1px solid rgba(255, 255, 255, .28); color: #fff; + background: rgba(255, 255, 255, .16); backdrop-filter: blur(8px); + display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s; +} +.hub-audio-device.off { background: #fff; color: #141414; } +.hub-audio-device-control span { font-size: 11.5px; color: rgba(255, 255, 255, .72); } + +.hub-audio-pill-bar { display: flex; gap: 12px; } +.hub-audio-pill-bar.two > * { flex: 1; } +.hub-audio-pill { + height: 48px; border-radius: var(--radius-lg, 12px); cursor: pointer; + font-size: 14px; font-weight: 600; font-family: var(--font-sans, inherit); +} +.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; } + +@media (max-width: 480px) { + .hub-audio-view { max-width: 100%; max-height: none; aspect-ratio: auto; height: 100%; border-radius: 0; } +} diff --git a/packages/ui/src/call/audioCallStates.ts b/packages/ui/src/call/audioCallStates.ts new file mode 100644 index 0000000..0f82e1a --- /dev/null +++ b/packages/ui/src/call/audioCallStates.ts @@ -0,0 +1,75 @@ +// Состояния аудиозвонка и сопутствующие данные (baseline «Аудиозвонок.dc.html»). +// Тексты/иконки/тоны вынесены отдельно, чтобы AudioCallView оставался +// composition-only и не смешивал разметку со справочником состояний. + +import type { AudioStatusIconName } from "./AudioCallIcons"; + +export type AudioCallMode = "incoming" | "ringing" | "connecting" | "active" | "reconnecting" | "status"; + +export type AudioCallActionKind = "close" | "callAgain" | "retry" | "retryCheck" | "end"; + +export type AudioCallAction = { label: string; kind: AudioCallActionKind }; + +export type AudioCallStatus = { + icon: AudioStatusIconName; + tone: "neutral" | "warn" | "error"; + title: string; + caption: string; + bar: AudioBarKind; +}; + +// Какой нижний бар рисовать для текущего состояния (вне active/incoming/ringing/connecting). +export type AudioBarKind = "ended" | "retrySingle" | "retryClose" | "reconnect" | "retryCheck"; + +export type AudioStatusEntry = AudioCallStatus; + +// Терминальные состояния, приходящие из домена звонков (TERMINAL_CALL_STATUSES). +export const AUDIO_TERMINAL_STATUSES = new Set([ + "DECLINED", + "CANCELLED", + "MISSED", + "ENDED", + "FAILED", + "EXPIRED", +]); + +export const isAudioTerminal = (status?: string) => Boolean(status && AUDIO_TERMINAL_STATUSES.has(status)); + +function dur(prefix: string, duration?: number | null) { + return duration != null && duration > 0 ? `${prefix}Длительность ${formatDuration(duration)}. ` : prefix; +} + +// Справочник статусов-центров: процессы + ошибки + терминалы. +// `duration` подставляется только там, где разговор реально состоялся. +export function buildAudioStatus(status: string, peerName: string, duration?: number | null): AudioStatusEntry | null { + const p = `${peerName} `; + switch (status) { + case "connecting": + case "CONNECTING": + return { icon: "spinner", tone: "neutral", title: "Соединяем", caption: "Устанавливаем защищённое соединение…", bar: "ended" }; + case "reconnecting": + return { icon: "spinner", tone: "neutral", title: "Связь прервана", caption: "Восстанавливаем соединение…", bar: "reconnect" }; + case "DECLINED": + return { icon: "declined", tone: "neutral", title: "Звонок отклонён", caption: `${p}отклонил(а) вызов.`, bar: "retrySingle" }; + case "MISSED": + return { icon: "missed", tone: "warn", title: "Пропущенный звонок", caption: `${p}не ответил(а) на вызов.`, bar: "retrySingle" }; + case "EXPIRED": + return { icon: "clock", tone: "warn", title: "Время ожидания истекло", caption: "Никто не ответил вовремя. Попробуйте позвонить снова.", bar: "retrySingle" }; + case "CANCELLED": + return { icon: "declined", tone: "neutral", title: "Звонок отменён", caption: "Приглашение отменено.", bar: "ended" }; + case "FAILED": + return { icon: "alert", tone: "error", title: "Не удалось соединиться", caption: `${dur("", duration)}Проверьте интернет-соединение и попробуйте снова.`, bar: "retryClose" }; + case "ENDED": + return { icon: "clock", tone: "neutral", title: "Звонок завершён", caption: `${dur("", duration)}Разговор завершён.`, bar: "ended" }; + case "nodevice": + return { icon: "nodevice", tone: "error", title: "Нет доступа к микрофону", caption: "Разрешите доступ к микрофону в настройках браузера и повторите.", bar: "retryCheck" }; + case "unsupported": + return { icon: "unsupported", tone: "error", title: "Аудиозвонки не поддерживаются", caption: "Обновите браузер или откройте ссылку в Chrome, Safari или Edge.", bar: "retryCheck" }; + default: + return null; + } +} + +export function formatDuration(seconds: number) { + return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; +} diff --git a/packages/ui/src/call/useCallRtcSession.ts b/packages/ui/src/call/useCallRtcSession.ts index e59843e..95e4f16 100644 --- a/packages/ui/src/call/useCallRtcSession.ts +++ b/packages/ui/src/call/useCallRtcSession.ts @@ -9,6 +9,7 @@ type Options = { accessToken: string; side: CallSide; iceServers: RTCIceServer[]; + videoEnabled?: boolean; onCallState: (call: PublicCallState) => void; }; @@ -27,6 +28,8 @@ export function useCallRtcSession(options: Options) { const localRef = useRef(null); const clientRef = useRef(null); const preparingRef = useRef(false); + const videoEnabledRef = useRef(options.videoEnabled ?? true); + videoEnabledRef.current = options.videoEnabled ?? true; const onCallStateRef = useRef(options.onCallState); onCallStateRef.current = options.onCallState; @@ -47,14 +50,24 @@ export function useCallRtcSession(options: Options) { preparingRef.current = true; setPreparing(true); try { + // Аудиозвонок: видео не запрашивается и не откатывается — это честный + // аудио-режим. Видеозвонок: video+audio, при отказе камеры — audio-only. + const constraints: MediaStreamConstraints = videoEnabledRef.current + ? { audio: true, video: true } + : { audio: true, video: false }; let stream: MediaStream; try { - stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); + stream = await navigator.mediaDevices.getUserMedia(constraints); setMediaIssue("none"); + if (!videoEnabledRef.current) setCamOn(false); } catch { - stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); - setCamOn(false); - setMediaIssue("video"); + if (videoEnabledRef.current) { + stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + setCamOn(false); + setMediaIssue("video"); + } else { + throw new Error("audio devices unavailable"); + } } localRef.current = stream; setLocalStream(stream); @@ -71,7 +84,7 @@ export function useCallRtcSession(options: Options) { useEffect(() => { stop(); setMicOn(true); - setCamOn(true); + setCamOn(videoEnabledRef.current); setRemoteMicOn(true); setRemoteCamOn(true); setMediaIssue("none"); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c7ee9b3..f03e1a4 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1,6 @@ export { edevsHubTheme } 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 { useCallRtcSession, type CallMediaIssue } from "./call/useCallRtcSession"; export { useAudioCue, useLoopingAudio } from "./audio/useAudio";