From aaa738d3cd2ff77adcc44d405a75d75c2f37b2a8 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 19 Jun 2026 17:45:21 +0200 Subject: [PATCH] Refactor Matrix chat widget implementation and enhance CSS styles for improved UI --- matrix_iframe/__init__.py | 29 +- .../static/matrix_iframe/matrix-chat.css | 333 ++++++++++++------ .../static/matrix_iframe/matrix-chat.js | 259 +++++++------- 3 files changed, 362 insertions(+), 259 deletions(-) diff --git a/matrix_iframe/__init__.py b/matrix_iframe/__init__.py index fa98511..6e47493 100644 --- a/matrix_iframe/__init__.py +++ b/matrix_iframe/__init__.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, current_app, redirect, url_for, request +from flask import Blueprint, current_app, redirect, url_for, request blueprint = Blueprint( 'matrix_iframe', @@ -8,29 +8,35 @@ blueprint = Blueprint( static_url_path='/static/matrix_iframe', ) - -def init_app(app, server_url='', room_alias='', base_template='layout.html', login_endpoint=None): +def init_app(app, server_url='', room_alias='', login_endpoint=None): """ Register the Matrix chat blueprint with a Flask app. + The blueprint serves static assets (JS + CSS) and injects + matrix_chat_available, matrix_server_url, and matrix_room_alias + into every template context so the host app's layout can mount + the chat overlay widget. + Args: server_url: Matrix homeserver base URL, e.g. "https://matrix.sticknife.com" room_alias: Matrix room alias, e.g. "#sticknife-library:matrix.sticknife.com" - base_template: Jinja2 base template to extend, default "layout.html" login_endpoint: Flask endpoint to redirect unauthenticated users to, e.g. "web.login". If None, no auth guard is applied. """ app.config.setdefault('MATRIX_SERVER_URL', server_url) app.config.setdefault('MATRIX_ROOM_ALIAS', room_alias) - app.config.setdefault('MATRIX_CHAT_BASE', base_template) app.config.setdefault('MATRIX_CHAT_LOGIN_ENDPOINT', login_endpoint) app.register_blueprint(blueprint) @blueprint.app_context_processor def _inject_matrix_chat(): - """Makes matrix_chat_available=True visible to all templates when the blueprint is registered.""" - return {'matrix_chat_available': True} + """Injects matrix chat config into every template when the blueprint is registered.""" + return { + 'matrix_chat_available': True, + 'matrix_server_url': current_app.config.get('MATRIX_SERVER_URL', ''), + 'matrix_room_alias': current_app.config.get('MATRIX_ROOM_ALIAS', ''), + } @blueprint.before_request @@ -46,12 +52,3 @@ def _auth_guard(): pass -@blueprint.route('/chat') -def chat(): - return render_template( - 'matrix_iframe/chat.html', - matrix_server=current_app.config.get('MATRIX_SERVER_URL', ''), - matrix_room_alias=current_app.config.get('MATRIX_ROOM_ALIAS', ''), - base_template=current_app.config.get('MATRIX_CHAT_BASE', 'layout.html'), - page='chat', - ) diff --git a/matrix_iframe/static/matrix_iframe/matrix-chat.css b/matrix_iframe/static/matrix_iframe/matrix-chat.css index 2579ee3..4949315 100644 --- a/matrix_iframe/static/matrix_iframe/matrix-chat.css +++ b/matrix_iframe/static/matrix_iframe/matrix-chat.css @@ -1,144 +1,261 @@ -#matrix-chat-root { - display: flex; - flex-direction: column; - height: calc(100vh - 120px); - padding: 1rem; - gap: 0.75rem; +/* ── Launch button ──────────────────────────────────────────────────────── */ + +.sk-chat-launch { + position: fixed; + left: 18px; + bottom: 18px; + z-index: 1020; + min-height: 46px; + display: inline-flex; + align-items: center; + gap: 10px; + border: 1px solid rgba(126, 207, 255, 0.40); + border-radius: 999px; + color: #0e1f2b; + background: #7ecfff; + box-shadow: 0 16px 38px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.38); + padding: 0 16px 0 12px; + font-weight: 800; + cursor: pointer; + font-size: 0.9rem; + font-family: inherit; + transition: background 140ms ease, box-shadow 140ms ease; } -.matrix-status { - font-size: 0.875rem; - color: #aaa; - min-height: 1.25rem; +.sk-chat-launch:hover { + background: #a8dcff; + box-shadow: 0 20px 44px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.42); } -.matrix-status-error { - color: #e74c3c; +.sk-chat-launch-icon { + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(14, 31, 43, 0.14); + display: inline-grid; + place-items: center; + flex-shrink: 0; +} + +.sk-chat-launch svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* ── Backdrop ───────────────────────────────────────────────────────────── */ + +.sk-chat-backdrop { + position: fixed; + inset: 0; + z-index: 2000; + display: grid; + place-items: center; + padding: 28px; + background: rgba(5, 10, 12, 0.58); + backdrop-filter: blur(10px); +} + +.sk-chat-backdrop[hidden] { + display: none; +} + +/* ── Shell (modal) ──────────────────────────────────────────────────────── */ + +.sk-chat-shell { + position: relative; + width: min(680px, 100%); + height: min(700px, calc(100vh - 56px)); + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; + border: 1px solid rgba(126, 207, 255, 0.18); + border-radius: 18px; + color: #e8f4ff; + background: rgba(10, 20, 30, 0.94); + box-shadow: 0 28px 88px rgba(0, 0, 0, 0.52), inset 0 1px 0 rgba(255, 255, 255, 0.06); + padding: 14px; +} + +/* ── Top bar ────────────────────────────────────────────────────────────── */ + +.sk-chat-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 40px; + flex-shrink: 0; +} + +.sk-chat-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 700; + font-size: 1rem; + color: #7ecfff; +} + +.sk-chat-title svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.sk-chat-close { + background: none; + border: none; + color: #7ecfff; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + transition: background 120ms ease; + font-family: inherit; +} + +.sk-chat-close:hover { + background: rgba(126, 207, 255, 0.12); +} + +/* ── Status bar ─────────────────────────────────────────────────────────── */ + +.sk-chat-status { + font-size: 0.8rem; + color: #7aa8c4; + min-height: 1rem; + flex-shrink: 0; +} + +.sk-chat-status-error { + color: #e74c3c; } /* ── Message list ───────────────────────────────────────────────────────── */ -.matrix-messages { - flex: 1; - overflow-y: auto; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; - padding: 1rem; - display: flex; - flex-direction: column; - gap: 0.4rem; +.sk-chat-messages { + flex: 1; + overflow-y: auto; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(126, 207, 255, 0.08); + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; } -.matrix-message { - display: flex; - align-items: baseline; - gap: 0.5rem; - flex-wrap: nowrap; - font-size: 0.9rem; - line-height: 1.5; - min-width: 0; +.sk-chat-message { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 0.875rem; + line-height: 1.5; + min-width: 0; } -.matrix-sender { - font-weight: 600; - color: #7ecfff; - white-space: nowrap; - flex-shrink: 0; +.sk-chat-sender { + font-weight: 600; + color: #7ecfff; + white-space: nowrap; + flex-shrink: 0; } -.matrix-sender::after { - content: ':'; +.sk-chat-sender::after { + content: ':'; } -.matrix-body { - color: #ddd; - word-break: break-word; - flex: 1; - min-width: 0; +.sk-chat-body { + color: #cce4f5; + word-break: break-word; + flex: 1; + min-width: 0; } -.matrix-time { - font-size: 0.72rem; - color: #555; - white-space: nowrap; - flex-shrink: 0; - align-self: flex-end; - padding-bottom: 1px; +.sk-chat-time { + font-size: 0.7rem; + color: #3a5568; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-end; + padding-bottom: 1px; } /* ── Input bar ──────────────────────────────────────────────────────────── */ -.matrix-input-bar { - display: flex; - gap: 0.5rem; - flex-shrink: 0; +.sk-chat-inputbar { + display: flex; + gap: 8px; + flex-shrink: 0; } -.matrix-input { - flex: 1; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 6px; - color: #ddd; - padding: 0.5rem 0.75rem; - font-size: 0.9rem; - outline: none; - transition: border-color 0.15s; +.sk-chat-input { + flex: 1; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(126, 207, 255, 0.18); + border-radius: 8px; + color: #cce4f5; + padding: 8px 12px; + font-size: 0.875rem; + outline: none; + transition: border-color 0.15s; + font-family: inherit; } -.matrix-input:focus { - border-color: rgba(126, 207, 255, 0.4); +.sk-chat-input:focus { + border-color: rgba(126, 207, 255, 0.45); } -.matrix-input::placeholder { - color: #555; +.sk-chat-input::placeholder { + color: #2e4a5e; } -.matrix-send-btn { - background: rgba(126, 207, 255, 0.12); - border: 1px solid rgba(126, 207, 255, 0.3); - border-radius: 6px; - color: #7ecfff; - padding: 0.5rem 1.25rem; - font-size: 0.9rem; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; - white-space: nowrap; +.sk-chat-send { + background: rgba(126, 207, 255, 0.14); + border: 1px solid rgba(126, 207, 255, 0.32); + border-radius: 8px; + color: #7ecfff; + padding: 8px 18px; + font-size: 0.875rem; + cursor: pointer; + font-weight: 600; + transition: background 0.14s, border-color 0.14s; + font-family: inherit; + white-space: nowrap; } -.matrix-send-btn:hover { - background: rgba(126, 207, 255, 0.22); - border-color: rgba(126, 207, 255, 0.5); +.sk-chat-send:hover { + background: rgba(126, 207, 255, 0.24); + border-color: rgba(126, 207, 255, 0.5); } -.matrix-send-btn:active { - background: rgba(126, 207, 255, 0.3); -} +/* ── Responsive ─────────────────────────────────────────────────────────── */ -/* ── Light theme fallback (when host app uses a light base) ─────────────── */ - -@media (prefers-color-scheme: light) { - .matrix-messages { - background: rgba(0, 0, 0, 0.04); - border-color: rgba(0, 0, 0, 0.1); - } - .matrix-sender { color: #1a6fa8; } - .matrix-body { color: #222; } - .matrix-time { color: #aaa; } - .matrix-input { - background: #fff; - border-color: #ccc; - color: #222; - } - .matrix-input:focus { border-color: #1a6fa8; } - .matrix-input::placeholder { color: #aaa; } - .matrix-send-btn { - background: rgba(26, 111, 168, 0.1); - border-color: rgba(26, 111, 168, 0.4); - color: #1a6fa8; - } - .matrix-send-btn:hover { - background: rgba(26, 111, 168, 0.18); - } +@media (max-width: 600px) { + .sk-chat-backdrop { + padding: 0; + align-items: flex-end; + } + .sk-chat-shell { + width: 100%; + height: 85vh; + border-radius: 18px 18px 0 0; + } + .sk-chat-launch { + left: 12px; + bottom: 12px; + } } diff --git a/matrix_iframe/static/matrix_iframe/matrix-chat.js b/matrix_iframe/static/matrix_iframe/matrix-chat.js index 0fe7dd3..bd8915b 100644 --- a/matrix_iframe/static/matrix_iframe/matrix-chat.js +++ b/matrix_iframe/static/matrix_iframe/matrix-chat.js @@ -1,9 +1,11 @@ 'use strict'; (function () { - const root = document.getElementById('matrix-chat-root'); - const SERVER = (root.dataset.matrixServer || '').replace(/\/$/, ''); - const ROOM_ALIAS = root.dataset.matrixRoom || ''; + const cfg = window._matrixChat || {}; + const SERVER = (cfg.server || '').replace(/\/$/, ''); + const ROOM_ALIAS = cfg.room || ''; + if (!SERVER || !ROOM_ALIAS) return; + const CREDS_KEY = 'matrix_chat_creds_' + SERVER; let accessToken = null; @@ -12,30 +14,84 @@ let syncToken = null; let syncActive = false; let txnCounter = 0; + let initialized = false; - const messagesEl = document.getElementById('matrix-messages'); - const statusEl = document.getElementById('matrix-chat-status'); - const inputEl = document.getElementById('matrix-input'); - const sendBtn = document.getElementById('matrix-send'); + // ── DOM bootstrap ───────────────────────────────────────────────────────── - // ── Utilities ──────────────────────────────────────────────────────────── + function mount() { + const css = document.createElement('link'); + css.rel = 'stylesheet'; + css.href = cfg.cssUrl || ''; + if (cfg.cssUrl) document.head.appendChild(css); + + document.body.insertAdjacentHTML('beforeend', ` + + +`); + + document.getElementById('sk-chat-launch').addEventListener('click', openPanel); + document.getElementById('sk-chat-close').addEventListener('click', closePanel); + document.getElementById('sk-chat-backdrop').addEventListener('click', function (e) { + if (e.target === this) closePanel(); + }); + document.getElementById('sk-chat-send').addEventListener('click', sendMessage); + document.getElementById('sk-chat-input').addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closePanel(); + }); + } + + function openPanel() { + document.getElementById('sk-chat-backdrop').hidden = false; + document.getElementById('sk-chat-input').focus(); + if (!initialized) { initialized = true; init(); } + } + + function closePanel() { + document.getElementById('sk-chat-backdrop').hidden = true; + } + + // ── Utilities ───────────────────────────────────────────────────────────── function escapeHtml(str) { return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function setStatus(msg, isError) { - statusEl.textContent = msg; - statusEl.className = 'matrix-status' + (isError ? ' matrix-status-error' : ''); + const el = document.getElementById('sk-chat-status'); + el.textContent = msg; + el.className = 'sk-chat-status' + (isError ? ' sk-chat-status-error' : ''); } function clearStatus() { - statusEl.textContent = ''; - statusEl.className = 'matrix-status'; + const el = document.getElementById('sk-chat-status'); + el.textContent = ''; + el.className = 'sk-chat-status'; } function senderLocalpart(mxid) { @@ -48,7 +104,7 @@ return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } - // ── Credentials ────────────────────────────────────────────────────────── + // ── Credentials ─────────────────────────────────────────────────────────── function loadCreds() { try { @@ -63,10 +119,7 @@ } function saveCreds() { - localStorage.setItem(CREDS_KEY, JSON.stringify({ - access_token: accessToken, - user_id: userId, - })); + localStorage.setItem(CREDS_KEY, JSON.stringify({ access_token: accessToken, user_id: userId })); } function clearCreds() { @@ -75,40 +128,26 @@ userId = null; } - // ── Matrix API ─────────────────────────────────────────────────────────── + // ── Matrix API ──────────────────────────────────────────────────────────── async function matrixFetch(method, path, body, params) { const url = new URL(SERVER + path); - if (params) { - for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v)); - } - const opts = { - method, - headers: { 'Content-Type': 'application/json' }, - }; + if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v)); + const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (accessToken) opts.headers['Authorization'] = 'Bearer ' + accessToken; if (body !== null && body !== undefined) opts.body = JSON.stringify(body); - const resp = await fetch(url.toString(), opts); - - if (resp.status === 401) { - clearCreds(); - beginSSO(); - throw new Error('Session expired — redirecting to login'); - } - + if (resp.status === 401) { clearCreds(); beginSSO(); throw new Error('Session expired'); } const data = await resp.json(); if (!resp.ok) throw new Error(data.error || 'HTTP ' + resp.status); return data; } - // ── Auth ───────────────────────────────────────────────────────────────── + // ── Auth ────────────────────────────────────────────────────────────────── function beginSSO() { - // Strip any existing query string so we get a clean callback URL const redirectUrl = window.location.origin + window.location.pathname; - window.location.href = - SERVER + '/_matrix/client/v3/login/sso/redirect?redirectUrl=' + + window.location.href = SERVER + '/_matrix/client/v3/login/sso/redirect?redirectUrl=' + encodeURIComponent(redirectUrl); } @@ -122,159 +161,109 @@ accessToken = data.access_token; userId = data.user_id; saveCreds(); - // Remove loginToken from the URL without triggering a reload window.history.replaceState({}, '', window.location.pathname); } - // ── Room ───────────────────────────────────────────────────────────────── + // ── Room ────────────────────────────────────────────────────────────────── async function resolveRoom() { - const data = await matrixFetch( - 'GET', - '/_matrix/client/v3/directory/room/' + encodeURIComponent(ROOM_ALIAS), - ); + const data = await matrixFetch('GET', '/_matrix/client/v3/directory/room/' + encodeURIComponent(ROOM_ALIAS)); roomId = data.room_id; } async function joinRoom() { - try { - await matrixFetch('POST', '/_matrix/client/v3/join/' + encodeURIComponent(roomId), {}); - } catch (_) { - // Already joined is fine; any real error surfaces during sync - } + try { await matrixFetch('POST', '/_matrix/client/v3/join/' + encodeURIComponent(roomId), {}); } + catch (_) {} } - // ── Rendering ──────────────────────────────────────────────────────────── + // ── Rendering ───────────────────────────────────────────────────────────── function renderEvent(event) { if (event.type !== 'm.room.message') return; if (!event.content || event.content.msgtype !== 'm.text') return; - + const messagesEl = document.getElementById('sk-chat-messages'); const el = document.createElement('div'); - el.className = 'matrix-message'; + el.className = 'sk-chat-message'; el.innerHTML = - '' + escapeHtml(senderLocalpart(event.sender)) + '' + - '' + escapeHtml(event.content.body) + '' + - '' + escapeHtml(formatTime(event.origin_server_ts)) + ''; + '' + escapeHtml(senderLocalpart(event.sender)) + '' + + '' + escapeHtml(event.content.body) + '' + + '' + escapeHtml(formatTime(event.origin_server_ts)) + ''; messagesEl.appendChild(el); } - function isScrolledToBottom() { - return messagesEl.scrollHeight - messagesEl.scrollTop <= messagesEl.clientHeight + 60; + function scrollToBottom() { + const el = document.getElementById('sk-chat-messages'); + el.scrollTop = el.scrollHeight; } - function scrollToBottom() { - messagesEl.scrollTop = messagesEl.scrollHeight; + function isScrolledToBottom() { + const el = document.getElementById('sk-chat-messages'); + return el.scrollHeight - el.scrollTop <= el.clientHeight + 60; } async function loadHistory() { - const data = await matrixFetch( - 'GET', + const data = await matrixFetch('GET', '/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/messages', - null, - { dir: 'b', limit: '50' }, - ); - // /messages?dir=b returns newest-first; reverse for chronological display - const events = (data.chunk || []).reverse(); - for (const ev of events) renderEvent(ev); + null, { dir: 'b', limit: '50' }); + for (const ev of (data.chunk || []).reverse()) renderEvent(ev); scrollToBottom(); } - // ── Send ───────────────────────────────────────────────────────────────── + // ── Send ────────────────────────────────────────────────────────────────── async function sendMessage() { + const inputEl = document.getElementById('sk-chat-input'); const text = inputEl.value.trim(); if (!text || !roomId) return; inputEl.value = ''; - const txnId = Date.now() + '_' + (txnCounter++); try { - await matrixFetch( - 'PUT', - '/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + - '/send/m.room.message/' + txnId, - { msgtype: 'm.text', body: text }, - ); + await matrixFetch('PUT', + '/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/send/m.room.message/' + txnId, + { msgtype: 'm.text', body: text }); } catch (e) { setStatus('Send failed: ' + e.message, true); } } - // ── Sync loop ──────────────────────────────────────────────────────────── - - // Minimal filter: only timeline events from our room, no state/presence/account noise - const SYNC_FILTER = JSON.stringify({ - room: { - rooms: [], // filled in after room resolution - timeline: { limit: 20 }, - state: { not_types: ['*'] }, - ephemeral: { not_types: ['*'] }, - }, - presence: { not_types: ['*'] }, - account_data: { not_types: ['*'] }, - }); + // ── Sync ────────────────────────────────────────────────────────────────── async function syncLoop() { syncActive = true; - - // Build a filter scoped to our single room const filter = JSON.stringify({ - room: { - rooms: [roomId], - timeline: { limit: 20 }, - state: { not_types: ['*'] }, - ephemeral: { not_types: ['*'] }, - }, + room: { rooms: [roomId], timeline: { limit: 20 }, state: { not_types: ['*'] }, ephemeral: { not_types: ['*'] } }, presence: { not_types: ['*'] }, account_data: { not_types: ['*'] }, }); - while (syncActive) { try { const params = { filter, timeout: '30000' }; if (syncToken) params.since = syncToken; - const data = await matrixFetch('GET', '/_matrix/client/v3/sync', null, params); if (!syncActive) break; - syncToken = data.next_batch; - - const roomTimeline = - data.rooms && - data.rooms.join && - data.rooms.join[roomId] && - data.rooms.join[roomId].timeline; - - if (roomTimeline && roomTimeline.events && roomTimeline.events.length) { + const timeline = data.rooms?.join?.[roomId]?.timeline; + if (timeline?.events?.length) { const atBottom = isScrolledToBottom(); - for (const ev of roomTimeline.events) renderEvent(ev); + for (const ev of timeline.events) renderEvent(ev); if (atBottom) scrollToBottom(); } } catch (e) { if (!syncActive) break; - // Back off before retrying to avoid hammering on persistent errors await new Promise(r => setTimeout(r, 5000)); } } } - // ── Entry point ────────────────────────────────────────────────────────── + // ── Entry point ─────────────────────────────────────────────────────────── async function init() { - if (!SERVER) { setStatus('MATRIX_SERVER_URL is not configured.', true); return; } - if (!ROOM_ALIAS) { setStatus('MATRIX_ROOM_ALIAS is not configured.', true); return; } - - // Handle SSO callback: Matrix redirects back here with ?loginToken= const urlParams = new URLSearchParams(window.location.search); const loginToken = urlParams.get('loginToken'); if (loginToken) { - try { - await exchangeLoginToken(loginToken); - } catch (e) { - setStatus('Login failed: ' + e.message, true); - return; - } + try { await exchangeLoginToken(loginToken); } + catch (e) { setStatus('Login failed: ' + e.message, true); return; } } else if (!loadCreds()) { beginSSO(); return; @@ -294,18 +283,18 @@ syncLoop(); } - // ── Event listeners ────────────────────────────────────────────────────── + window.addEventListener('beforeunload', function () { syncActive = false; }); - sendBtn.addEventListener('click', sendMessage); - inputEl.addEventListener('keydown', function (e) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } - }); - window.addEventListener('beforeunload', function () { - syncActive = false; - }); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount); + } else { + mount(); + } - init(); + // Auto-open if returning from SSO redirect (loginToken in URL) + if (new URLSearchParams(window.location.search).has('loginToken')) { + document.addEventListener('DOMContentLoaded', function () { + openPanel(); + }); + } })();