mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-03 16:07:42 +00:00
999 lines
50 KiB
JavaScript
999 lines
50 KiB
JavaScript
(function initKoalaSyncChatOverlay() {
|
||
if (document.documentElement?.namespaceURI !== 'http://www.w3.org/1999/xhtml') return;
|
||
|
||
if (window.koalaSyncChatOverlay?.refresh) {
|
||
window.koalaSyncChatOverlay.refresh();
|
||
return;
|
||
}
|
||
|
||
const MAX_MESSAGES = 200;
|
||
const MIN_WIDTH = 300;
|
||
const MIN_HEIGHT = 320;
|
||
const DEFAULT_WIDTH = 360;
|
||
const DEFAULT_HEIGHT = 520;
|
||
const LAUNCHER_SIZE = 48;
|
||
const EDGE_INSET = 8;
|
||
const MAX_MESSAGE_CODE_POINTS = 500;
|
||
const MAX_CHAT_CHUNKS = 10;
|
||
const MAX_COMPOSER_CODE_POINTS = MAX_MESSAGE_CODE_POINTS * MAX_CHAT_CHUNKS;
|
||
const PAGE_DOCK_ATTRIBUTE = 'data-koalasync-chat-dock';
|
||
const PAGE_DOCK_WIDTH = '--koalasync-chat-dock-width';
|
||
const QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥']);
|
||
const MAX_REACTION_PARTICLES = 36;
|
||
const MAX_RENDERED_ACTIVITY_IDS = 200;
|
||
const LAUNCHER_ICON_DATA_URL = 'data:image/png;base64,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';
|
||
const SIZE_PRESETS = Object.freeze({
|
||
compact: Object.freeze({ width: 320, height: 400 }),
|
||
standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }),
|
||
large: Object.freeze({ width: 440, height: 640 })
|
||
});
|
||
const storageKey = `chatOverlayLayout:${location.origin}`;
|
||
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
|
||
let context = null;
|
||
let opened = false;
|
||
let destroyed = false;
|
||
let saveTimer = null;
|
||
let applyingLayout = false;
|
||
let preferencesLoaded = false;
|
||
let startStateApplied = false;
|
||
let refreshGeneration = 0;
|
||
let sendGeneration = 0;
|
||
let sending = false;
|
||
let unreadCount = 0;
|
||
let layout = {
|
||
mode: 'right',
|
||
x: 24,
|
||
y: 72,
|
||
width: DEFAULT_WIDTH,
|
||
height: DEFAULT_HEIGHT,
|
||
customWidth: DEFAULT_WIDTH,
|
||
customHeight: DEFAULT_HEIGHT,
|
||
detachedInitialized: false,
|
||
launcherX: null,
|
||
launcherY: null,
|
||
launcherInitialized: false
|
||
};
|
||
let chatPosition = 'right';
|
||
let chatSize = 'standard';
|
||
let chatStartMode = 'bubble';
|
||
let chatReactionDisplay = 'chat';
|
||
let themeMode = 'system';
|
||
let themePalette = 'eucalyptus';
|
||
let pageDockTarget = null;
|
||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||
const renderedActivityIds = new Set();
|
||
const renderedActivityIdOrder = [];
|
||
|
||
const host = document.createElement('div');
|
||
host.id = 'koalasync-chat-overlay-host';
|
||
host.style.cssText = 'all:initial;display:none;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);';
|
||
const shadow = host.attachShadow({ mode: 'open' });
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
:host { all: initial; }
|
||
* { box-sizing: border-box; }
|
||
#app {
|
||
--bg: oklch(0.205 0.025 155); --card: oklch(0.30 0.035 150);
|
||
--surface-alt: oklch(0.265 0.032 150); --surface-deep: oklch(0.205 0.025 155);
|
||
--accent: oklch(0.73 0.13 155); --accent-hover: oklch(0.79 0.12 155);
|
||
--text: oklch(0.96 0.01 90); --text-muted: oklch(0.78 0.02 90);
|
||
--border-soft: oklch(0.88 0.025 145 / 0.09); --border-strong: oklch(0.39 0.038 145);
|
||
--text-on-green: oklch(0.14 0.02 90); --danger: oklch(0.63 0.18 25);
|
||
position: fixed; inset: 0; pointer-events: none; color: var(--text);
|
||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
}
|
||
#app[data-theme="light"] {
|
||
--bg: oklch(0.935 0.018 110); --card: oklch(0.995 0.006 100);
|
||
--surface-alt: oklch(0.91 0.022 115); --surface-deep: oklch(0.935 0.018 110);
|
||
--accent: oklch(0.52 0.13 155); --accent-hover: oklch(0.45 0.12 155);
|
||
--text: oklch(0.21 0.03 140); --text-muted: oklch(0.43 0.03 135);
|
||
--border-soft: oklch(0.28 0.035 140 / 0.10); --border-strong: oklch(0.73 0.035 125);
|
||
--text-on-green: oklch(0.16 0.03 140);
|
||
}
|
||
#app[data-palette="cyber"] {
|
||
--bg: oklch(0.185 0.04 265); --card: oklch(0.285 0.05 265);
|
||
--surface-alt: oklch(0.25 0.048 265); --surface-deep: oklch(0.185 0.04 265);
|
||
--accent: oklch(0.59 0.20 275); --accent-hover: oklch(0.69 0.17 275);
|
||
--text: oklch(0.97 0.012 265); --text-muted: oklch(0.74 0.03 265);
|
||
--border-soft: oklch(0.9 0.03 265 / 0.10); --border-strong: oklch(0.42 0.05 265);
|
||
--text-on-green: oklch(0.99 0.006 275);
|
||
}
|
||
#app[data-palette="cyber"][data-theme="light"] {
|
||
--bg: oklch(0.945 0.014 265); --card: oklch(0.995 0.005 265);
|
||
--surface-alt: oklch(0.915 0.02 265); --surface-deep: oklch(0.945 0.014 265);
|
||
--accent: oklch(0.53 0.21 275); --accent-hover: oklch(0.46 0.20 275);
|
||
--text: oklch(0.26 0.05 265); --text-muted: oklch(0.48 0.045 265);
|
||
--border-soft: oklch(0.30 0.05 265 / 0.12); --border-strong: oklch(0.79 0.03 265);
|
||
--text-on-green: oklch(0.99 0.005 275);
|
||
}
|
||
#app[data-palette="graphite"] {
|
||
--bg: oklch(0.165 0.004 260); --card: oklch(0.255 0.005 260);
|
||
--surface-alt: oklch(0.225 0.005 260); --surface-deep: oklch(0.165 0.004 260);
|
||
--accent: oklch(0.93 0.006 260); --accent-hover: oklch(0.99 0.003 260);
|
||
--text: oklch(0.97 0.003 260); --text-muted: oklch(0.72 0.005 260);
|
||
--border-soft: oklch(0.92 0.008 260 / 0.11); --border-strong: oklch(0.42 0.006 260);
|
||
--text-on-green: oklch(0.20 0.004 260);
|
||
}
|
||
#app[data-palette="graphite"][data-theme="light"] {
|
||
--bg: oklch(0.928 0.003 260); --card: oklch(1 0 0);
|
||
--surface-alt: oklch(0.90 0.004 260); --surface-deep: oklch(0.928 0.003 260);
|
||
--accent: oklch(0.28 0.006 260); --accent-hover: oklch(0.18 0.005 260);
|
||
--text: oklch(0.22 0.005 260); --text-muted: oklch(0.46 0.006 260);
|
||
--border-soft: oklch(0.20 0.006 260 / 0.13); --border-strong: oklch(0.77 0.004 260);
|
||
--text-on-green: oklch(0.98 0.002 260);
|
||
}
|
||
button, textarea { font: inherit; }
|
||
button { color: inherit; }
|
||
.launcher {
|
||
position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong);
|
||
border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer;
|
||
pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32);
|
||
touch-action: none; user-select: none;
|
||
}
|
||
.launcher.docked-left { left: 0 !important; right: auto !important; border-left: 0; border-radius: 0 16px 16px 0; }
|
||
.launcher.docked-right { left: auto !important; right: 0 !important; border-right: 0; border-radius: 16px 0 0 16px; }
|
||
.launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); }
|
||
.launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
|
||
.launcher-icon { display: block; width: 36px; height: 36px; margin: auto; pointer-events: none; }
|
||
.launcher-bubble-mark {
|
||
position: absolute; right: 3px; bottom: 3px; width: 15px; height: 12px;
|
||
border: 2px solid var(--card); border-radius: 7px; background: var(--accent); pointer-events: none;
|
||
}
|
||
.launcher-bubble-mark::after {
|
||
content: ""; position: absolute; right: 0; bottom: -4px;
|
||
width: 5px; height: 5px; background: var(--accent); clip-path: polygon(0 0, 100% 0, 100% 100%);
|
||
}
|
||
.unread {
|
||
position: absolute; top: -7px; right: -7px; display: none; min-width: 22px; height: 22px;
|
||
align-items: center; justify-content: center; padding: 0 6px; border: 2px solid var(--card);
|
||
border-radius: 999px; background: var(--danger); color: white; font-size: 11px; font-weight: 800;
|
||
line-height: 1; pointer-events: none;
|
||
}
|
||
.unread.visible { display: flex; }
|
||
.panel {
|
||
position: fixed; display: none; flex-direction: column; overflow: hidden;
|
||
min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px);
|
||
max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text);
|
||
background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px;
|
||
box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0);
|
||
}
|
||
.panel.open { display: flex; }
|
||
.panel.docked { border-radius: 0; max-height: 100vh; }
|
||
.header { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border-soft); user-select: none; }
|
||
.header.detached { cursor: move; }
|
||
.heading { min-width: 0; flex: 1; }
|
||
.title { font-size: 14px; font-weight: 760; }
|
||
.subtitle { color: var(--text-muted); font-size: 11px; }
|
||
.modes { display: flex; gap: 4px; }
|
||
.icon-button { width: 30px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--surface-alt); cursor: pointer; }
|
||
.icon-button:hover, .icon-button.active { border-color: var(--accent); color: var(--accent); }
|
||
.messages { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; background: var(--bg); overscroll-behavior: contain; }
|
||
.empty { color: var(--text-muted); text-align: center; padding: 32px 12px; font-size: 12px; }
|
||
.message { margin-bottom: 11px; overflow-wrap: anywhere; }
|
||
.meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; }
|
||
.sender { color: var(--accent); font-weight: 700; font-size: 12px; }
|
||
.time { color: var(--text-muted); font-size: 10px; }
|
||
.body { white-space: pre-wrap; color: var(--text); }
|
||
.event-message {
|
||
display: flex; align-items: center; justify-content: center; gap: 7px; margin: 8px 0;
|
||
color: var(--text-muted); font-size: 11px; text-align: center;
|
||
}
|
||
.event-message::before, .event-message::after { content: ""; flex: 1; height: 1px; background: var(--border-soft); }
|
||
.event-text { max-width: 78%; }
|
||
.composer { padding: 10px; border-top: 1px solid var(--border-soft); background: var(--card); }
|
||
.quick-reactions { display: flex; align-items: center; gap: 5px; margin-bottom: 8px; }
|
||
.reaction-button {
|
||
width: 34px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px;
|
||
background: var(--surface-alt); cursor: pointer; font-size: 18px; line-height: 1;
|
||
}
|
||
.reaction-button:hover:not(:disabled), .reaction-button:focus-visible { border-color: var(--accent); transform: translateY(-1px); }
|
||
.reaction-button:disabled { opacity: .5; cursor: wait; }
|
||
textarea { width: 100%; min-height: 62px; max-height: 132px; resize: vertical; border: 1px solid var(--border-strong); border-radius: 11px; padding: 9px; background: var(--surface-deep); color: var(--text); outline: none; }
|
||
textarea:focus { border-color: var(--accent); }
|
||
textarea::placeholder { color: var(--text-muted); }
|
||
.composer-row { display: flex; align-items: center; gap: 8px; margin-top: 7px; }
|
||
.count { flex: 1; color: var(--text-muted); font-size: 10px; }
|
||
.status { color: var(--danger); font-size: 10px; min-height: 14px; }
|
||
.send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; }
|
||
.send:hover:not(:disabled) { background: var(--accent-hover); }
|
||
.send:disabled { opacity: .55; cursor: wait; }
|
||
.reaction-layer { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 1; }
|
||
.reaction-particle {
|
||
position: absolute; top: -12vh; font-size: clamp(24px, 4vw, 48px);
|
||
filter: drop-shadow(0 4px 7px rgb(0 0 0 / .35));
|
||
animation: reaction-fall var(--reaction-duration) cubic-bezier(.22,.62,.45,1) var(--reaction-delay) forwards;
|
||
}
|
||
.launcher, .panel { z-index: 2; }
|
||
@keyframes reaction-fall {
|
||
0% { transform: translate3d(0, -8vh, 0) rotate(var(--reaction-start-rotation)); opacity: 0; }
|
||
12% { opacity: 1; }
|
||
100% { transform: translate3d(var(--reaction-drift), 116vh, 0) rotate(var(--reaction-end-rotation)); opacity: .92; }
|
||
}
|
||
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||
@media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
* { transition: none !important; }
|
||
.reaction-particle { animation: none !important; display: none !important; }
|
||
}
|
||
`;
|
||
|
||
function element(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text !== undefined) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
const app = element('div');
|
||
app.id = 'app';
|
||
const launcher = element('button', 'launcher');
|
||
launcher.type = 'button';
|
||
const launcherIcon = element('img', 'launcher-icon');
|
||
launcherIcon.src = LAUNCHER_ICON_DATA_URL;
|
||
launcherIcon.alt = '';
|
||
const launcherBubbleMark = element('span', 'launcher-bubble-mark');
|
||
launcherBubbleMark.setAttribute('aria-hidden', 'true');
|
||
const unreadBadge = element('span', 'unread');
|
||
unreadBadge.setAttribute('aria-hidden', 'true');
|
||
launcher.append(launcherIcon, launcherBubbleMark, unreadBadge);
|
||
const launcherHint = element('span', 'visually-hidden');
|
||
launcherHint.id = 'chat-launcher-hint';
|
||
const panel = element('section', 'panel');
|
||
panel.setAttribute('role', 'dialog');
|
||
const header = element('header', 'header');
|
||
const heading = element('div', 'heading');
|
||
const title = element('div', 'title');
|
||
const subtitle = element('div', 'subtitle');
|
||
heading.append(title, subtitle);
|
||
const modes = element('div', 'modes');
|
||
const leftButton = element('button', 'icon-button', '←');
|
||
const detachedButton = element('button', 'icon-button', '✥');
|
||
const rightButton = element('button', 'icon-button', '→');
|
||
const closeButton = element('button', 'icon-button', '×');
|
||
for (const button of [leftButton, detachedButton, rightButton, closeButton]) button.type = 'button';
|
||
modes.append(leftButton, detachedButton, rightButton, closeButton);
|
||
header.append(heading, modes);
|
||
const messages = element('div', 'messages');
|
||
messages.setAttribute('aria-live', 'polite');
|
||
const empty = element('div', 'empty');
|
||
messages.append(empty);
|
||
const composer = element('form', 'composer');
|
||
const quickReactions = element('div', 'quick-reactions');
|
||
quickReactions.setAttribute('role', 'group');
|
||
const reactionButtons = QUICK_REACTIONS.map(reaction => {
|
||
const button = element('button', 'reaction-button', reaction);
|
||
button.type = 'button';
|
||
button.dataset.reaction = reaction;
|
||
quickReactions.append(button);
|
||
return button;
|
||
});
|
||
const textareaLabel = element('label', 'visually-hidden');
|
||
textareaLabel.htmlFor = 'chat-composer-input';
|
||
const textarea = element('textarea');
|
||
textarea.id = 'chat-composer-input';
|
||
textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status');
|
||
const composerRow = element('div', 'composer-row');
|
||
const count = element('span', 'count', `0/${MAX_COMPOSER_CODE_POINTS}`);
|
||
count.id = 'chat-composer-count';
|
||
const sendButton = element('button', 'send');
|
||
sendButton.type = 'submit';
|
||
composerRow.append(count, sendButton);
|
||
const status = element('div', 'status');
|
||
status.id = 'chat-composer-status';
|
||
status.setAttribute('role', 'status');
|
||
composer.append(quickReactions, textareaLabel, textarea, composerRow, status);
|
||
panel.append(header, messages, composer);
|
||
const reactionLayer = element('div', 'reaction-layer');
|
||
reactionLayer.setAttribute('aria-hidden', 'true');
|
||
app.append(reactionLayer, launcherHint, launcher, panel);
|
||
shadow.append(style, app);
|
||
document.documentElement.append(host);
|
||
const pageDockStyle = document.createElement('style');
|
||
pageDockStyle.id = 'koalasync-chat-page-dock-style';
|
||
pageDockStyle.textContent = `
|
||
html[${PAGE_DOCK_ATTRIBUTE}] {
|
||
box-sizing: border-box !important;
|
||
width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
|
||
max-width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
|
||
overflow-x: clip !important;
|
||
}
|
||
html[${PAGE_DOCK_ATTRIBUTE}="left"] {
|
||
margin-left: var(${PAGE_DOCK_WIDTH}) !important;
|
||
margin-right: 0 !important;
|
||
}
|
||
html[${PAGE_DOCK_ATTRIBUTE}="right"] {
|
||
margin-left: 0 !important;
|
||
margin-right: var(${PAGE_DOCK_WIDTH}) !important;
|
||
}
|
||
html[${PAGE_DOCK_ATTRIBUTE}] > body {
|
||
width: 100% !important;
|
||
max-width: 100% !important;
|
||
min-height: 100vh !important;
|
||
overflow-x: clip !important;
|
||
clip-path: inset(0) !important;
|
||
contain: layout paint !important;
|
||
}
|
||
[${PAGE_DOCK_ATTRIBUTE}]:not(html) > :not(#koalasync-chat-overlay-host) {
|
||
box-sizing: border-box !important;
|
||
width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
|
||
max-width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
|
||
overflow-x: clip !important;
|
||
clip-path: inset(0) !important;
|
||
contain: layout paint !important;
|
||
}
|
||
[${PAGE_DOCK_ATTRIBUTE}="left"]:not(html) > :not(#koalasync-chat-overlay-host) {
|
||
margin-left: var(${PAGE_DOCK_WIDTH}) !important;
|
||
margin-right: 0 !important;
|
||
}
|
||
[${PAGE_DOCK_ATTRIBUTE}="right"]:not(html) > :not(#koalasync-chat-overlay-host) {
|
||
margin-left: 0 !important;
|
||
margin-right: var(${PAGE_DOCK_WIDTH}) !important;
|
||
}
|
||
`;
|
||
document.documentElement.append(pageDockStyle);
|
||
|
||
function messageRuntime(payload, timeoutMs = 5000) {
|
||
return new Promise(resolve => {
|
||
let settled = false;
|
||
const finish = response => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timeout);
|
||
resolve(response || null);
|
||
};
|
||
const timeout = setTimeout(() => finish(null), timeoutMs);
|
||
try {
|
||
chrome.runtime.sendMessage(payload, response => {
|
||
if (chrome.runtime.lastError) finish(null);
|
||
else finish(response);
|
||
});
|
||
} catch (_) {
|
||
finish(null);
|
||
}
|
||
});
|
||
}
|
||
|
||
function setLocalStorage(values) {
|
||
if (destroyed) return;
|
||
try {
|
||
const result = chrome.storage.local.set(values);
|
||
if (result?.catch) result.catch(() => {});
|
||
} catch (_) {
|
||
// Extension context was invalidated while the page remained open.
|
||
}
|
||
}
|
||
|
||
function strings() {
|
||
return context?.strings || {};
|
||
}
|
||
|
||
function applyStrings() {
|
||
const text = strings();
|
||
title.textContent = text.title || '';
|
||
subtitle.textContent = text.liveOnly || '';
|
||
launcher.setAttribute('aria-label', text.open || '');
|
||
const unavailableHint = context?.supported && !context?.hasKey
|
||
? (text.missingKey || '')
|
||
: context?.supported && !context?.connected ? (text.sendFailed || '') : '';
|
||
launcher.title = unavailableHint || text.open || '';
|
||
launcherHint.textContent = unavailableHint;
|
||
if (unavailableHint) launcher.setAttribute('aria-describedby', launcherHint.id);
|
||
else launcher.removeAttribute('aria-describedby');
|
||
panel.setAttribute('aria-label', text.title || '');
|
||
textarea.placeholder = text.placeholder || '';
|
||
textareaLabel.textContent = text.placeholder || text.title || '';
|
||
sendButton.textContent = text.send || '';
|
||
empty.textContent = text.empty || '';
|
||
leftButton.title = text.dockLeft || '';
|
||
detachedButton.title = text.detached || '';
|
||
rightButton.title = text.dockRight || '';
|
||
closeButton.title = text.close || '';
|
||
leftButton.setAttribute('aria-label', text.dockLeft || '');
|
||
detachedButton.setAttribute('aria-label', text.detached || '');
|
||
rightButton.setAttribute('aria-label', text.dockRight || '');
|
||
closeButton.setAttribute('aria-label', text.close || '');
|
||
quickReactions.setAttribute('aria-label', text.quickReactions || text.title || '');
|
||
for (const button of reactionButtons) {
|
||
const label = `${text.quickReactions || text.title || ''} ${button.dataset.reaction}`.trim();
|
||
button.title = label;
|
||
button.setAttribute('aria-label', label);
|
||
}
|
||
applyUnreadCount();
|
||
}
|
||
|
||
function applyTheme() {
|
||
const light = themeMode === 'light' || (themeMode === 'system' && systemTheme.matches);
|
||
app.dataset.theme = light ? 'light' : 'dark';
|
||
app.dataset.palette = ['eucalyptus', 'cyber', 'graphite'].includes(themePalette) ? themePalette : 'eucalyptus';
|
||
}
|
||
|
||
function viewportBounds() {
|
||
return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) };
|
||
}
|
||
|
||
function normalizePosition(value) {
|
||
return ['left', 'right', 'detached'].includes(value) ? value : 'right';
|
||
}
|
||
|
||
function normalizeSize(value) {
|
||
return ['compact', 'standard', 'large', 'custom'].includes(value) ? value : 'standard';
|
||
}
|
||
|
||
function preferredSize() {
|
||
return SIZE_PRESETS[chatSize] || {
|
||
width: Number(layout.customWidth) || DEFAULT_WIDTH,
|
||
height: Number(layout.customHeight) || DEFAULT_HEIGHT
|
||
};
|
||
}
|
||
|
||
function clampLauncher() {
|
||
const viewport = viewportBounds();
|
||
const maxX = Math.max(0, viewport.width - LAUNCHER_SIZE - EDGE_INSET);
|
||
const maxY = Math.max(0, viewport.height - LAUNCHER_SIZE - EDGE_INSET);
|
||
layout.launcherX = Math.min(Math.max(Number(layout.launcherX) || EDGE_INSET, EDGE_INSET), maxX);
|
||
layout.launcherY = Math.min(Math.max(Number(layout.launcherY) || EDGE_INSET, EDGE_INSET), maxY);
|
||
}
|
||
|
||
function initializeLauncher() {
|
||
if (layout.launcherInitialized) return;
|
||
const viewport = viewportBounds();
|
||
layout.launcherX = chatPosition === 'left'
|
||
? Math.min(16, Math.max(EDGE_INSET, viewport.width - LAUNCHER_SIZE - EDGE_INSET))
|
||
: Math.max(EDGE_INSET, viewport.width - LAUNCHER_SIZE - 16);
|
||
layout.launcherY = Math.max(EDGE_INSET, Math.round(viewport.height / 2 - LAUNCHER_SIZE / 2));
|
||
layout.launcherInitialized = true;
|
||
clampLauncher();
|
||
}
|
||
|
||
function clearPageDock() {
|
||
const targets = new Set([pageDockTarget, document.documentElement].filter(Boolean));
|
||
for (const target of targets) {
|
||
target.removeAttribute(PAGE_DOCK_ATTRIBUTE);
|
||
target.style.removeProperty(PAGE_DOCK_WIDTH);
|
||
}
|
||
pageDockTarget = null;
|
||
}
|
||
|
||
function applyPageDock(side, width) {
|
||
if (!opened) {
|
||
clearPageDock();
|
||
return;
|
||
}
|
||
const target = document.fullscreenElement || document.documentElement;
|
||
if (pageDockTarget && pageDockTarget !== target) clearPageDock();
|
||
pageDockTarget = target;
|
||
target.setAttribute(PAGE_DOCK_ATTRIBUTE, side);
|
||
target.style.setProperty(PAGE_DOCK_WIDTH, `${width}px`);
|
||
}
|
||
|
||
function applyUnreadCount() {
|
||
unreadBadge.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
|
||
unreadBadge.classList.toggle('visible', unreadCount > 0);
|
||
const openLabel = strings().open || '';
|
||
launcher.setAttribute('aria-label', unreadCount > 0 ? `${openLabel} (${unreadCount})` : openLabel);
|
||
}
|
||
|
||
function setUnreadCount(next) {
|
||
unreadCount = Math.max(0, Number.isFinite(next) ? Math.trunc(next) : 0);
|
||
applyUnreadCount();
|
||
}
|
||
|
||
function clampDetached() {
|
||
const viewport = viewportBounds();
|
||
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
|
||
const maxHeight = Math.max(1, viewport.height - 16);
|
||
const horizontalInset = Math.min(8, Math.max(0, Math.floor((viewport.width - 1) / 2)));
|
||
const verticalInset = Math.min(8, Math.max(0, Math.floor((viewport.height - 1) / 2)));
|
||
layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, Math.min(MIN_WIDTH, maxWidth)), maxWidth);
|
||
layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, Math.min(MIN_HEIGHT, maxHeight)), maxHeight);
|
||
layout.x = Math.min(Math.max(Number(layout.x) || horizontalInset, horizontalInset), Math.max(horizontalInset, viewport.width - layout.width - horizontalInset));
|
||
layout.y = Math.min(Math.max(Number(layout.y) || verticalInset, verticalInset), Math.max(verticalInset, viewport.height - layout.height - verticalInset));
|
||
}
|
||
|
||
function saveLayout() {
|
||
if (saveTimer) clearTimeout(saveTimer);
|
||
saveTimer = setTimeout(() => {
|
||
saveTimer = null;
|
||
setLocalStorage({ [storageKey]: layout });
|
||
}, 150);
|
||
}
|
||
|
||
function applyLayout() {
|
||
if (destroyed) return;
|
||
applyingLayout = true;
|
||
const size = preferredSize();
|
||
launcher.classList.toggle('docked-left', layout.mode === 'left');
|
||
launcher.classList.toggle('docked-right', layout.mode === 'right');
|
||
if (layout.mode === 'detached') {
|
||
initializeLauncher();
|
||
clampLauncher();
|
||
launcher.style.left = `${layout.launcherX}px`;
|
||
launcher.style.right = 'auto';
|
||
launcher.style.top = `${layout.launcherY}px`;
|
||
} else {
|
||
launcher.style.left = layout.mode === 'left' ? '0' : 'auto';
|
||
launcher.style.right = layout.mode === 'right' ? '0' : 'auto';
|
||
launcher.style.top = `${Math.max(0, Math.round(window.innerHeight / 2 - LAUNCHER_SIZE / 2))}px`;
|
||
}
|
||
panel.style.right = 'auto';
|
||
panel.style.left = 'auto';
|
||
panel.style.bottom = 'auto';
|
||
panel.style.resize = 'none';
|
||
panel.classList.toggle('docked', layout.mode !== 'detached');
|
||
header.classList.toggle('detached', layout.mode === 'detached');
|
||
leftButton.classList.toggle('active', layout.mode === 'left');
|
||
detachedButton.classList.toggle('active', layout.mode === 'detached');
|
||
rightButton.classList.toggle('active', layout.mode === 'right');
|
||
if (layout.mode === 'detached') {
|
||
clearPageDock();
|
||
clampDetached();
|
||
panel.style.left = `${layout.x}px`;
|
||
panel.style.top = `${layout.y}px`;
|
||
panel.style.width = `${layout.width}px`;
|
||
panel.style.height = `${layout.height}px`;
|
||
panel.style.resize = 'both';
|
||
} else {
|
||
const viewport = viewportBounds();
|
||
const dockWidth = Math.max(1, Math.min(size.width, viewport.width - 16));
|
||
panel.style.top = '0';
|
||
panel.style.bottom = '0';
|
||
panel.style.width = `${dockWidth}px`;
|
||
panel.style.height = `${viewport.height}px`;
|
||
panel.style[layout.mode] = '0';
|
||
applyPageDock(layout.mode, dockWidth);
|
||
}
|
||
globalThis.queueMicrotask(() => { applyingLayout = false; });
|
||
}
|
||
|
||
function setMode(mode, persistPreference = true) {
|
||
mode = normalizePosition(mode);
|
||
if (mode === 'detached' && !layout.detachedInitialized) {
|
||
const size = preferredSize();
|
||
layout.x = Math.max(8, Math.round((window.innerWidth - size.width) / 2));
|
||
layout.y = Math.max(8, Math.round((window.innerHeight - size.height) / 2));
|
||
layout.detachedInitialized = true;
|
||
}
|
||
chatPosition = mode;
|
||
layout.mode = mode;
|
||
applyLayout();
|
||
saveLayout();
|
||
if (persistPreference) setLocalStorage({ chatPosition: mode });
|
||
}
|
||
|
||
function setSize(size, persistPreference = true) {
|
||
chatSize = normalizeSize(size);
|
||
const preset = SIZE_PRESETS[chatSize];
|
||
if (preset) {
|
||
layout.width = preset.width;
|
||
layout.height = preset.height;
|
||
} else {
|
||
layout.width = Number(layout.customWidth) || DEFAULT_WIDTH;
|
||
layout.height = Number(layout.customHeight) || DEFAULT_HEIGHT;
|
||
}
|
||
if (layout.mode === 'detached') clampDetached();
|
||
applyLayout();
|
||
saveLayout();
|
||
if (persistPreference) setLocalStorage({ chatSize });
|
||
}
|
||
|
||
function setOpened(next) {
|
||
opened = !!next && !!context?.enabled;
|
||
panel.classList.toggle('open', opened);
|
||
launcher.style.display = opened ? 'none' : '';
|
||
if (opened) {
|
||
setUnreadCount(0);
|
||
textarea.focus();
|
||
messages.scrollTop = messages.scrollHeight;
|
||
}
|
||
applyLayout();
|
||
}
|
||
|
||
function applyContext(next) {
|
||
const previousRoomId = context?.roomId;
|
||
context = next || null;
|
||
const supported = !!context?.supported;
|
||
const optedIn = !!context?.enabled;
|
||
const hasKey = !!context?.hasKey;
|
||
const connected = !!context?.connected;
|
||
context = context ? { ...context, enabled: supported && optedIn && hasKey && connected } : null;
|
||
if (previousRoomId && previousRoomId !== context?.roomId) {
|
||
clearMessages();
|
||
startStateApplied = false;
|
||
}
|
||
host.style.display = supported && optedIn ? '' : 'none';
|
||
launcher.setAttribute('aria-disabled', String(!context?.enabled));
|
||
if (!optedIn) startStateApplied = false;
|
||
if (!context?.enabled) {
|
||
setOpened(false);
|
||
} else if (preferencesLoaded && !startStateApplied) {
|
||
startStateApplied = true;
|
||
setOpened(chatStartMode === 'open');
|
||
}
|
||
applyStrings();
|
||
applyLayout();
|
||
if (context?.enabled && Array.isArray(context.activity)) {
|
||
for (const event of context.activity) appendEvent(event);
|
||
}
|
||
}
|
||
|
||
async function refresh() {
|
||
if (destroyed) return;
|
||
const generation = ++refreshGeneration;
|
||
const next = await messageRuntime({ type: 'GET_CHAT_CONTEXT' });
|
||
if (destroyed || generation !== refreshGeneration) return;
|
||
applyContext(next);
|
||
}
|
||
|
||
function appendMessage(message) {
|
||
if (!context?.enabled || !message || typeof message.text !== 'string') return;
|
||
if (empty.isConnected) empty.remove();
|
||
const wrapper = element('article', 'message');
|
||
const meta = element('div', 'meta');
|
||
const own = message.senderId === context.peerId;
|
||
if (!opened && !own) setUnreadCount(unreadCount + 1);
|
||
const sender = element('span', 'sender', own ? (strings().you || '') : (message.username || message.senderId || ''));
|
||
const time = element('time', 'time');
|
||
const date = new Date(message.timestamp);
|
||
time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||
meta.append(sender, time);
|
||
const body = element('div', 'body');
|
||
const tokens = globalThis.KoalaSyncChatFormat?.tokenizeChatText(message.text) || [{ type: 'text', text: message.text }];
|
||
for (const token of tokens) {
|
||
const node = token.type === 'strong' ? document.createElement('strong') : token.type === 'em' ? document.createElement('em') : document.createTextNode(token.text);
|
||
if (node.nodeType === 1) node.textContent = token.text;
|
||
body.append(node);
|
||
}
|
||
wrapper.append(meta, body);
|
||
messages.append(wrapper);
|
||
while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove();
|
||
messages.scrollTop = messages.scrollHeight;
|
||
if (QUICK_REACTIONS.includes(message.text.trim())) triggerReactionRain(message.text.trim());
|
||
}
|
||
|
||
function appendEvent(event) {
|
||
if (!context?.enabled || !event || typeof event.action !== 'string') return;
|
||
if (typeof event.id === 'string' && renderedActivityIds.has(event.id)) return;
|
||
const own = event.senderId === context.peerId;
|
||
const displayName = own ? (strings().you || '') : (event.username || event.senderId || '');
|
||
const labels = {
|
||
play: strings().eventPlay,
|
||
pause: strings().eventPause,
|
||
seek: strings().eventSeek,
|
||
force_sync_prepare: strings().eventForcePrepare,
|
||
force_sync_execute: strings().eventForceExecute
|
||
};
|
||
let eventText = '';
|
||
if (event.action === 'joined' || event.action === 'left') {
|
||
const template = event.action === 'joined' ? strings().eventJoined : strings().eventLeft;
|
||
eventText = (template || '{name}').replace('{name}', displayName);
|
||
} else if (labels[event.action]) {
|
||
eventText = (strings().eventAction || '{name} {action}')
|
||
.replace('{name}', displayName)
|
||
.replace('{action}', labels[event.action]);
|
||
}
|
||
if (!eventText) return;
|
||
if (typeof event.id === 'string') {
|
||
renderedActivityIds.add(event.id);
|
||
renderedActivityIdOrder.push(event.id);
|
||
while (renderedActivityIdOrder.length > MAX_RENDERED_ACTIVITY_IDS) {
|
||
renderedActivityIds.delete(renderedActivityIdOrder.shift());
|
||
}
|
||
}
|
||
|
||
if (empty.isConnected) empty.remove();
|
||
if (!opened && context.eventNotifications) setUnreadCount(unreadCount + 1);
|
||
const wrapper = element('article', 'message event-message');
|
||
const body = element('span', 'event-text', eventText);
|
||
const time = element('time', 'time');
|
||
const date = new Date(event.timestamp);
|
||
time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||
wrapper.append(body, time);
|
||
messages.append(wrapper);
|
||
while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove();
|
||
messages.scrollTop = messages.scrollHeight;
|
||
}
|
||
|
||
function clearMessages() {
|
||
messages.replaceChildren(empty);
|
||
renderedActivityIds.clear();
|
||
renderedActivityIdOrder.length = 0;
|
||
setUnreadCount(0);
|
||
}
|
||
|
||
function triggerReactionRain(reaction) {
|
||
if (chatReactionDisplay !== 'video' || reducedMotion.matches || !QUICK_REACTIONS.includes(reaction)) return;
|
||
const available = Math.max(0, MAX_REACTION_PARTICLES - reactionLayer.childElementCount);
|
||
const particleCount = Math.min(10, available);
|
||
for (let index = 0; index < particleCount; index++) {
|
||
const particle = element('span', 'reaction-particle', reaction);
|
||
particle.style.left = `${4 + Math.random() * 88}%`;
|
||
particle.style.setProperty('--reaction-duration', `${2.8 + Math.random() * 1.6}s`);
|
||
particle.style.setProperty('--reaction-delay', `${Math.random() * .7}s`);
|
||
particle.style.setProperty('--reaction-drift', `${-45 + Math.random() * 90}px`);
|
||
particle.style.setProperty('--reaction-start-rotation', `${-35 + Math.random() * 70}deg`);
|
||
particle.style.setProperty('--reaction-end-rotation', `${-160 + Math.random() * 320}deg`);
|
||
particle.addEventListener('animationend', () => particle.remove(), { once: true });
|
||
reactionLayer.append(particle);
|
||
}
|
||
}
|
||
|
||
function updateComposerState() {
|
||
const length = [...textarea.value].length;
|
||
count.textContent = `${length}/${MAX_COMPOSER_CODE_POINTS}`;
|
||
count.style.color = length > MAX_COMPOSER_CODE_POINTS ? 'var(--danger)' : '';
|
||
status.textContent = length > MAX_COMPOSER_CODE_POINTS ? (strings().tooLong || '') : '';
|
||
return length;
|
||
}
|
||
|
||
function resetComposer() {
|
||
sendGeneration++;
|
||
sending = false;
|
||
textarea.value = '';
|
||
updateComposerState();
|
||
status.textContent = '';
|
||
sendButton.disabled = false;
|
||
for (const button of reactionButtons) button.disabled = false;
|
||
}
|
||
|
||
let launcherDrag = null;
|
||
let suppressLauncherClick = false;
|
||
launcher.addEventListener('click', event => {
|
||
if (suppressLauncherClick && event.detail !== 0) {
|
||
suppressLauncherClick = false;
|
||
return;
|
||
}
|
||
suppressLauncherClick = false;
|
||
setOpened(true);
|
||
});
|
||
launcher.addEventListener('pointerdown', event => {
|
||
if (layout.mode !== 'detached') return;
|
||
launcherDrag = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
x: layout.launcherX,
|
||
y: layout.launcherY,
|
||
moved: false
|
||
};
|
||
launcher.setPointerCapture(event.pointerId);
|
||
});
|
||
launcher.addEventListener('pointermove', event => {
|
||
if (!launcherDrag || event.pointerId !== launcherDrag.pointerId) return;
|
||
const deltaX = event.clientX - launcherDrag.startX;
|
||
const deltaY = event.clientY - launcherDrag.startY;
|
||
if (Math.hypot(deltaX, deltaY) >= 4) launcherDrag.moved = true;
|
||
layout.launcherX = launcherDrag.x + deltaX;
|
||
layout.launcherY = launcherDrag.y + deltaY;
|
||
clampLauncher();
|
||
launcher.style.left = `${layout.launcherX}px`;
|
||
launcher.style.top = `${layout.launcherY}px`;
|
||
});
|
||
launcher.addEventListener('pointerup', event => {
|
||
if (!launcherDrag || event.pointerId !== launcherDrag.pointerId) return;
|
||
suppressLauncherClick = launcherDrag.moved;
|
||
launcherDrag = null;
|
||
saveLayout();
|
||
});
|
||
launcher.addEventListener('pointercancel', () => {
|
||
launcherDrag = null;
|
||
suppressLauncherClick = false;
|
||
});
|
||
closeButton.addEventListener('click', () => setOpened(false));
|
||
leftButton.addEventListener('click', () => setMode('left'));
|
||
detachedButton.addEventListener('click', () => setMode('detached'));
|
||
rightButton.addEventListener('click', () => setMode('right'));
|
||
|
||
textarea.addEventListener('input', updateComposerState);
|
||
const stopPageKeyboardShortcut = event => {
|
||
const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
|
||
if (!path.includes(textarea) && !path.includes(composer)) return;
|
||
if (event.type === 'keydown' && event.key === 'Enter' && !event.shiftKey) {
|
||
event.preventDefault();
|
||
if (!sending) composer.requestSubmit();
|
||
}
|
||
event.stopImmediatePropagation();
|
||
};
|
||
for (const eventName of ['keydown', 'keyup', 'keypress']) {
|
||
window.addEventListener(eventName, stopPageKeyboardShortcut, true);
|
||
}
|
||
async function sendChatText(text, clearComposerValue = false) {
|
||
if (sending || !context?.enabled) return;
|
||
text = String(text || '').trim();
|
||
if (!text) return;
|
||
if ([...text].length > MAX_COMPOSER_CODE_POINTS) {
|
||
status.textContent = strings().tooLong || '';
|
||
return;
|
||
}
|
||
let chunks;
|
||
try {
|
||
chunks = globalThis.KoalaSyncChatFormat?.splitChatText(
|
||
text,
|
||
MAX_MESSAGE_CODE_POINTS,
|
||
MAX_CHAT_CHUNKS
|
||
);
|
||
} catch (_) {
|
||
status.textContent = strings().tooLong || '';
|
||
return;
|
||
}
|
||
if (!Array.isArray(chunks) || chunks.length === 0) return;
|
||
const generation = ++sendGeneration;
|
||
sending = true;
|
||
sendButton.disabled = true;
|
||
for (const button of reactionButtons) button.disabled = true;
|
||
status.textContent = '';
|
||
let completedChunks = 0;
|
||
let response = null;
|
||
for (const chunk of chunks) {
|
||
response = await messageRuntime({ type: 'CHAT_SEND', text: chunk });
|
||
if (destroyed || generation !== sendGeneration) return;
|
||
if (response?.status !== 'ok') break;
|
||
completedChunks++;
|
||
}
|
||
sending = false;
|
||
sendButton.disabled = false;
|
||
for (const button of reactionButtons) button.disabled = false;
|
||
if (completedChunks === chunks.length) {
|
||
if (clearComposerValue && textarea.value.trim() === text) {
|
||
textarea.value = '';
|
||
updateComposerState();
|
||
}
|
||
} else {
|
||
if (clearComposerValue && textarea.value.trim() === text) {
|
||
textarea.value = chunks.slice(completedChunks).join('\n');
|
||
updateComposerState();
|
||
}
|
||
status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || '');
|
||
}
|
||
}
|
||
composer.addEventListener('submit', async event => {
|
||
event.preventDefault();
|
||
await sendChatText(textarea.value, true);
|
||
});
|
||
for (const button of reactionButtons) {
|
||
button.addEventListener('click', () => sendChatText(button.dataset.reaction));
|
||
}
|
||
|
||
let drag = null;
|
||
header.addEventListener('pointerdown', event => {
|
||
if (layout.mode !== 'detached' || event.target.closest('button')) return;
|
||
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: layout.x, y: layout.y };
|
||
header.setPointerCapture(event.pointerId);
|
||
});
|
||
header.addEventListener('pointermove', event => {
|
||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||
layout.x = drag.x + event.clientX - drag.startX;
|
||
layout.y = drag.y + event.clientY - drag.startY;
|
||
clampDetached();
|
||
applyLayout();
|
||
});
|
||
header.addEventListener('pointerup', event => {
|
||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||
drag = null;
|
||
saveLayout();
|
||
});
|
||
|
||
const resizeObserver = new globalThis.ResizeObserver(() => {
|
||
if (applyingLayout || layout.mode !== 'detached') return;
|
||
const rect = panel.getBoundingClientRect();
|
||
if (!opened || !rect?.width || !rect.height) return;
|
||
if (Math.abs(rect.width - layout.width) < 1 && Math.abs(rect.height - layout.height) < 1) return;
|
||
layout.width = rect.width;
|
||
layout.height = rect.height;
|
||
layout.customWidth = rect.width;
|
||
layout.customHeight = rect.height;
|
||
if (chatSize !== 'custom') {
|
||
chatSize = 'custom';
|
||
setLocalStorage({ chatSize });
|
||
}
|
||
clampDetached();
|
||
saveLayout();
|
||
});
|
||
resizeObserver.observe(panel);
|
||
|
||
function moveIntoFullscreen() {
|
||
const parent = document.fullscreenElement || document.documentElement;
|
||
if (parent && host.parentNode !== parent) parent.append(host);
|
||
globalThis.requestAnimationFrame(applyLayout);
|
||
}
|
||
|
||
function handleResize() {
|
||
clampLauncher();
|
||
applyLayout();
|
||
saveLayout();
|
||
if (layout.mode === 'detached') {
|
||
clampDetached();
|
||
}
|
||
}
|
||
|
||
function handleSystemTheme() {
|
||
if (themeMode === 'system') applyTheme();
|
||
}
|
||
|
||
function handleStorage(changes, area) {
|
||
if (destroyed || area !== 'local') return;
|
||
if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system';
|
||
if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus';
|
||
if (changes.themeMode || changes.themePalette) applyTheme();
|
||
if (changes.chatPosition) setMode(changes.chatPosition.newValue, false);
|
||
if (changes.chatSize) setSize(changes.chatSize.newValue, false);
|
||
if (changes.chatStartMode) {
|
||
chatStartMode = changes.chatStartMode.newValue === 'open' ? 'open' : 'bubble';
|
||
if (context?.enabled) {
|
||
startStateApplied = true;
|
||
setOpened(chatStartMode === 'open');
|
||
}
|
||
}
|
||
if (changes.chatReactionDisplay) {
|
||
chatReactionDisplay = changes.chatReactionDisplay.newValue === 'video' ? 'video' : 'chat';
|
||
}
|
||
if (changes.locale) refresh();
|
||
}
|
||
|
||
function handleRuntime(message) {
|
||
if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message);
|
||
if (message?.type === 'CHAT_EVENT') appendEvent(message.event);
|
||
if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh();
|
||
if (message?.type === 'CHAT_RESET') {
|
||
clearMessages();
|
||
resetComposer();
|
||
refresh();
|
||
}
|
||
if (message?.type === 'CHAT_DESTROY' || message?.type === 'TARGET_DEACTIVATE') destroy();
|
||
}
|
||
|
||
function destroy() {
|
||
if (destroyed) return;
|
||
destroyed = true;
|
||
refreshGeneration++;
|
||
sendGeneration++;
|
||
if (saveTimer) clearTimeout(saveTimer);
|
||
resizeObserver.disconnect();
|
||
clearPageDock();
|
||
pageDockStyle.remove();
|
||
for (const eventName of ['keydown', 'keyup', 'keypress']) {
|
||
window.removeEventListener(eventName, stopPageKeyboardShortcut, true);
|
||
}
|
||
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
|
||
window.removeEventListener('resize', handleResize);
|
||
systemTheme.removeEventListener('change', handleSystemTheme);
|
||
try { chrome.storage.onChanged.removeListener(handleStorage); } catch (_) { /* invalidated context */ }
|
||
try { chrome.runtime.onMessage.removeListener(handleRuntime); } catch (_) { /* invalidated context */ }
|
||
host.remove();
|
||
window.koalaSyncChatOverlay = null;
|
||
}
|
||
|
||
document.addEventListener('fullscreenchange', moveIntoFullscreen);
|
||
window.addEventListener('resize', handleResize, { passive: true });
|
||
systemTheme.addEventListener('change', handleSystemTheme);
|
||
chrome.storage.onChanged.addListener(handleStorage);
|
||
chrome.runtime.onMessage.addListener(handleRuntime);
|
||
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
|
||
if (destroyed) return;
|
||
const storedLayout = data[storageKey];
|
||
if (storedLayout && typeof storedLayout === 'object') {
|
||
layout = { ...layout, ...storedLayout };
|
||
layout.customWidth = Number(storedLayout.customWidth) || Number(storedLayout.width) || DEFAULT_WIDTH;
|
||
layout.customHeight = Number(storedLayout.customHeight) || Number(storedLayout.height) || DEFAULT_HEIGHT;
|
||
}
|
||
chatPosition = normalizePosition(data.chatPosition);
|
||
chatSize = normalizeSize(data.chatSize);
|
||
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
|
||
chatReactionDisplay = data.chatReactionDisplay === 'video' ? 'video' : 'chat';
|
||
layout.mode = chatPosition;
|
||
if (layout.mode === 'detached') layout.detachedInitialized = true;
|
||
const preset = SIZE_PRESETS[chatSize];
|
||
if (preset) {
|
||
layout.width = preset.width;
|
||
layout.height = preset.height;
|
||
}
|
||
themeMode = data.themeMode || 'system';
|
||
themePalette = data.themePalette || 'eucalyptus';
|
||
preferencesLoaded = true;
|
||
applyTheme();
|
||
applyLayout();
|
||
refresh();
|
||
});
|
||
|
||
window.koalaSyncChatOverlay = { refresh, destroy };
|
||
})();
|