Add standalone OBS chat docks
This commit is contained in:
parent
f335bbe24a
commit
4bd337d5e3
@ -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.
|
||||
|
||||
4
TODO.md
4
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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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(/</g, "\\u003c")
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function requireOverlayAccess(capability = OVERLAY_CAPABILITIES.MANAGE) {
|
||||
@ -436,6 +480,14 @@ function registerOverlayAdminRoutes(app) {
|
||||
const canManageObs = canAccessOverlay(req.session.user, req.params.id, OVERLAY_CAPABILITIES.MANAGE_OBS);
|
||||
const overlay = getOverlay(req.params.id, { includeSecrets: canViewSecrets });
|
||||
if (!overlay) return res.status(404).render("error", { title: "Overlay not found", message: "That overlay does not exist." });
|
||||
if (canViewSecrets) {
|
||||
for (const module of overlay.scenes.flatMap((scene) => 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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`);
|
||||
|
||||
30
src/web/public/overlay-chat-dock.css
Normal file
30
src/web/public/overlay-chat-dock.css
Normal file
@ -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;
|
||||
}
|
||||
51
src/web/public/overlay-chat-dock.js
Normal file
51
src/web/public/overlay-chat-dock.js
Normal file
@ -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 {}
|
||||
});
|
||||
})();
|
||||
@ -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 };
|
||||
})();
|
||||
|
||||
@ -198,6 +198,7 @@
|
||||
</details>
|
||||
<div class="form-actions overlay-source-save-row"><button class="button" type="submit" data-source-save>Save source</button><button class="button subtle" type="button" data-source-revert>Undo unsaved changes</button><span class="hint" data-source-preview-status>Saved settings</span></div>
|
||||
</form>
|
||||
<% if (module.chat_dock_url) { %><details class="overlay-private-link"><summary>OBS chat dock</summary><p class="hint">Add this private page as a <strong>Custom Browser Dock</strong> in OBS. It uses this chat source's saved filters and appearance, fills any dock size, scrolls like a webpage, and does not animate messages.</p><div class="overlay-url-row"><input value="<%= module.chat_dock_url %>" readonly aria-label="Private OBS chat dock link" /><button type="button" class="button subtle" data-copy-value="<%= module.chat_dock_url %>">Copy dock link</button></div><div class="overlay-list-actions"><a class="button subtle" href="<%= module.chat_dock_url %>" target="_blank" rel="noreferrer">Open chat dock</a></div></details><% } %>
|
||||
<div class="overlay-module-actions"><button type="button" class="button subtle" data-select-source="<%= module.id %>">Select on canvas</button><form method="post" action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/duplicate" class="inline-form"><button class="button subtle" type="submit">Copy source</button></form><button type="button" class="button danger" data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete" data-confirm-mode="modal" data-confirm-title="Delete source" data-confirm-text="Delete this source?" data-confirm-label="Delete source">Delete source</button></div>
|
||||
</details>
|
||||
<% }) %>
|
||||
|
||||
17
src/web/views/overlay-chat-dock.ejs
Normal file
17
src/web/views/overlay-chat-dock.ejs
Normal file
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Lumi Chat Dock</title>
|
||||
<link rel="stylesheet" href="/overlay-chat-dock.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="lumi-chat-dock-root" aria-live="off"></main>
|
||||
<script id="lumi-chat-dock-state" type="application/json"><%- initialStateJson %></script>
|
||||
<div id="lumi-chat-dock-config" data-state-url="<%= statePath %>" data-events-url="<%= eventsPath %>" hidden></div>
|
||||
<script src="/overlay-renderer.js" defer></script>
|
||||
<script src="/overlay-chat-dock.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Lumi Core",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-19",
|
||||
"compatible_from": "0.1.9",
|
||||
@ -8,7 +8,7 @@
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"migration_notes": "Improves native chat preview focus, avatars, alignment, compact sizing, animations, and Discord emoji/GIF media. Existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.",
|
||||
"migration_notes": "Adds responsive standalone OBS chat docks backed by existing native chat settings and revocable credentials. Existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.",
|
||||
"rollback_safe": true,
|
||||
"requirements": [
|
||||
"Node.js 18 or newer"
|
||||
@ -217,6 +217,18 @@
|
||||
],
|
||||
"rollback_safe": true,
|
||||
"migration_notes": "Improves native chat preview focus, avatars, alignment, compact sizing, animations, and Discord emoji/GIF media; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
|
||||
},
|
||||
{
|
||||
"version": "0.2.17",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-19",
|
||||
"compatible_from": "0.1.9",
|
||||
"migration_kind": "patch",
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"rollback_safe": true,
|
||||
"migration_notes": "Adds responsive standalone OBS chat docks backed by existing native chat settings and revocable credentials; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user