diff --git a/CHANGELOG.md b/CHANGELOG.md index 75cd189..fa6e1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Lumi changelog +## 0.2.17 + +- Added a signed standalone OBS Custom Browser Dock page for every native Lumi chat source, with admin-only copy/open controls. +- Reused each chat source's saved filters and appearance while deliberately ignoring overlay coordinates, source dimensions, and entry/exit animations. +- Made dock chat responsive and normally scrollable at any dock size, with bottom-follow behavior that pauses when an operator scrolls up. +- Bound dock access to existing revocable overlay and scene credentials so regenerating private links or deleting the source invalidates its dock URL. +- Added shared live-event credential revalidation so already-open overlay and dock streams close after their bearer URL is revoked. + ## 0.2.16 - Kept keyboard focus in source fields while server-normalized live previews rerender, preventing interrupted typing and other continuous input. diff --git a/TODO.md b/TODO.md index b067a2c..2d3c34b 100644 --- a/TODO.md +++ b/TODO.md @@ -37,6 +37,10 @@ or linked-Lumi-account user blocking. Live draft rerenders preserve form focus; chat messages now use content-sized cards and whole-message alignment, cached Twitch avatars, Discord emoji/GIF/sticker media, four-direction entry/exit animations, and a continuously animated multi-message editor simulation. +Each saved native chat source also has a signed standalone OBS Custom Browser +Dock URL. The dock reuses its filters and visual settings without overlay canvas +coordinates, fixed source dimensions, or message animations, and its private URL +is revoked through the existing overlay/scene token lifecycle. Remaining work: diff --git a/docs/obs-overlays.md b/docs/obs-overlays.md index 5e40ff6..227961d 100644 --- a/docs/obs-overlays.md +++ b/docs/obs-overlays.md @@ -108,6 +108,21 @@ updates. Lumi keeps a small, short-lived in-memory replay window so a temporary Browser Source reconnection can restore recent messages without writing chat content to the database. +Every saved Lumi chat source also provides an **OBS chat dock** link to +administrators. Add it from **OBS → Docks → Custom Browser Docks**. This +standalone page uses that source's saved services, channel and hidden-user +filters, typography, colors, message details, limits, timeout, and custom CSS. +It intentionally ignores overlay position, source width/height, and entry/exit +animations. The page fills the available dock width, grows and scrolls like a +normal webpage, follows new messages while at the bottom, and leaves the scroll +position alone while an operator reviews older messages. + +Treat a chat dock link as a password. It is signed and shown only to admins. +Replacing the overlay or scene private links also revokes affected dock links; +deleting or disabling the chat source makes its dock unavailable. Lumi also +revalidates credentials on open live-event streams and closes them after +revocation. + ### Video and audio Video sources support browser-playable media such as WebM and MP4; audio sources diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 3be97d7..36aa043 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.2.16 +Version: 0.2.17 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index 70dee3e..a893310 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.16", + "version": "0.2.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.16", + "version": "0.2.17", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index 973f6b6..cf69cbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index 69689a4..9703b7c 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.17", + "ref": "refs/tags/v0.2.17", + "released_at": "2026-07-19", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Adds responsive standalone OBS chat docks backed by existing native chat settings and revocable credentials. Existing overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.", + "plugins": { + "auto-vc": "0.1.6", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.5", + "moderation": "0.1.5", + "okf": "0.1.1", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.2.16", "ref": "refs/tags/v0.2.16", diff --git a/scripts/verify-overlays.js b/scripts/verify-overlays.js index be82508..e635131 100644 --- a/scripts/verify-overlays.js +++ b/scripts/verify-overlays.js @@ -200,6 +200,11 @@ try { const chatState = overlays.buildPublicState(created.id, created.scenes[0].id).scene.modules.find((module) => module.id === chatId); assert.strictEqual(chatState.renderType, "chat"); assert.deepStrictEqual(chatState.config.platforms, ["twitch", "youtube"]); + const chatDockUrl = overlays.chatDockRenderUrl(chatId); + const chatDockTicket = chatDockUrl.split("/").at(-1); + assert(chatDockUrl.startsWith("/overlay-chat/")); + assert.strictEqual(overlays.resolveChatDockTicket(chatDockTicket).module.id, chatId); + assert.deepStrictEqual(overlays.resolveChatDockTicket(chatDockTicket).module.config.platforms, ["twitch", "youtube"]); const overlayChat = require(path.join(serviceDir, "overlay-chat.js")); const normalizedChat = overlayChat.normalizeChatMessage({ id: "message-1", @@ -247,9 +252,12 @@ try { assert.strictEqual(overlays.resolvePublicOverlay(oldOverlayToken), null, "regeneration must revoke old overlay tokens"); assert(overlays.resolvePublicOverlay(newOverlayToken)); assert.strictEqual(overlays.resolveWebSourceTicket(websiteTicket), null, "overlay URL regeneration must revoke website render tickets"); + assert.strictEqual(overlays.resolveChatDockTicket(chatDockTicket), null, "overlay URL regeneration must revoke chat dock tickets"); const refreshedWebsiteUrl = overlays.buildPublicState(created.id, created.scenes[0].id).scene.modules.find((module) => module.id === websiteId).config.renderUrl; const refreshedWebsiteTicket = refreshedWebsiteUrl.split("/").at(-1); assert(overlays.resolveWebSourceTicket(refreshedWebsiteTicket)); + const refreshedChatDockTicket = overlays.chatDockRenderUrl(chatId).split("/").at(-1); + assert(overlays.resolveChatDockTicket(refreshedChatDockTicket)); const refreshed = overlays.getOverlay(created.id, { includeSecrets: true }); const firstScene = refreshed.scenes.find((scene) => scene.name === "Live"); @@ -258,8 +266,11 @@ try { assert.strictEqual(overlays.resolvePublicOverlay(newOverlayToken, oldSceneToken), null, "regeneration must revoke old fixed scene tokens"); assert(overlays.resolvePublicOverlay(newOverlayToken, newSceneToken)); assert.strictEqual(overlays.resolveWebSourceTicket(refreshedWebsiteTicket), null, "scene URL regeneration must revoke website render tickets"); + assert.strictEqual(overlays.resolveChatDockTicket(refreshedChatDockTicket), null, "scene URL regeneration must revoke chat dock tickets"); + const finalChatDockTicket = overlays.chatDockRenderUrl(chatId).split("/").at(-1); overlays.deleteModule(websiteId); overlays.deleteModule(chatId); + assert.strictEqual(overlays.resolveChatDockTicket(finalChatDockTicket), null, "deleting a chat source must revoke its dock ticket"); overlays.deleteScene(second.id); assert.throws(() => overlays.deleteScene(firstScene.id), /at least one scene/); @@ -294,6 +305,8 @@ try { const editorScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-management.js"), "utf8"); const runtimeScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-runtime.js"), "utf8"); const rendererScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-renderer.js"), "utf8"); + const chatDockScript = fs.readFileSync(path.join(root, "src", "web", "public", "overlay-chat-dock.js"), "utf8"); + const chatDockView = fs.readFileSync(path.join(root, "src", "web", "views", "overlay-chat-dock.ejs"), "utf8"); const renderView = fs.readFileSync(path.join(root, "src", "web", "views", "overlay-render.ejs"), "utf8"); const { OBS_BROWSER_DEFAULT_CSS } = require(path.join(root, "src", "services", "obs-browser-defaults.js")); const connectorScript = fs.readFileSync(path.join(root, "src", "services", "overlay-connectors.js"), "utf8"); @@ -304,6 +317,7 @@ try { assert(editorView.includes('data-module-fields="video"') && editorView.includes('data-module-fields="audio"')); assert(editorView.includes('data-module-fields="chat"') && editorView.includes('name="chat_custom_css"')); assert(editorView.includes('name="chat_blocked_users"') && editorView.includes("lumi:internal_name|user-id")); + assert(editorView.includes("OBS chat dock") && editorView.includes("module.chat_dock_url")); assert(editorView.includes('name="custom_css"') && editorView.includes('name="crop_left"')); assert(editorView.includes('name="inject_page_css"') && editorView.includes("Apply CSS inside the website")); assert(editorView.includes('name="force_transparent_background"') && editorView.includes("Keep the website background transparent")); @@ -323,6 +337,8 @@ try { assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/")); assert(rendererScript.includes("/icons/badges/")); assert(rendererScript.includes("previewChatMessages") && rendererScript.includes("schedulePreviewChat")); + assert(rendererScript.includes("renderChatDock") && rendererScript.includes("chatDock: true")); + assert(rendererScript.includes("animation:none !important") && rendererScript.includes("min-height:100vh !important")); assert(rendererScript.includes("width:fit-content") && rendererScript.includes("align-self:${alignment}")); assert(rendererScript.includes("enter-slide-right") && rendererScript.includes("leave-slide-up")); assert(rendererScript.includes('mediaData.type === "video"') && rendererScript.includes('className = "media-row"')); @@ -355,6 +371,10 @@ try { assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import")); assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument")); assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket")); + assert(routeScript.includes('/overlay-chat/:ticket') && routeScript.includes("resolveChatDockTicket")); + assert(routeScript.includes("validate: () => Boolean(chatDockContext(req))")); + assert(chatDockView.includes("lumi-chat-dock-root") && chatDockView.includes("overlay-chat-dock.js")); + assert(chatDockScript.includes("renderChatDock") && chatDockScript.includes("overlay:chat-message")); console.log("Overlay verification passed: CRUD, fixed-canvas preview parity, transparent OBS defaults, layout controls, external recovery, token revocation, encrypted OBS credentials, and permissions."); } finally { diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index cb1407c..a1e41e4 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,8 +4,8 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.2.16"; -const previousCoreVersion = "0.2.15"; +const releaseVersion = "0.2.17"; +const previousCoreVersion = "0.2.16"; const earliestCompatibleCoreVersion = "0.1.9"; const changedPlugins = { "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, @@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: core 0.2.16, Lumi AI 0.8.5, and synchronized package metadata."); +console.log("Release metadata verification passed: core 0.2.17, Lumi AI 0.8.5, and synchronized package metadata."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index c5b9f01..f4abee4 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -16,7 +16,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true }, { version: "0.2.16", ref: "refs/tags/v0.2.16", rollback_safe: true }, { version: "0.2.15", ref: "refs/tags/v0.2.15", rollback_safe: true }, { version: "0.2.14", ref: "refs/tags/v0.2.14", rollback_safe: true }, @@ -76,7 +77,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.2.16"); +assert.equal(corrected.safe_target_version, "0.2.17"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/overlay-routes.js b/src/services/overlay-routes.js index 1998336..93087f9 100644 --- a/src/services/overlay-routes.js +++ b/src/services/overlay-routes.js @@ -10,6 +10,7 @@ const { addModule, addScene, buildPublicState, + chatDockRenderUrl, createOverlay, deleteModule, deleteOverlay, @@ -27,6 +28,7 @@ const { reorderOverlays, reorderScenes, resolvePublicOverlay, + resolveChatDockTicket, resolveWebSourceTicket, saveObsSettings, setActiveScene, @@ -221,6 +223,7 @@ function registerPublicOverlayRoutes(app) { if (!context) return res.status(404).end(); return subscribeWebEvents(req, res, { scopes: [`overlay:${context.overlay.id}`], + validate: () => Boolean(publicOverlayContext(req)), initialEvents: [ { event: "overlay:changed", payload: { revision: context.overlay.updated_at } }, ...recentOverlayChatMessages(context.overlay.id) @@ -252,6 +255,47 @@ function registerPublicOverlayRoutes(app) { registerVariant("/overlay/:overlayToken/scene/:sceneToken", true); registerVariant("/overlay/:overlayToken", false); + + const chatDockContext = (req) => resolveChatDockTicket(req.params.ticket); + const chatDockState = (context) => context ? { + exists: true, + enabled: true, + revision: Date.now(), + module: context.module + } : { exists: false, enabled: false, revision: Date.now(), module: null }; + + app.get("/overlay-chat/:ticket/state", (req, res) => { + noStore(res); + const context = chatDockContext(req); + if (!context) return res.status(404).json(chatDockState(null)); + return res.json(chatDockState(context)); + }); + + app.get("/overlay-chat/:ticket/events", (req, res) => { + const context = chatDockContext(req); + if (!context) return res.status(404).end(); + return subscribeWebEvents(req, res, { + scopes: [`overlay:${context.overlayId}`], + validate: () => Boolean(chatDockContext(req)), + initialEvents: [ + { event: "overlay:changed", payload: { revision: Date.now() } }, + ...recentOverlayChatMessages(context.overlayId) + ] + }); + }); + + app.get("/overlay-chat/:ticket", (req, res) => { + noStore(res); + const context = chatDockContext(req); + if (!context) return res.status(404).send("Chat dock unavailable."); + const path = req.path.replace(/\/$/, ""); + res.set("Content-Security-Policy", "default-src 'none'; img-src https: http: data:; media-src https: http: data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';"); + return res.render("overlay-chat-dock", { + statePath: `${path}/state`, + eventsPath: `${path}/events`, + initialStateJson: JSON.stringify(chatDockState(context)).replace(/ scene.modules)) { + if ((getOverlayModuleType(module.type)?.renderType || module.type) === "chat") { + const path = chatDockRenderUrl(module.id); + module.chat_dock_url = path ? `${baseUrl(req)}${path}` : ""; + } + } + } res.render("admin-overlay-detail", { title: overlay.name, overlay, diff --git a/src/services/overlays.js b/src/services/overlays.js index c00dbc7..3c01910 100644 --- a/src/services/overlays.js +++ b/src/services/overlays.js @@ -453,6 +453,37 @@ function webSourceRenderUrl(moduleId) { return `/overlay-web/${row.id}.${row.scene_id}.${signature}`; } +function chatDockTicketValue(row) { + return [row.id, row.scene_id, row.overlay_id, row.overlay_token_hash, row.scene_token_hash].join(":"); +} + +function chatDockRenderUrl(moduleId) { + const row = webSourceTicketRecord(moduleId); + if (!row || getOverlayModuleType(row.type)?.renderType !== "chat") return null; + const signature = scopedSignature("overlay-chat-dock", chatDockTicketValue(row)); + return `/overlay-chat/${row.id}.${row.scene_id}.${signature}`; +} + +function resolveChatDockTicket(ticket) { + const [moduleId, sceneId, signature, ...extra] = String(ticket || "").split("."); + if (!moduleId || !sceneId || !signature || extra.length) return null; + const row = webSourceTicketRecord(moduleId); + if (!row || row.scene_id !== sceneId || !row.overlay_enabled || !row.scene_enabled || !row.enabled) return null; + if (getOverlayModuleType(row.type)?.renderType !== "chat") return null; + if (!verifyScopedSignature("overlay-chat-dock", chatDockTicketValue(row), signature)) return null; + return { + overlayId: row.overlay_id, + sceneId: row.scene_id, + module: { + id: row.id, + type: row.type, + renderType: "chat", + name: row.name, + config: parseConfig(row.config_json) + } + }; +} + function resolveWebSourceTicket(ticket) { const [moduleId, sceneId, signature, ...extra] = String(ticket || "").split("."); if (!moduleId || !sceneId || !signature || extra.length) return null; @@ -551,6 +582,7 @@ module.exports = { addModule, addScene, buildPublicState, + chatDockRenderUrl, createOverlay, deleteModule, deleteOverlay, @@ -571,6 +603,7 @@ module.exports = { reorderOverlays, reorderScenes, resolvePublicOverlay, + resolveChatDockTicket, resolveWebSourceTicket, saveObsSettings, setActiveScene, diff --git a/src/services/web-events.js b/src/services/web-events.js index 4576be8..61a4ac4 100644 --- a/src/services/web-events.js +++ b/src/services/web-events.js @@ -4,7 +4,14 @@ function subscribe(req, res, options = {}) { const id = `${Date.now()}:${Math.random().toString(16).slice(2)}`; const user = req.session?.user || null; const scopes = new Set(Array.isArray(options.scopes) ? options.scopes.filter(Boolean) : []); - const client = { id, res, user, scopes, connectedAt: Date.now() }; + const client = { + id, + res, + user, + scopes, + connectedAt: Date.now(), + validate: typeof options.validate === "function" ? options.validate : null + }; clients.set(id, client); res.writeHead(200, { @@ -23,6 +30,7 @@ function subscribe(req, res, options = {}) { } const keepAlive = setInterval(() => { + if (!clientIsValid(client)) return disconnect(client); send(client, "server:status", { status: "heartbeat", at: Date.now() }); }, 25000); @@ -35,6 +43,10 @@ function subscribe(req, res, options = {}) { function publish(event, payload = {}, options = {}) { let delivered = 0; for (const client of clients.values()) { + if (!clientIsValid(client)) { + disconnect(client); + continue; + } if (!canReceive(client.user, options, client)) continue; send(client, event, payload); delivered += 1; @@ -42,6 +54,17 @@ function publish(event, payload = {}, options = {}) { return delivered; } +function clientIsValid(client) { + if (!client?.validate) return true; + try { return client.validate() !== false; } + catch { return false; } +} + +function disconnect(client) { + clients.delete(client.id); + try { client.res.end(); } catch {} +} + function send(client, event, payload) { try { client.res.write(`event: ${event}\n`); diff --git a/src/web/public/overlay-chat-dock.css b/src/web/public/overlay-chat-dock.css new file mode 100644 index 0000000..dc32402 --- /dev/null +++ b/src/web/public/overlay-chat-dock.css @@ -0,0 +1,30 @@ +:root, +html, +body { + min-width: 100%; + min-height: 100%; + margin: 0; +} + +html { + background: transparent; + color-scheme: dark; +} + +body { + overflow: auto; + background: transparent; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +#lumi-chat-dock-root, +.lumi-overlay-chat-dock, +.lumi-overlay-chat-root { + width: 100%; + min-height: 100vh; + box-sizing: border-box; +} + +.lumi-overlay-chat-dock { + display: block; +} diff --git a/src/web/public/overlay-chat-dock.js b/src/web/public/overlay-chat-dock.js new file mode 100644 index 0000000..94bc404 --- /dev/null +++ b/src/web/public/overlay-chat-dock.js @@ -0,0 +1,51 @@ +(() => { + const root = document.getElementById("lumi-chat-dock-root"); + const initial = document.getElementById("lumi-chat-dock-state"); + const config = document.getElementById("lumi-chat-dock-config"); + const renderer = window.LumiOverlayRenderer; + if (!root || !initial || !config || !renderer?.renderChatDock) return; + + let state = null; + let refreshPromise = null; + + function nearBottom() { + return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 48; + } + + function render(nextState) { + const shouldFollow = !state || nearBottom(); + state = nextState; + renderer.renderChatDock(root, nextState?.enabled ? nextState.module : null); + if (shouldFollow && nextState?.module?.config?.newestAt !== "top") requestAnimationFrame(() => window.scrollTo(0, document.documentElement.scrollHeight)); + } + + function refresh() { + if (refreshPromise) return refreshPromise; + refreshPromise = fetch(config.dataset.stateUrl, { cache: "no-store", credentials: "omit" }) + .then((response) => response.ok ? response.json() : { enabled: false, module: null }) + .then(render) + .catch(() => {}) + .finally(() => { refreshPromise = null; }); + return refreshPromise; + } + + try { render(JSON.parse(initial.textContent || "{}")); } + catch { render({ enabled: false, module: null }); } + + const events = new EventSource(config.dataset.eventsUrl, { withCredentials: false }); + events.addEventListener("overlay:changed", refresh); + events.addEventListener("overlay:chat-message", (event) => { + try { + const shouldFollow = nearBottom(); + renderer.handleChatMessage(root, JSON.parse(event.data || "{}")); + if (shouldFollow && state?.module?.config?.newestAt !== "top") { + requestAnimationFrame(() => window.scrollTo(0, document.documentElement.scrollHeight)); + } + } catch {} + }); + events.addEventListener("server:status", (event) => { + try { + if (JSON.parse(event.data || "{}").status === "connected") refresh(); + } catch {} + }); +})(); diff --git a/src/web/public/overlay-renderer.js b/src/web/public/overlay-renderer.js index 8ef4bfe..163a9d1 100644 --- a/src/web/public/overlay-renderer.js +++ b/src/web/public/overlay-renderer.js @@ -346,9 +346,10 @@ const style = document.createElement("style"); const chatCustomCss = values.customCss || ""; const alignment = values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start"; + const dock = Boolean(options.chatDock); style.textContent = ` - :host { display:block; width:100%; height:100%; overflow:hidden; box-sizing:border-box; background:${values.background}; color:${values.color}; font:${values.fontWeight} ${values.fontSize}px/${values.lineHeight} ${values.fontFamily}; } - .chat { width:100%; height:100%; box-sizing:border-box; display:flex; flex-direction:column; align-items:${alignment}; justify-content:${values.newestAt === "bottom" ? "flex-end" : "flex-start"}; gap:${values.gap}px; padding:${values.padding}px; overflow:hidden; text-align:${values.horizontalAlign}; } + :host { display:block; width:100%; ${dock ? "min-height:100%; height:auto; overflow:visible;" : "height:100%; overflow:hidden;"} box-sizing:border-box; background:${values.background}; color:${values.color}; font:${values.fontWeight} ${values.fontSize}px/${values.lineHeight} ${values.fontFamily}; } + .chat { width:100%; ${dock ? "min-height:100vh; height:auto; overflow:visible;" : "height:100%; overflow:hidden;"} box-sizing:border-box; display:flex; flex-direction:column; align-items:${alignment}; justify-content:${values.newestAt === "bottom" ? "flex-end" : "flex-start"}; gap:${values.gap}px; padding:${values.padding}px; text-align:${values.horizontalAlign}; } .message { display:inline-flex; align-items:flex-start; align-self:${alignment}; gap:.55em; box-sizing:border-box; width:fit-content; max-width:100%; padding:.55em .7em; border-radius:${values.borderRadius}px; background:${values.messageBackground}; overflow:hidden; transition:opacity ${values.animationDurationMs}ms ease, transform ${values.animationDurationMs}ms ease; } .avatar { width:1.85em; height:1.85em; flex:0 0 auto; border-radius:50%; object-fit:cover; } .content { min-width:0; max-width:100%; flex:0 1 auto; } @@ -374,6 +375,7 @@ .leave-slide-down.leave-active { transform:translateY(1em); } .leave-scale.leave-active { transform:scale(.92); } ${chatCustomCss} + ${dock ? ":host { width:100% !important; min-height:100% !important; height:auto !important; overflow:visible !important; } .chat { width:100% !important; min-height:100vh !important; height:auto !important; overflow:visible !important; } .message, .message * { animation:none !important; transition:none !important; } .message { transform:none !important; opacity:1 !important; }" : ""} `; const list = document.createElement("div"); list.className = "chat"; @@ -528,6 +530,25 @@ return elements; } + function renderChatDock(root, module) { + if (!root) return null; + if (!module || (module.renderType || module.type) !== "chat") { + root.replaceChildren(); + return null; + } + const wrapper = document.createElement("section"); + wrapper.className = "lumi-overlay-module lumi-overlay-chat-dock"; + wrapper.dataset.moduleId = module.id; + wrapper.dataset.moduleType = module.type; + wrapper.dataset.renderType = "chat"; + wrapper.appendChild(buildChat({ + ...module, + config: { ...(module.config || {}), inAnimation: "none", outAnimation: "none", animationDurationMs: 0 } + }, { chatDock: true })); + root.replaceChildren(wrapper); + return wrapper; + } + function stageLayout(root, canvas, scaleOverride) { const width = Math.max(1, finite(canvas?.width, 1920)); const height = Math.max(1, finite(canvas?.height, 1080)); @@ -540,5 +561,5 @@ }; } - window.LumiOverlayRenderer = { anchors, applyBox, buildModule, findSourceContent, handleChatMessage, render, restartMediaElement, stageLayout }; + window.LumiOverlayRenderer = { anchors, applyBox, buildModule, findSourceContent, handleChatMessage, render, renderChatDock, restartMediaElement, stageLayout }; })(); diff --git a/src/web/views/admin-overlay-detail.ejs b/src/web/views/admin-overlay-detail.ejs index 57e7d5e..bda7a72 100644 --- a/src/web/views/admin-overlay-detail.ejs +++ b/src/web/views/admin-overlay-detail.ejs @@ -198,6 +198,7 @@
+ <% if (module.chat_dock_url) { %><% } %> <% }) %> diff --git a/src/web/views/overlay-chat-dock.ejs b/src/web/views/overlay-chat-dock.ejs new file mode 100644 index 0000000..f8bf27d --- /dev/null +++ b/src/web/views/overlay-chat-dock.ejs @@ -0,0 +1,17 @@ + + + + + + +