Refine overlay chat previews and media
This commit is contained in:
parent
97e6615183
commit
f335bbe24a
@ -1,5 +1,12 @@
|
|||||||
# Lumi changelog
|
# Lumi changelog
|
||||||
|
|
||||||
|
## 0.2.16
|
||||||
|
|
||||||
|
- Kept keyboard focus in source fields while server-normalized live previews rerender, preventing interrupted typing and other continuous input.
|
||||||
|
- Added cached Twitch profile-image lookup and Discord custom/animated emoji, GIF, GIFV, attachment, and sticker rendering for native chat overlays.
|
||||||
|
- Made chat alignment apply to complete message groups, made message backgrounds fit their content, and added directional entry/exit animations.
|
||||||
|
- Expanded the editor simulation to cycle realistic short, wrapped, avatar, badge, platform, arrival, and departure examples continuously.
|
||||||
|
|
||||||
## 0.2.15
|
## 0.2.15
|
||||||
|
|
||||||
- Fixed Lumi's own Twitch, YouTube, and Discord messages being omitted from native chat overlays and added platform-specific plus linked-Lumi-account user blocking.
|
- Fixed Lumi's own Twitch, YouTube, and Discord messages being omitted from native chat overlays and added platform-specific plus linked-Lumi-account user blocking.
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@ -33,7 +33,10 @@ temporary website CSS), marks/reverts drafts, provides contrast and grid views,
|
|||||||
and keeps save actions reachable. Alert documents also retain transparent page
|
and keeps save actions reachable. Alert documents also retain transparent page
|
||||||
and app-root backgrounds after late DOM/style changes. Chat includes Lumi's own
|
and app-root backgrounds after late DOM/style changes. Chat includes Lumi's own
|
||||||
platform messages, image-based platform/badge marks, and server-enforced platform
|
platform messages, image-based platform/badge marks, and server-enforced platform
|
||||||
or linked-Lumi-account user blocking.
|
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.
|
||||||
|
|
||||||
Remaining work:
|
Remaining work:
|
||||||
|
|
||||||
|
|||||||
@ -74,16 +74,23 @@ accept one value per line and may be prefixed with the service, such as
|
|||||||
`twitch:cozycarnage` or `discord:community-chat`.
|
`twitch:cozycarnage` or `discord:community-chat`.
|
||||||
|
|
||||||
Chat sources support transparent source and message backgrounds, font family,
|
Chat sources support transparent source and message backgrounds, font family,
|
||||||
size, weight and alignment, name/text colors, avatars, badges, platform labels,
|
size, weight and whole-message alignment, name/text colors, avatars, badges, platform labels,
|
||||||
timestamps, message spacing, rounded corners, maximum visible messages,
|
timestamps, message spacing, rounded corners, maximum visible messages,
|
||||||
top-or-bottom placement, timeout removal, and separate entry and exit
|
top-or-bottom placement, timeout removal, and separate entry and exit
|
||||||
animations. Twitch emotes are displayed as images. The editor canvas supplies
|
animations. Message cards fit their contents up to the source boundary rather
|
||||||
sample messages so appearance can be configured before the stream is live.
|
than filling its width. Entry and exit slides can travel left, right, up, or
|
||||||
|
down. Twitch emotes and Discord custom or animated emoji are displayed as
|
||||||
|
images. Discord GIF links, GIF attachments, GIFV embeds, and stickers are shown
|
||||||
|
as bounded inline media at roughly two text lines tall. The editor continuously
|
||||||
|
cycles several short and wrapped sample messages so both static appearance and
|
||||||
|
entry/exit behavior can be configured before the stream is live.
|
||||||
|
|
||||||
Lumi includes messages sent by its own connected bot accounts. Platform marks
|
Lumi includes messages sent by its own connected bot accounts. Platform marks
|
||||||
are shown as icons, and chat badges are rendered as images. Twitch badge artwork
|
are shown as icons, and chat badges are rendered as images. Twitch badge artwork
|
||||||
is resolved from Twitch's global and channel badge catalogs; a local image
|
is resolved from Twitch's global and channel badge catalogs; a local image
|
||||||
fallback is used when a provider does not expose badge artwork.
|
fallback is used when a provider does not expose badge artwork. Twitch profile
|
||||||
|
images are resolved through the Twitch user catalog and cached to avoid a lookup
|
||||||
|
for every message.
|
||||||
|
|
||||||
The optional hidden-user list accepts one rule per line in the form
|
The optional hidden-user list accepts one rule per line in the form
|
||||||
`platform:username|user-id`. Twitch, YouTube, and Discord rules match only that
|
`platform:username|user-id`. Twitch, YouTube, and Discord rules match only that
|
||||||
@ -94,7 +101,8 @@ and is also checked by the renderer.
|
|||||||
|
|
||||||
Advanced CSS is isolated to that chat source and is appended after the visual
|
Advanced CSS is isolated to that chat source and is appended after the visual
|
||||||
settings. Stable selectors include `.chat`, `.message`, `.avatar`, `.content`,
|
settings. Stable selectors include `.chat`, `.message`, `.avatar`, `.content`,
|
||||||
`.meta`, `.platform`, `.author`, `.badge`, `.timestamp`, `.text`, and `.emote`.
|
`.meta`, `.platform`, `.author`, `.badge`, `.timestamp`, `.text`, `.emote`,
|
||||||
|
`.media-row`, and `.media`.
|
||||||
Messages arrive through the same token-scoped overlay event connection as scene
|
Messages arrive through the same token-scoped overlay event connection as scene
|
||||||
updates. Lumi keeps a small, short-lived in-memory replay window so a temporary
|
updates. Lumi keeps a small, short-lived in-memory replay window so a temporary
|
||||||
Browser Source reconnection can restore recent messages without writing chat
|
Browser Source reconnection can restore recent messages without writing chat
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Lumi is the core web UI and bot runtime.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.2.15
|
Version: 0.2.16
|
||||||
## Routes
|
## Routes
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
- POST /api/destructive-confirmations
|
- POST /api/destructive-confirmations
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.15",
|
"version": "0.2.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.15",
|
"version": "0.2.16",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.12",
|
"adm-zip": "^0.5.12",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.15",
|
"version": "0.2.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -2,6 +2,36 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "0.2.16",
|
||||||
|
"ref": "refs/tags/v0.2.16",
|
||||||
|
"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": "Improves native chat preview focus, avatars, compact alignment and sizing, directional animations, and Discord emoji/GIF media. 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.15",
|
"version": "0.2.15",
|
||||||
"ref": "refs/tags/v0.2.15",
|
"ref": "refs/tags/v0.2.15",
|
||||||
|
|||||||
@ -19,7 +19,8 @@ for (const file of [
|
|||||||
"overlays.js",
|
"overlays.js",
|
||||||
"overlay-permissions.js",
|
"overlay-permissions.js",
|
||||||
"overlay-connectors.js",
|
"overlay-connectors.js",
|
||||||
"overlay-chat.js"
|
"overlay-chat.js",
|
||||||
|
"discord-chat-content.js"
|
||||||
]) {
|
]) {
|
||||||
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
|
fs.copyFileSync(path.join(root, "src", "services", file), path.join(serviceDir, file));
|
||||||
}
|
}
|
||||||
@ -161,6 +162,8 @@ try {
|
|||||||
assert.strictEqual(chatConfig.background, "#0000");
|
assert.strictEqual(chatConfig.background, "#0000");
|
||||||
assert.strictEqual(chatConfig.inAnimation, "scale");
|
assert.strictEqual(chatConfig.inAnimation, "scale");
|
||||||
assert(chatConfig.customCss.includes(".message"));
|
assert(chatConfig.customCss.includes(".message"));
|
||||||
|
assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).inAnimation, "slide-right");
|
||||||
|
assert.strictEqual(overlayModules.normalizeModuleConfig("chat", { inAnimation: "slide-right", outAnimation: "slide-up" }).outAnimation, "slide-up");
|
||||||
assert.deepStrictEqual(overlayModules.normalizeModuleConfig("chat", {}).platforms, ["twitch", "youtube", "discord"]);
|
assert.deepStrictEqual(overlayModules.normalizeModuleConfig("chat", {}).platforms, ["twitch", "youtube", "discord"]);
|
||||||
const styledWebConfig = overlayModules.normalizeModuleConfig("web", {
|
const styledWebConfig = overlayModules.normalizeModuleConfig("web", {
|
||||||
url: "https://example.com/widget",
|
url: "https://example.com/widget",
|
||||||
@ -212,6 +215,25 @@ try {
|
|||||||
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["twitch:viewer|other-id"] }), true);
|
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["twitch:viewer|other-id"] }), true);
|
||||||
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["lumi:linked-user|other-id"] }), true);
|
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["lumi:linked-user|other-id"] }), true);
|
||||||
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["youtube:viewer"] }), false);
|
assert.strictEqual(overlayChat.messageBlockedByConfig(normalizedChat, { blockedUsers: ["youtube:viewer"] }), false);
|
||||||
|
const mediaOnlyChat = overlayChat.normalizeChatMessage({
|
||||||
|
id: "message-gif",
|
||||||
|
platform: "discord",
|
||||||
|
text: "",
|
||||||
|
media: [{ url: "https://cdn.example/dance.gif", type: "image", alt: "Dance" }],
|
||||||
|
author: { id: "discord-viewer", name: "Discord viewer" }
|
||||||
|
});
|
||||||
|
assert.strictEqual(mediaOnlyChat.media[0].url, "https://cdn.example/dance.gif");
|
||||||
|
assert.strictEqual(mediaOnlyChat.text, "");
|
||||||
|
const { discordOverlayContent } = require(path.join(serviceDir, "discord-chat-content.js"));
|
||||||
|
const discordContent = discordOverlayContent({
|
||||||
|
content: "Hello <a:party:123456> https://cdn.example/dance.gif",
|
||||||
|
attachments: new Map(),
|
||||||
|
embeds: [],
|
||||||
|
stickers: new Map()
|
||||||
|
});
|
||||||
|
assert.strictEqual(discordContent.text, "Hello <a:party:123456>");
|
||||||
|
assert.strictEqual(discordContent.emotes[0].image, "https://cdn.discordapp.com/emojis/123456.gif?size=96&quality=lossless");
|
||||||
|
assert.strictEqual(discordContent.media[0].url, "https://cdn.example/dance.gif");
|
||||||
overlayChat.publishOverlayChatMessage(normalizedChat);
|
overlayChat.publishOverlayChatMessage(normalizedChat);
|
||||||
assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1"));
|
assert(overlayChat.recentOverlayChatMessages(created.id).some((event) => event.payload.id === "message-1"));
|
||||||
const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id);
|
const websiteState = overlays.buildPublicState(created.id, created.scenes[0].id);
|
||||||
@ -291,6 +313,7 @@ try {
|
|||||||
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
|
assert(editorView.includes('data-confirm-action="/admin/overlays/<%= overlay.id %>/modules/<%= module.id %>/delete"'), "source deletion must use the shared timed-confirmation action flow");
|
||||||
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
|
assert(editorScript.includes("data-resize-handle") && editorScript.includes("snapTargets"));
|
||||||
assert(editorScript.includes("previewForm(form)") && editorScript.includes("Live preview · not saved"));
|
assert(editorScript.includes("previewForm(form)") && editorScript.includes("Live preview · not saved"));
|
||||||
|
assert(editorScript.includes("focusCanvas = true") && editorScript.includes("focusCanvas: false"), "draft rerenders must preserve form focus");
|
||||||
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
|
assert(editorScript.includes("--overlay-settings-max-height"), "settings height must follow its viewport position");
|
||||||
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
|
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
|
||||||
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
|
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
|
||||||
@ -299,6 +322,10 @@ try {
|
|||||||
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
|
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
|
||||||
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
|
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
|
||||||
assert(rendererScript.includes("/icons/badges/"));
|
assert(rendererScript.includes("/icons/badges/"));
|
||||||
|
assert(rendererScript.includes("previewChatMessages") && rendererScript.includes("schedulePreviewChat"));
|
||||||
|
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"'));
|
||||||
assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")'));
|
assert(rendererScript.includes('frame.setAttribute("allow", "autoplay; fullscreen")'));
|
||||||
assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")'));
|
assert(rendererScript.includes('frame.setAttribute("allowtransparency", "true")'));
|
||||||
assert(rendererScript.includes("values.renderUrl || values.url"));
|
assert(rendererScript.includes("values.renderUrl || values.url"));
|
||||||
@ -323,7 +350,8 @@ try {
|
|||||||
assert.strictEqual(obsLayout.scaleX, 1);
|
assert.strictEqual(obsLayout.scaleX, 1);
|
||||||
assert.strictEqual(obsLayout.scaleY, 1);
|
assert.strictEqual(obsLayout.scaleY, 1);
|
||||||
assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report"));
|
assert(connectorScript.includes('id: "obs_browser_bridge"') && connectorScript.includes("acceptReport(report"));
|
||||||
assert(twitchBadgeScript.includes("api.twitch.tv/helix/chat/badges") && twitchBadgeScript.includes("image_url_2x"));
|
assert(twitchBadgeScript.includes("api.twitch.tv/helix/${path}") && twitchBadgeScript.includes("chat/badges") && twitchBadgeScript.includes("image_url_2x"));
|
||||||
|
assert(twitchBadgeScript.includes("resolveTwitchAvatar") && twitchBadgeScript.includes("profile_image_url") && twitchBadgeScript.includes("users?${query}"));
|
||||||
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
|
assert(routeScript.includes("/obs-bridge") && routeScript.includes("/obs/import"));
|
||||||
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
|
assert(routeScript.includes('/overlay-web/:ticket') && routeScript.includes("loadInjectedOverlayDocument"));
|
||||||
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));
|
assert(routeScript.includes("/preview") && routeScript.includes("web-preview/:ticket"));
|
||||||
|
|||||||
@ -4,8 +4,8 @@ const path = require("path");
|
|||||||
const { findSafeTarget } = require("../src/services/versioning");
|
const { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.2.15";
|
const releaseVersion = "0.2.16";
|
||||||
const previousCoreVersion = "0.2.14";
|
const previousCoreVersion = "0.2.15";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const changedPlugins = {
|
const changedPlugins = {
|
||||||
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
|
"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(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
||||||
|
|
||||||
console.log("Release metadata verification passed: core 0.2.15, Lumi AI 0.8.5, and synchronized package metadata.");
|
console.log("Release metadata verification passed: core 0.2.16, Lumi AI 0.8.5, and synchronized package metadata.");
|
||||||
|
|||||||
@ -16,7 +16,7 @@ function readJson(relativePath) {
|
|||||||
|
|
||||||
const releaseIndex = readJson("release-index.json");
|
const releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
||||||
assert.deepEqual(releaseVersions, ["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.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");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
|||||||
const baseTarget = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
available_versions: [
|
||||||
|
{ 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.15", ref: "refs/tags/v0.2.15", rollback_safe: true },
|
||||||
{ version: "0.2.14", ref: "refs/tags/v0.2.14", rollback_safe: true },
|
{ version: "0.2.14", ref: "refs/tags/v0.2.14", rollback_safe: true },
|
||||||
{ version: "0.2.13", ref: "refs/tags/v0.2.13", rollback_safe: true },
|
{ version: "0.2.13", ref: "refs/tags/v0.2.13", rollback_safe: true },
|
||||||
@ -75,7 +76,7 @@ const corrected = buildStatus({
|
|||||||
channel: "stable"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
assert.equal(corrected.version_correction, true);
|
||||||
assert.equal(corrected.safe_target_version, "0.2.15");
|
assert.equal(corrected.safe_target_version, "0.2.16");
|
||||||
assert.equal(corrected.update_available, true);
|
assert.equal(corrected.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
59
src/services/discord-chat-content.js
Normal file
59
src/services/discord-chat-content.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
function collectionValues(collection) {
|
||||||
|
if (!collection) return [];
|
||||||
|
if (Array.isArray(collection)) return collection;
|
||||||
|
if (typeof collection.values === "function") return [...collection.values()];
|
||||||
|
return Object.values(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discordOverlayContent(message) {
|
||||||
|
let text = String(message?.content || "");
|
||||||
|
const media = [];
|
||||||
|
const mediaUrls = new Set();
|
||||||
|
const removedLinks = new Set();
|
||||||
|
const addMedia = (url, type = "image", alt = "Animated image", sourceUrl = "") => {
|
||||||
|
const clean = String(url || "").trim();
|
||||||
|
if (!/^https?:\/\//i.test(clean) || mediaUrls.has(clean) || media.length >= 4) return;
|
||||||
|
mediaUrls.add(clean);
|
||||||
|
media.push({ url: clean, type: type === "video" ? "video" : "image", alt });
|
||||||
|
if (sourceUrl && text.includes(sourceUrl)) removedLinks.add(sourceUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const attachment of collectionValues(message?.attachments)) {
|
||||||
|
const url = attachment?.url || attachment?.proxyURL;
|
||||||
|
const contentType = String(attachment?.contentType || attachment?.content_type || "").toLowerCase();
|
||||||
|
if (contentType === "image/gif" || /\.gif(?:$|\?)/i.test(String(url || ""))) {
|
||||||
|
addMedia(url, "image", attachment?.name || "Animated image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const embed of collectionValues(message?.embeds)) {
|
||||||
|
const sourceUrl = String(embed?.url || "");
|
||||||
|
const embedType = String(embed?.type || "").toLowerCase();
|
||||||
|
if (embedType !== "gifv" && !/\.(?:gif|gifv)(?:$|\?)/i.test(sourceUrl)) continue;
|
||||||
|
const videoUrl = embed?.video?.url || embed?.video?.proxyURL || embed?.video?.proxy_url;
|
||||||
|
const imageUrl = embed?.image?.url || embed?.image?.proxyURL || embed?.image?.proxy_url || embed?.thumbnail?.url || embed?.thumbnail?.proxyURL || embed?.thumbnail?.proxy_url;
|
||||||
|
if (videoUrl) addMedia(videoUrl, "video", embed?.title || "Animated image", sourceUrl);
|
||||||
|
else addMedia(imageUrl || sourceUrl, "image", embed?.title || "Animated image", sourceUrl);
|
||||||
|
}
|
||||||
|
for (const sticker of collectionValues(message?.stickers)) {
|
||||||
|
addMedia(sticker?.url, "image", sticker?.name || "Sticker");
|
||||||
|
}
|
||||||
|
for (const match of text.matchAll(/https?:\/\/[^\s<>]+\.gif(?:\?[^\s<>]*)?/gi)) {
|
||||||
|
addMedia(match[0], "image", "Animated image", match[0]);
|
||||||
|
}
|
||||||
|
for (const link of removedLinks) text = text.split(link).join("");
|
||||||
|
text = text.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").trim();
|
||||||
|
|
||||||
|
const emotes = [];
|
||||||
|
for (const match of text.matchAll(/<(a?):([a-zA-Z0-9_]+):(\d+)>/g)) {
|
||||||
|
const animated = match[1] === "a";
|
||||||
|
emotes.push({
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length - 1,
|
||||||
|
image: `https://cdn.discordapp.com/emojis/${match[3]}.${animated ? "gif" : "webp"}?size=96&quality=lossless`,
|
||||||
|
label: `:${match[2]}:`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { text, emotes, media };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { discordOverlayContent };
|
||||||
@ -9,6 +9,7 @@ const { incrementMessages } = require("./stats");
|
|||||||
const { ensureUserForIdentity } = require("./users");
|
const { ensureUserForIdentity } = require("./users");
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
|
const { discordOverlayContent } = require("./discord-chat-content");
|
||||||
|
|
||||||
const discordLog = createLogger("platform:discord", { category: "integration" });
|
const discordLog = createLogger("platform:discord", { category: "integration" });
|
||||||
|
|
||||||
@ -53,6 +54,7 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
|
|
||||||
client.on("messageCreate", async (message) => {
|
client.on("messageCreate", async (message) => {
|
||||||
if (!message.guild) return;
|
if (!message.guild) return;
|
||||||
|
const overlayContent = discordOverlayContent(message);
|
||||||
const displayName =
|
const displayName =
|
||||||
message.author.globalName || message.author.username || message.author.tag;
|
message.author.globalName || message.author.username || message.author.tag;
|
||||||
let avatarUrl = null;
|
let avatarUrl = null;
|
||||||
@ -72,7 +74,9 @@ async function startBot({ commandRouter } = {}) {
|
|||||||
publishOverlayChatMessage({
|
publishOverlayChatMessage({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
platform: "discord",
|
platform: "discord",
|
||||||
text: message.content,
|
text: overlayContent.text,
|
||||||
|
emotes: overlayContent.emotes,
|
||||||
|
media: overlayContent.media,
|
||||||
timestamp: message.createdTimestamp,
|
timestamp: message.createdTimestamp,
|
||||||
channel: {
|
channel: {
|
||||||
id: message.channel?.id,
|
id: message.channel?.id,
|
||||||
|
|||||||
@ -46,11 +46,22 @@ function normalizeEmote(emote) {
|
|||||||
return image ? { start, end, image, label: cleanText(emote?.label, 100) } : null;
|
return image ? { start, end, image, label: cleanText(emote?.label, 100) } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMedia(media) {
|
||||||
|
const url = cleanUrl(media?.url);
|
||||||
|
if (!url) return null;
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
type: media?.type === "video" ? "video" : "image",
|
||||||
|
alt: cleanText(media?.alt || "Animated image", 160)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeChatMessage(input = {}) {
|
function normalizeChatMessage(input = {}) {
|
||||||
const platform = cleanText(input.platform, 24).toLowerCase();
|
const platform = cleanText(input.platform, 24).toLowerCase();
|
||||||
const text = cleanMessageText(input.text, 2000);
|
const text = cleanMessageText(input.text, 2000);
|
||||||
const authorId = cleanText(input.author?.id, 160);
|
const authorId = cleanText(input.author?.id, 160);
|
||||||
if (!platform || !text.trim() || !authorId) return null;
|
const media = (Array.isArray(input.media) ? input.media : []).map(normalizeMedia).filter(Boolean).slice(0, 4);
|
||||||
|
if (!platform || (!text.trim() && !media.length) || !authorId) return null;
|
||||||
const channelId = cleanText(input.channel?.id, 256);
|
const channelId = cleanText(input.channel?.id, 256);
|
||||||
const channelName = cleanText(input.channel?.name, 256);
|
const channelName = cleanText(input.channel?.name, 256);
|
||||||
const channelKey = cleanText(input.channel?.key || channelName || channelId, 256);
|
const channelKey = cleanText(input.channel?.key || channelName || channelId, 256);
|
||||||
@ -61,6 +72,7 @@ function normalizeChatMessage(input = {}) {
|
|||||||
timestamp: Number.isFinite(Number(input.timestamp)) ? Number(input.timestamp) : Date.now(),
|
timestamp: Number.isFinite(Number(input.timestamp)) ? Number(input.timestamp) : Date.now(),
|
||||||
channel: { id: channelId, name: channelName, key: channelKey },
|
channel: { id: channelId, name: channelName, key: channelKey },
|
||||||
emotes: (Array.isArray(input.emotes) ? input.emotes : []).map(normalizeEmote).filter(Boolean).slice(0, 100),
|
emotes: (Array.isArray(input.emotes) ? input.emotes : []).map(normalizeEmote).filter(Boolean).slice(0, 100),
|
||||||
|
media,
|
||||||
author: {
|
author: {
|
||||||
id: authorId,
|
id: authorId,
|
||||||
name: cleanText(input.author?.name || input.author?.username || "Viewer", 160),
|
name: cleanText(input.author?.name || input.author?.username || "Viewer", 160),
|
||||||
|
|||||||
@ -166,8 +166,8 @@ registerOverlayModuleType({
|
|||||||
showBadges: input.showBadges !== false && input.showBadges !== "false" && input.showBadges !== "off",
|
showBadges: input.showBadges !== false && input.showBadges !== "false" && input.showBadges !== "off",
|
||||||
showPlatform: input.showPlatform !== false && input.showPlatform !== "false" && input.showPlatform !== "off",
|
showPlatform: input.showPlatform !== false && input.showPlatform !== "false" && input.showPlatform !== "off",
|
||||||
showTimestamp: input.showTimestamp === true || input.showTimestamp === "on" || input.showTimestamp === "true",
|
showTimestamp: input.showTimestamp === true || input.showTimestamp === "on" || input.showTimestamp === "true",
|
||||||
inAnimation: ["none", "fade", "slide-up", "slide-left", "scale"].includes(input.inAnimation) ? input.inAnimation : "slide-up",
|
inAnimation: ["none", "fade", "scale", "slide-left", "slide-right", "slide-up", "slide-down"].includes(input.inAnimation) ? input.inAnimation : "slide-up",
|
||||||
outAnimation: ["none", "fade", "slide-left", "slide-down"].includes(input.outAnimation) ? input.outAnimation : "fade",
|
outAnimation: ["none", "fade", "scale", "slide-left", "slide-right", "slide-up", "slide-down"].includes(input.outAnimation) ? input.outAnimation : "fade",
|
||||||
animationDurationMs: Math.round(number(input.animationDurationMs, 350, 0, 3000)),
|
animationDurationMs: Math.round(number(input.animationDurationMs, 350, 0, 3000)),
|
||||||
customCss: text(input.customCss, "", 12000)
|
customCss: text(input.customCss, "", 12000)
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,20 +1,23 @@
|
|||||||
const { getSetting } = require("./settings");
|
const { getSetting } = require("./settings");
|
||||||
|
|
||||||
const badgeCatalogs = new Map();
|
const badgeCatalogs = new Map();
|
||||||
|
const userProfiles = new Map();
|
||||||
const CACHE_MS = 30 * 60 * 1000;
|
const CACHE_MS = 30 * 60 * 1000;
|
||||||
|
const USER_CACHE_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const USER_FAILURE_CACHE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
function fallbackBadges(badges = {}) {
|
function fallbackBadges(badges = {}) {
|
||||||
return Object.entries(badges).map(([label]) => ({ label, image: null }));
|
return Object.entries(badges).map(([label]) => ({ label, image: null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function badgeRequest(path) {
|
async function helixRequest(path) {
|
||||||
const clientId = String(getSetting("twitch_client_id", "") || "").trim();
|
const clientId = String(getSetting("twitch_client_id", "") || "").trim();
|
||||||
const token = String(getSetting("twitch_bot_oauth", "") || "").trim().replace(/^oauth:/i, "");
|
const token = String(getSetting("twitch_bot_oauth", "") || "").trim().replace(/^oauth:/i, "");
|
||||||
if (!clientId || !token) throw new Error("Twitch badge credentials are unavailable.");
|
if (!clientId || !token) throw new Error("Twitch API credentials are unavailable.");
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 2500);
|
const timeout = setTimeout(() => controller.abort(), 2500);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.twitch.tv/helix/chat/badges${path}`, {
|
const response = await fetch(`https://api.twitch.tv/helix/${path}`, {
|
||||||
headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` },
|
headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` },
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
@ -25,6 +28,10 @@ async function badgeRequest(path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function badgeRequest(path) {
|
||||||
|
return helixRequest(`chat/badges${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
function indexCatalog(payload) {
|
function indexCatalog(payload) {
|
||||||
const catalog = new Map();
|
const catalog = new Map();
|
||||||
for (const set of payload?.data || []) {
|
for (const set of payload?.data || []) {
|
||||||
@ -68,4 +75,23 @@ async function resolveTwitchBadges(roomId, badges = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { resolveTwitchBadges };
|
async function resolveTwitchAvatar(userId, username) {
|
||||||
|
const id = String(userId || "").trim();
|
||||||
|
const login = String(username || "").trim().toLowerCase();
|
||||||
|
if (!id && !login) return null;
|
||||||
|
const key = id ? `id:${id}` : `login:${login}`;
|
||||||
|
const cached = userProfiles.get(key);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.promise;
|
||||||
|
const query = id ? `id=${encodeURIComponent(id)}` : `login=${encodeURIComponent(login)}`;
|
||||||
|
const promise = helixRequest(`users?${query}`)
|
||||||
|
.then((payload) => payload?.data?.[0]?.profile_image_url || null)
|
||||||
|
.catch(() => null);
|
||||||
|
const result = { expiresAt: Date.now() + USER_CACHE_MS, promise };
|
||||||
|
userProfiles.set(key, result);
|
||||||
|
promise.then((avatar) => {
|
||||||
|
if (!avatar) result.expiresAt = Date.now() + USER_FAILURE_CACHE_MS;
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveTwitchAvatar, resolveTwitchBadges };
|
||||||
|
|||||||
@ -4,7 +4,7 @@ const { incrementMessages } = require("./stats");
|
|||||||
const { ensureUserForIdentity } = require("./users");
|
const { ensureUserForIdentity } = require("./users");
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
const { publishOverlayChatMessage } = require("./overlay-chat");
|
const { publishOverlayChatMessage } = require("./overlay-chat");
|
||||||
const { resolveTwitchBadges } = require("./twitch-chat-assets");
|
const { resolveTwitchAvatar, resolveTwitchBadges } = require("./twitch-chat-assets");
|
||||||
|
|
||||||
const twitchLog = createLogger("platform:twitch", { category: "integration" });
|
const twitchLog = createLogger("platform:twitch", { category: "integration" });
|
||||||
|
|
||||||
@ -46,12 +46,16 @@ async function startTwitchBot({ commandRouter } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const displayName = tags["display-name"] || tags.username || username;
|
const displayName = tags["display-name"] || tags.username || username;
|
||||||
|
const [badges, avatar] = await Promise.all([
|
||||||
|
resolveTwitchBadges(tags["room-id"], tags.badges || {}),
|
||||||
|
resolveTwitchAvatar(tags["user-id"], tags.username || username)
|
||||||
|
]);
|
||||||
const profile = self ? null : ensureUserForIdentity({
|
const profile = self ? null : ensureUserForIdentity({
|
||||||
provider: "twitch",
|
provider: "twitch",
|
||||||
providerUserId: userId,
|
providerUserId: userId,
|
||||||
displayName
|
displayName,
|
||||||
|
avatar
|
||||||
});
|
});
|
||||||
const badges = await resolveTwitchBadges(tags["room-id"], tags.badges || {});
|
|
||||||
publishOverlayChatMessage({
|
publishOverlayChatMessage({
|
||||||
id: tags.id,
|
id: tags.id,
|
||||||
platform: "twitch",
|
platform: "twitch",
|
||||||
@ -62,6 +66,7 @@ async function startTwitchBot({ commandRouter } = {}) {
|
|||||||
id: userId,
|
id: userId,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
username: tags.username,
|
username: tags.username,
|
||||||
|
avatar,
|
||||||
color: tags.color,
|
color: tags.color,
|
||||||
badges,
|
badges,
|
||||||
lumi: profile ? { id: profile.id, username: profile.internal_username } : null
|
lumi: profile ? { id: profile.id, username: profile.internal_username } : null
|
||||||
|
|||||||
1
src/web/public/icons/avatars/preview-cyan.svg
Normal file
1
src/web/public/icons/avatars/preview-cyan.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#164e63"/><circle cx="32" cy="24" r="12" fill="#cffafe"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#22d3ee"/></svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
1
src/web/public/icons/avatars/preview-gold.svg
Normal file
1
src/web/public/icons/avatars/preview-gold.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#713f12"/><circle cx="32" cy="24" r="12" fill="#fef3c7"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#fbbf24"/></svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
1
src/web/public/icons/avatars/preview-pink.svg
Normal file
1
src/web/public/icons/avatars/preview-pink.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#831843"/><circle cx="32" cy="24" r="12" fill="#f9a8d4"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#f472b6"/></svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
1
src/web/public/icons/avatars/preview-purple.svg
Normal file
1
src/web/public/icons/avatars/preview-purple.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#4c1d95"/><circle cx="32" cy="24" r="12" fill="#ddd6fe"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#a78bfa"/></svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
1
src/web/public/icons/avatars/preview-red.svg
Normal file
1
src/web/public/icons/avatars/preview-red.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="32" fill="#881337"/><circle cx="32" cy="24" r="12" fill="#fecdd3"/><path d="M10 59c3-14 11-21 22-21s19 7 22 21" fill="#fb7185"/></svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
@ -273,7 +273,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function selectSource(moduleId, { openSettings = false } = {}) {
|
function selectSource(moduleId, { openSettings = false, focusCanvas = true } = {}) {
|
||||||
selectedId = moduleId;
|
selectedId = moduleId;
|
||||||
layer.querySelectorAll(".lumi-overlay-module").forEach((element) => element.classList.toggle("is-selected", element.dataset.moduleId === moduleId));
|
layer.querySelectorAll(".lumi-overlay-module").forEach((element) => element.classList.toggle("is-selected", element.dataset.moduleId === moduleId));
|
||||||
sourceList.querySelectorAll("[data-canvas-select]").forEach((button) => button.classList.toggle("is-selected", button.dataset.canvasSelect === moduleId));
|
sourceList.querySelectorAll("[data-canvas-select]").forEach((button) => button.classList.toggle("is-selected", button.dataset.canvasSelect === moduleId));
|
||||||
@ -282,7 +282,7 @@
|
|||||||
const card = document.querySelector(`[data-module-card="${CSS.escape(moduleId)}"]`);
|
const card = document.querySelector(`[data-module-card="${CSS.escape(moduleId)}"]`);
|
||||||
if (card) card.open = true;
|
if (card) card.open = true;
|
||||||
}
|
}
|
||||||
canvas.focus({ preventScroll: true });
|
if (focusCanvas) canvas.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCanvas() {
|
function renderCanvas() {
|
||||||
@ -300,7 +300,7 @@
|
|||||||
sourceList.appendChild(button);
|
sourceList.appendChild(button);
|
||||||
}
|
}
|
||||||
if (!elements.has(selectedId)) selectedId = elements.keys().next().value || null;
|
if (!elements.has(selectedId)) selectedId = elements.keys().next().value || null;
|
||||||
if (selectedId) selectSource(selectedId);
|
if (selectedId) selectSource(selectedId, { focusCanvas: false });
|
||||||
else updateReadout();
|
else updateReadout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -225,23 +225,47 @@
|
|||||||
timestamp.textContent = new Date(payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
timestamp.textContent = new Date(payload.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
meta.appendChild(timestamp);
|
meta.appendChild(timestamp);
|
||||||
}
|
}
|
||||||
const text = document.createElement("div");
|
const messageText = String(payload.text || "");
|
||||||
text.className = "text";
|
if (messageText) {
|
||||||
let cursor = 0;
|
const text = document.createElement("div");
|
||||||
const emotes = [...(payload.emotes || [])].sort((a, b) => a.start - b.start);
|
text.className = "text";
|
||||||
for (const emoteData of emotes) {
|
let cursor = 0;
|
||||||
if (emoteData.start < cursor || emoteData.start >= payload.text.length) continue;
|
const emotes = [...(payload.emotes || [])].sort((a, b) => a.start - b.start);
|
||||||
text.append(document.createTextNode(payload.text.slice(cursor, emoteData.start)));
|
for (const emoteData of emotes) {
|
||||||
const emote = document.createElement("img");
|
if (emoteData.start < cursor || emoteData.start >= messageText.length) continue;
|
||||||
emote.className = "emote";
|
text.append(document.createTextNode(messageText.slice(cursor, emoteData.start)));
|
||||||
emote.src = emoteData.image;
|
const emote = document.createElement("img");
|
||||||
emote.alt = payload.text.slice(emoteData.start, Math.min(payload.text.length, emoteData.end + 1)) || emoteData.label || "Emote";
|
emote.className = "emote";
|
||||||
emote.referrerPolicy = "no-referrer";
|
emote.src = emoteData.image;
|
||||||
text.appendChild(emote);
|
emote.alt = emoteData.label || messageText.slice(emoteData.start, Math.min(messageText.length, emoteData.end + 1)) || "Emote";
|
||||||
cursor = Math.min(payload.text.length, emoteData.end + 1);
|
emote.referrerPolicy = "no-referrer";
|
||||||
|
text.appendChild(emote);
|
||||||
|
cursor = Math.min(messageText.length, emoteData.end + 1);
|
||||||
|
}
|
||||||
|
text.append(document.createTextNode(messageText.slice(cursor)));
|
||||||
|
content.appendChild(text);
|
||||||
}
|
}
|
||||||
text.append(document.createTextNode(payload.text.slice(cursor)));
|
if (payload.media?.length) {
|
||||||
content.append(meta, text);
|
const mediaRow = document.createElement("div");
|
||||||
|
mediaRow.className = "media-row";
|
||||||
|
for (const mediaData of payload.media) {
|
||||||
|
const media = document.createElement(mediaData.type === "video" ? "video" : "img");
|
||||||
|
media.className = "media";
|
||||||
|
media.src = mediaData.url;
|
||||||
|
media.setAttribute("aria-label", mediaData.alt || "Animated image");
|
||||||
|
media.referrerPolicy = "no-referrer";
|
||||||
|
if (media.tagName === "VIDEO") {
|
||||||
|
media.autoplay = true;
|
||||||
|
media.loop = true;
|
||||||
|
media.muted = true;
|
||||||
|
media.playsInline = true;
|
||||||
|
} else media.alt = mediaData.alt || "Animated image";
|
||||||
|
media.addEventListener("error", () => media.remove(), { once: true });
|
||||||
|
mediaRow.appendChild(media);
|
||||||
|
}
|
||||||
|
content.appendChild(mediaRow);
|
||||||
|
}
|
||||||
|
content.prepend(meta);
|
||||||
row.appendChild(content);
|
row.appendChild(content);
|
||||||
|
|
||||||
if (animate && values.inAnimation !== "none" && Number(values.animationDurationMs) > 0) {
|
if (animate && values.inAnimation !== "none" && Number(values.animationDurationMs) > 0) {
|
||||||
@ -272,32 +296,83 @@
|
|||||||
setTimeout(() => removeChatMessage(moduleId, payload.id), remaining);
|
setTimeout(() => removeChatMessage(moduleId, payload.id), remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewChatMessages = [
|
||||||
|
{ platform: "twitch", text: "Hi chat!", author: { name: "CozyViewer", color: "#f472b6", avatar: "/icons/avatars/preview-pink.svg", badges: [{ label: "Subscriber", image: null }] } },
|
||||||
|
{ platform: "discord", text: "The badges, avatar, and service icon move together.", author: { name: "PackMember", color: "#a78bfa", avatar: "/icons/avatars/preview-purple.svg", badges: [{ label: "Moderator", image: null }] } },
|
||||||
|
{ platform: "youtube", text: "Short message", author: { name: "Lumi Fan", color: "#fb7185", avatar: "/icons/avatars/preview-red.svg", badges: [{ label: "Member", image: null }] } },
|
||||||
|
{ platform: "twitch", text: "This longer sample wraps naturally so you can compare it with compact one-line messages.", author: { name: "StreamFriend", color: "#22d3ee", avatar: "/icons/avatars/preview-cyan.svg", badges: [] } },
|
||||||
|
{ platform: "discord", text: "Animated arrivals and departures repeat in this preview.", author: { name: "Lumi", color: "#67e8f9", avatar: "/icons/platforms/lumi.svg", badges: [{ label: "Owner", image: null }] } },
|
||||||
|
{ platform: "youtube", text: "Looks good!", author: { name: "ChatRegular", color: "#fbbf24", avatar: "/icons/avatars/preview-gold.svg", badges: [] } }
|
||||||
|
];
|
||||||
|
|
||||||
|
function nextPreviewMessage(moduleId, state) {
|
||||||
|
const allowed = previewChatMessages.filter((message) => (state.values.platforms || []).includes(message.platform));
|
||||||
|
const samples = allowed.length ? allowed : previewChatMessages;
|
||||||
|
const sample = samples[state.previewIndex % samples.length];
|
||||||
|
state.previewIndex += 1;
|
||||||
|
return {
|
||||||
|
...sample,
|
||||||
|
id: `${moduleId}-preview-${state.previewIndex}-${Date.now()}`,
|
||||||
|
preview: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
channel: { name: sample.platform === "discord" ? "#community" : "Live chat" },
|
||||||
|
author: { ...sample.author }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePreviewChat(moduleId, state) {
|
||||||
|
clearTimeout(state.previewTimer);
|
||||||
|
const delay = Math.max(1400, Number(state.values.animationDurationMs || 0) * 2 + 500);
|
||||||
|
state.previewTimer = setTimeout(() => {
|
||||||
|
if (!state.editor || !state.list?.isConnected) {
|
||||||
|
state.previewTimer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = nextPreviewMessage(moduleId, state);
|
||||||
|
state.messages.push(payload);
|
||||||
|
const row = chatMessageElement(payload, state.values);
|
||||||
|
if (state.values.newestAt === "top") state.list.prepend(row);
|
||||||
|
else state.list.appendChild(row);
|
||||||
|
const visibleLimit = Math.max(1, Math.min(Number(state.values.maxMessages || 8), 5));
|
||||||
|
while (state.messages.length > visibleLimit) removeChatMessage(moduleId, state.messages[0].id);
|
||||||
|
schedulePreviewChat(moduleId, state);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
function buildChat(module, values, options) {
|
function buildChat(module, values, options) {
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
root.className = "lumi-overlay-chat-root";
|
root.className = "lumi-overlay-chat-root";
|
||||||
const shadow = root.attachShadow({ mode: "open" });
|
const shadow = root.attachShadow({ mode: "open" });
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
const chatCustomCss = values.customCss || "";
|
const chatCustomCss = values.customCss || "";
|
||||||
|
const alignment = values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start";
|
||||||
style.textContent = `
|
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}; }
|
: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; justify-content:${values.newestAt === "bottom" ? "flex-end" : "flex-start"}; gap:${values.gap}px; padding:${values.padding}px; overflow:hidden; text-align:${values.horizontalAlign}; }
|
.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}; }
|
||||||
.message { display:flex; align-items:flex-start; gap:.55em; box-sizing:border-box; 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; }
|
.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; }
|
.avatar { width:1.85em; height:1.85em; flex:0 0 auto; border-radius:50%; object-fit:cover; }
|
||||||
.content { min-width:0; flex:1; }
|
.content { min-width:0; max-width:100%; flex:0 1 auto; }
|
||||||
.meta { display:flex; align-items:center; justify-content:${values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start"}; gap:.4em; min-width:0; margin-bottom:.12em; font-size:.78em; line-height:1.15; }
|
.meta { display:flex; align-items:center; justify-content:${alignment}; gap:.4em; min-width:0; margin-bottom:.12em; font-size:.78em; line-height:1.15; }
|
||||||
.author { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:800; }
|
.author { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:800; }
|
||||||
.platform { flex:0 0 auto; width:1.05em; height:1.05em; border-radius:.24em; object-fit:contain; }
|
.platform { flex:0 0 auto; width:1.05em; height:1.05em; border-radius:.24em; object-fit:contain; }
|
||||||
.badge { flex:0 0 auto; width:1.1em; height:1.1em; border-radius:.2em; object-fit:contain; }
|
.badge { flex:0 0 auto; width:1.1em; height:1.1em; border-radius:.2em; object-fit:contain; }
|
||||||
.timestamp { flex:0 0 auto; margin-left:auto; opacity:.65; font-size:.85em; }
|
.timestamp { flex:0 0 auto; opacity:.65; font-size:.85em; }
|
||||||
.text { overflow-wrap:anywhere; white-space:pre-wrap; }
|
.text { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||||
.emote { display:inline-block; width:auto; height:1.35em; margin:0 .08em; vertical-align:-.3em; object-fit:contain; }
|
.emote { display:inline-block; width:auto; height:1.35em; margin:0 .08em; vertical-align:-.3em; object-fit:contain; }
|
||||||
|
.media-row { display:flex; flex-wrap:wrap; justify-content:${alignment}; gap:.3em; margin-top:.2em; max-width:100%; }
|
||||||
|
.media { display:block; width:auto; height:${Math.max(1.6, Number(values.lineHeight || 1.25) * 2)}em; max-width:100%; border-radius:.25em; object-fit:contain; }
|
||||||
.enter-fade, .leave-active { opacity:0; }
|
.enter-fade, .leave-active { opacity:0; }
|
||||||
.enter-slide-up { opacity:0; transform:translateY(1em); }
|
.enter-slide-left { opacity:0; transform:translateX(-1.5em); }
|
||||||
.enter-slide-left { opacity:0; transform:translateX(1.5em); }
|
.enter-slide-right { opacity:0; transform:translateX(1.5em); }
|
||||||
|
.enter-slide-up { opacity:0; transform:translateY(-1em); }
|
||||||
|
.enter-slide-down { opacity:0; transform:translateY(1em); }
|
||||||
.enter-scale { opacity:0; transform:scale(.92); }
|
.enter-scale { opacity:0; transform:scale(.92); }
|
||||||
.enter-active { opacity:1; transform:none; }
|
.enter-active { opacity:1; transform:none; }
|
||||||
.leave-slide-left.leave-active { transform:translateX(-1.5em); }
|
.leave-slide-left.leave-active { transform:translateX(-1.5em); }
|
||||||
|
.leave-slide-right.leave-active { transform:translateX(1.5em); }
|
||||||
|
.leave-slide-up.leave-active { transform:translateY(-1em); }
|
||||||
.leave-slide-down.leave-active { transform:translateY(1em); }
|
.leave-slide-down.leave-active { transform:translateY(1em); }
|
||||||
|
.leave-scale.leave-active { transform:scale(.92); }
|
||||||
${chatCustomCss}
|
${chatCustomCss}
|
||||||
`;
|
`;
|
||||||
const list = document.createElement("div");
|
const list = document.createElement("div");
|
||||||
@ -307,24 +382,28 @@
|
|||||||
shadow.append(style, list);
|
shadow.append(style, list);
|
||||||
|
|
||||||
let state = chatStates.get(module.id);
|
let state = chatStates.get(module.id);
|
||||||
if (!state) state = { messages: [] };
|
if (!state) state = { messages: [], previewIndex: 0 };
|
||||||
state.values = values;
|
state.values = values;
|
||||||
state.list = list;
|
state.list = list;
|
||||||
|
state.editor = Boolean(options.editor);
|
||||||
if (options.editor && !state.messages.length) {
|
if (options.editor && !state.messages.length) {
|
||||||
state.messages = [
|
const initialCount = Math.max(1, Math.min(Number(values.maxMessages || 8), 5));
|
||||||
{ id: `${module.id}-preview-1`, platform: "twitch", text: "This is how a live chat message will look.", timestamp: Date.now() - 60000, channel: { name: "#stream" }, author: { name: "CozyViewer", color: "#f472b6", badges: [{ label: "Sub", image: null }] } },
|
for (let index = 0; index < initialCount; index += 1) state.messages.push(nextPreviewMessage(module.id, state));
|
||||||
{ id: `${module.id}-preview-2`, platform: "youtube", text: "Messages from your selected platforms appear together!", timestamp: Date.now(), channel: { name: "Live chat" }, author: { name: "Lumi Fan", color: null, badges: [] } }
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
state.messages = state.messages.slice(-Number(values.maxMessages || 8));
|
state.messages = state.messages.slice(-Number(values.maxMessages || 8));
|
||||||
const display = values.newestAt === "top" ? [...state.messages].reverse() : state.messages;
|
const acceptedMessages = state.messages.filter((message) => message.preview
|
||||||
|
? (values.platforms || []).includes(message.platform)
|
||||||
|
: chatAccepts(values, message));
|
||||||
|
const display = values.newestAt === "top" ? [...acceptedMessages].reverse() : acceptedMessages;
|
||||||
for (const message of display) list.appendChild(chatMessageElement(message, values, { animate: false }));
|
for (const message of display) list.appendChild(chatMessageElement(message, values, { animate: false }));
|
||||||
chatStates.set(module.id, state);
|
chatStates.set(module.id, state);
|
||||||
|
if (options.editor) schedulePreviewChat(module.id, state);
|
||||||
|
else clearTimeout(state.previewTimer);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChatMessage(root, payload) {
|
function handleChatMessage(root, payload) {
|
||||||
if (!payload?.id || !payload?.platform || !payload?.text) return 0;
|
if (!payload?.id || !payload?.platform || (!payload?.text && !payload?.media?.length)) return 0;
|
||||||
let accepted = 0;
|
let accepted = 0;
|
||||||
root?.querySelectorAll?.('.lumi-overlay-module[data-render-type="chat"]').forEach((box) => {
|
root?.querySelectorAll?.('.lumi-overlay-module[data-render-type="chat"]').forEach((box) => {
|
||||||
const moduleId = box.dataset.moduleId;
|
const moduleId = box.dataset.moduleId;
|
||||||
|
|||||||
@ -122,7 +122,7 @@
|
|||||||
<label>Messages shown<input type="number" name="chat_max_messages" min="1" max="50" value="<%= value(config,"maxMessages",8) %>" /></label>
|
<label>Messages shown<input type="number" name="chat_max_messages" min="1" max="50" value="<%= value(config,"maxMessages",8) %>" /></label>
|
||||||
<label>Hide messages after <span class="hint">(seconds; 0 keeps them)</span><input type="number" name="chat_timeout_seconds" min="0" max="3600" step="1" value="<%= value(config,"messageTimeoutSeconds",30) %>" /></label>
|
<label>Hide messages after <span class="hint">(seconds; 0 keeps them)</span><input type="number" name="chat_timeout_seconds" min="0" max="3600" step="1" value="<%= value(config,"messageTimeoutSeconds",30) %>" /></label>
|
||||||
<label>New messages appear<select name="chat_newest_at"><% [["bottom","At the bottom"],["top","At the top"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"newestAt","bottom") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
<label>New messages appear<select name="chat_newest_at"><% [["bottom","At the bottom"],["top","At the top"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"newestAt","bottom") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||||
<label>Text alignment<select name="horizontal_align"><% [["left","Left"],["center","Center"],["right","Right"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"horizontalAlign","left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
<label>Alignment <button type="button" class="overlay-help" data-tooltip="Aligns each complete message, including its avatar, service icon, badges, name, and text.">?</button><select name="horizontal_align"><% [["left","Left"],["center","Center"],["right","Right"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"horizontalAlign","left") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||||
<label class="full">Font<input name="chat_font_family" value="<%= value(config,"fontFamily","system-ui, sans-serif") %>" maxlength="300" placeholder="system-ui, sans-serif" /></label>
|
<label class="full">Font<input name="chat_font_family" value="<%= value(config,"fontFamily","system-ui, sans-serif") %>" maxlength="300" placeholder="system-ui, sans-serif" /></label>
|
||||||
<label>Text size<input type="number" name="font_size" min="8" max="160" value="<%= value(config,"fontSize",32) %>" /></label>
|
<label>Text size<input type="number" name="font_size" min="8" max="160" value="<%= value(config,"fontSize",32) %>" /></label>
|
||||||
<label>Weight<select name="font_weight"><% [[400,"Regular"],[600,"Semi-bold"],[700,"Bold"],[900,"Heavy"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= Number(value(config,"fontWeight",600)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
<label>Weight<select name="font_weight"><% [[400,"Regular"],[600,"Semi-bold"],[700,"Bold"],[900,"Heavy"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= Number(value(config,"fontWeight",600)) === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||||
@ -140,8 +140,8 @@
|
|||||||
<label><input type="checkbox" name="chat_show_platform" <%= value(config,"showPlatform",true) ? "checked" : "" %> /> Service icon</label>
|
<label><input type="checkbox" name="chat_show_platform" <%= value(config,"showPlatform",true) ? "checked" : "" %> /> Service icon</label>
|
||||||
<label><input type="checkbox" name="chat_show_timestamp" <%= value(config,"showTimestamp",false) ? "checked" : "" %> /> Time</label>
|
<label><input type="checkbox" name="chat_show_timestamp" <%= value(config,"showTimestamp",false) ? "checked" : "" %> /> Time</label>
|
||||||
</div></fieldset>
|
</div></fieldset>
|
||||||
<label>New-message animation<select name="chat_in_animation"><% [["slide-up","Slide up"],["slide-left","Slide in"],["fade","Fade in"],["scale","Grow in"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"inAnimation","slide-up") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
<label>New-message animation<select name="chat_in_animation"><% [["slide-left","Slide in from left"],["slide-right","Slide in from right"],["slide-up","Slide in from above"],["slide-down","Slide in from below"],["fade","Fade in"],["scale","Grow in"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"inAnimation","slide-up") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||||
<label>Old-message animation<select name="chat_out_animation"><% [["fade","Fade out"],["slide-left","Slide out"],["slide-down","Slide down"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"outAnimation","fade") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
<label>Old-message animation<select name="chat_out_animation"><% [["slide-left","Slide out to left"],["slide-right","Slide out to right"],["slide-up","Slide out upward"],["slide-down","Slide out downward"],["fade","Fade out"],["scale","Shrink out"],["none","None"]].forEach(([id,label]) => { %><option value="<%= id %>" <%= value(config,"outAnimation","fade") === id ? "selected" : "" %>><%= label %></option><% }) %></select></label>
|
||||||
<label>Animation speed <span class="hint">(milliseconds)</span><input type="number" name="chat_animation_duration_ms" min="0" max="3000" step="50" value="<%= value(config,"animationDurationMs",350) %>" /></label>
|
<label>Animation speed <span class="hint">(milliseconds)</span><input type="number" name="chat_animation_duration_ms" min="0" max="3000" step="50" value="<%= value(config,"animationDurationMs",350) %>" /></label>
|
||||||
<label class="full overlay-css-field">Custom chat CSS <button type="button" class="overlay-help" data-tooltip="Advanced: style .chat, .message, .avatar, .content, .meta, .platform, .author, .badge, .timestamp, and .text.">?</button><textarea name="chat_custom_css" rows="6" maxlength="12000" placeholder=".message { border: 1px solid rgba(255,255,255,.2); }"><%= value(config,"customCss","") %></textarea><small class="hint">Your CSS is isolated to this chat source and is applied after the visual settings above.</small></label>
|
<label class="full overlay-css-field">Custom chat CSS <button type="button" class="overlay-help" data-tooltip="Advanced: style .chat, .message, .avatar, .content, .meta, .platform, .author, .badge, .timestamp, and .text.">?</button><textarea name="chat_custom_css" rows="6" maxlength="12000" placeholder=".message { border: 1px solid rgba(255,255,255,.2); }"><%= value(config,"customCss","") %></textarea><small class="hint">Your CSS is isolated to this chat source and is applied after the visual settings above.</small></label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Lumi Core",
|
"name": "Lumi Core",
|
||||||
"version": "0.2.15",
|
"version": "0.2.16",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-19",
|
"released_at": "2026-07-19",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
@ -8,7 +8,7 @@
|
|||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"1.2.0"
|
||||||
],
|
],
|
||||||
"migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency. 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": "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.",
|
||||||
"rollback_safe": true,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer"
|
"Node.js 18 or newer"
|
||||||
@ -205,6 +205,18 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"rollback_safe": true,
|
||||||
"migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
|
"migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.16",
|
||||||
|
"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": "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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user