mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc091dbd03 | ||
|
|
8f3b1e43a4 |
No files matched your search
@@ -90,16 +90,21 @@ LOADER_JS = r"""
|
||||
frame.title = "Чат";
|
||||
frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
|
||||
shell.appendChild(frame);
|
||||
layoutShell();
|
||||
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
|
||||
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
|
||||
// повторяем его на load.
|
||||
frame.addEventListener("load", function () { if (open) notifyOpened(); });
|
||||
frame.addEventListener("load", function () {
|
||||
notifyLayout();
|
||||
if (open) notifyOpened();
|
||||
});
|
||||
document.body.appendChild(shell);
|
||||
window.addEventListener("message", function (e) {
|
||||
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
|
||||
var d = e.data || {};
|
||||
if (d.instanceId && d.instanceId !== instanceId) return;
|
||||
if (d.type === "chatballs-chat-close") setOpen(false);
|
||||
if (d.type === "chatballs-chat-layout-request") notifyLayout();
|
||||
if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
|
||||
if (d.type === "chatballs-chat-unread") {
|
||||
unread = Boolean(d.unread);
|
||||
@@ -121,11 +126,45 @@ LOADER_JS = r"""
|
||||
var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
|
||||
var PANEL_HEIGHT = { normal: "min(720px,calc(100vh - 116px))", expanded: "min(820px,calc(100vh - 116px))" };
|
||||
|
||||
// На телефоне плавающее окно не помещается: панель занимает весь экран,
|
||||
// разворачивать её уже некуда, а закрывают её кнопкой в шапке.
|
||||
var mobile = window.matchMedia("(max-width: 480px)");
|
||||
|
||||
function panelRadius() { return mobile.matches ? 0 : 24; }
|
||||
|
||||
function layoutShell() {
|
||||
if (!shell) return;
|
||||
var full = mobile.matches;
|
||||
shell.style.top = full ? "0" : "";
|
||||
shell.style.left = full ? "0" : "";
|
||||
shell.style.right = full ? "0" : "24px";
|
||||
shell.style.bottom = full ? "0" : "92px";
|
||||
shell.style.width = full ? "100%" : expanded ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
|
||||
shell.style.height = full ? "100%" : expanded ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
|
||||
frame.style.borderRadius = panelRadius() + "px";
|
||||
showLauncher();
|
||||
notifyLayout();
|
||||
}
|
||||
|
||||
// Сама панель ширину экрана не видит: в iframe её вьюпорт — это окно
|
||||
// виджета. Поэтому про полноэкранный режим ей говорит лоадер.
|
||||
function notifyLayout() {
|
||||
try { frame.contentWindow.postMessage({ type: "chatballs-chat-layout", fullscreen: mobile.matches }, origin); } catch (_) {}
|
||||
}
|
||||
|
||||
// Пока панель на весь экран, кнопка висела бы поверх поля ввода.
|
||||
function showLauncher() {
|
||||
var hidden = open && mobile.matches;
|
||||
btn.style.opacity = hidden ? "0" : "1";
|
||||
btn.style.pointerEvents = hidden ? "none" : "";
|
||||
}
|
||||
|
||||
if (mobile.addEventListener) mobile.addEventListener("change", layoutShell);
|
||||
else if (mobile.addListener) mobile.addListener(layoutShell);
|
||||
|
||||
function setExpanded(next) {
|
||||
expanded = next;
|
||||
if (!shell) return;
|
||||
shell.style.width = next ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
|
||||
shell.style.height = next ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
|
||||
layoutShell();
|
||||
}
|
||||
|
||||
// Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
|
||||
@@ -220,13 +259,13 @@ LOADER_JS = r"""
|
||||
var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
|
||||
frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
|
||||
" scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
|
||||
frame.style.borderRadius = Math.round(mix(24, icon.half, g.fill)) + "px";
|
||||
frame.style.borderRadius = Math.round(mix(panelRadius(), icon.half, g.fill)) + "px";
|
||||
}
|
||||
|
||||
function restPanel() {
|
||||
shell.style.clipPath = "none";
|
||||
frame.style.transform = "none";
|
||||
frame.style.borderRadius = "24px";
|
||||
frame.style.borderRadius = panelRadius() + "px";
|
||||
}
|
||||
|
||||
function playGenie(from, to, duration, panel, icon, done) {
|
||||
@@ -253,7 +292,7 @@ LOADER_JS = r"""
|
||||
bottom: box.bottom,
|
||||
cx: box.left + box.width / 2,
|
||||
half: box.width / 2,
|
||||
radius: 24
|
||||
radius: panelRadius()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -289,8 +328,7 @@ LOADER_JS = r"""
|
||||
if (!open) return;
|
||||
genieSvg.style.display = "none";
|
||||
restPanel();
|
||||
btn.style.opacity = "1";
|
||||
btn.style.pointerEvents = "";
|
||||
showLauncher();
|
||||
});
|
||||
} else {
|
||||
var box = panelRect();
|
||||
@@ -299,8 +337,7 @@ LOADER_JS = r"""
|
||||
genieSvg.style.display = "none";
|
||||
shell.style.display = "none";
|
||||
restPanel();
|
||||
btn.style.opacity = "1";
|
||||
btn.style.pointerEvents = "";
|
||||
showLauncher();
|
||||
});
|
||||
}
|
||||
btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./api";
|
||||
import { BrandFooter } from "./BrandFooter";
|
||||
import { CallInviteBanner, ChatBody, ChatComposer, ChatHeader, StartChatFooter } from "./ChatView";
|
||||
import { usePanelFullscreen } from "./usePanelFullscreen";
|
||||
import { useScrollToLatest } from "./useScrollToLatest";
|
||||
import { useVoiceRecorder } from "./useVoiceRecorder";
|
||||
import { useWidgetActivity } from "./widgetActivity";
|
||||
@@ -65,6 +66,7 @@ export function App() {
|
||||
const pollingReady = useRef(false);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToLatest = useScrollToLatest(bodyRef);
|
||||
const fullscreen = usePanelFullscreen();
|
||||
const incomingCall = Boolean(call && (call.status === "REQUESTED" || call.status === "RINGING"));
|
||||
const notifyNewMessage = useWidgetActivity(incomingCall);
|
||||
const recorder = useVoiceRecorder({
|
||||
@@ -218,7 +220,7 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", width: "100%", background: "#fff", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif", color: "#1f1f1f", overflow: "hidden" }}>
|
||||
<ChatHeader accent={accent} title={title} expanded={expanded} onToggleExpand={toggleExpanded} onClose={closePanel} />
|
||||
<ChatHeader accent={accent} title={title} expanded={expanded} canExpand={!fullscreen} onToggleExpand={toggleExpanded} onClose={closePanel} />
|
||||
<ChatBody bodyRef={bodyRef} config={config} unavailable={unavailable} accepted={accepted} accent={accent} title={title} messages={messages} pending={pending} awaiting={awaiting || thinking} lastContactRequestId={lastContactRequestId} showPhoneForm={showPhoneForm} onSubmitContact={submitContact} audioUrlFor={token ? (id) => voiceAudioUrl(token, id) : undefined} attachmentUrlFor={token ? (id, inline) => attachmentUrl(token, id, inline) : undefined} />
|
||||
{config?.available && accepted && call && (call.status === "REQUESTED" || call.status === "RINGING") && <CallInviteBanner call={call} accent={accent} onAccept={() => void acceptCallInvite()} onDecline={() => void declineCallInvite()} />}
|
||||
{config?.available && !accepted && <StartChatFooter accent={accent} starting={starting} onAccept={() => void accept()} />}
|
||||
|
||||
@@ -26,16 +26,16 @@ const HEADER_BUTTON: React.CSSProperties = {
|
||||
color: "#fff",
|
||||
};
|
||||
|
||||
export function ChatHeader({ accent, title, expanded, onToggleExpand, onClose }: { accent: string; title: string; expanded: boolean; onToggleExpand: () => void; onClose: () => void }) {
|
||||
export function ChatHeader({ accent, title, expanded, canExpand, onToggleExpand, onClose }: { accent: string; title: string; expanded: boolean; canExpand: boolean; onToggleExpand: () => void; onClose: () => void }) {
|
||||
return (
|
||||
<div style={{ flex: "none", background: accent, padding: "14px 16px", display: "flex", alignItems: "center", gap: 11 }}>
|
||||
<span style={{ display: "flex", flex: "none", color: "#fff" }}><BotIcon size={34} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 16, fontWeight: 600, color: "#fff", lineHeight: 1.25, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{title}</div>
|
||||
<button className="wc-icon-button" onClick={onToggleExpand} aria-label={expanded ? t("chat.shrink_panel") : t("chat.expand_panel")} style={HEADER_BUTTON}>
|
||||
{canExpand && <button className="wc-icon-button" onClick={onToggleExpand} aria-label={expanded ? t("chat.shrink_panel") : t("chat.expand_panel")} style={HEADER_BUTTON}>
|
||||
{expanded
|
||||
? <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M9 3v4a2 2 0 0 1-2 2H3" /><path d="M15 3v4a2 2 0 0 0 2 2h4" /><path d="M9 21v-4a2 2 0 0 0-2-2H3" /><path d="M15 21v-4a2 2 0 0 1 2-2h4" /></svg>
|
||||
: <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M16 3h3a2 2 0 0 1 2 2v3" /><path d="M8 21H5a2 2 0 0 1-2-2v-3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" /></svg>}
|
||||
</button>
|
||||
</button>}
|
||||
<button className="wc-icon-button" onClick={onClose} aria-label={t("chat.collapse")} style={HEADER_BUTTON}>
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Открыта ли панель на весь экран.
|
||||
*
|
||||
* Внутри iframe вьюпорт — это само окно виджета, и медиазапрос по ширине не
|
||||
* отличит телефон от обычной панели в 440px. Режим знает только лоадер: он
|
||||
* присылает `chatballs-chat-layout` после каждой раскладки. Первое такое
|
||||
* сообщение может прийти раньше, чем React повесит слушатель, поэтому после
|
||||
* монтирования панель сама спрашивает режим.
|
||||
*/
|
||||
export function usePanelFullscreen(): boolean {
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onParentMessage(event: MessageEvent) {
|
||||
if (event.source !== window.parent) return;
|
||||
const data = event.data as { type?: string; fullscreen?: unknown } | null;
|
||||
if (data?.type !== "chatballs-chat-layout") return;
|
||||
setFullscreen(Boolean(data.fullscreen));
|
||||
}
|
||||
window.addEventListener("message", onParentMessage);
|
||||
window.parent.postMessage({ type: "chatballs-chat-layout-request" }, "*");
|
||||
return () => window.removeEventListener("message", onParentMessage);
|
||||
}, []);
|
||||
|
||||
return fullscreen;
|
||||
}
|
||||
Reference in new issue
Block a user