From f3f95ae3e072ac96f9cbb867878a61fdc376ccf7 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 27 Jan 2026 00:35:01 -0500 Subject: [PATCH 01/71] 5.65.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 35ba5fd6a..30e28aa81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "firebotv5", - "version": "5.65.4", + "version": "5.65.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "firebotv5", - "version": "5.65.4", + "version": "5.65.5", "license": "GPL-3.0", "dependencies": { "@aws-sdk/client-polly": "^3.26.0", diff --git a/package.json b/package.json index d943346f6..03d051bd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firebotv5", - "version": "5.65.4", + "version": "5.65.5", "description": "Powerful all-in-one bot for Twitch streamers.", "main": "build/main.js", "scripts": { From 896d60d6b6d91a1b531f3498114fbf48de1c4d6c Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 27 Jan 2026 12:49:48 -0500 Subject: [PATCH 02/71] feat(events): OBS Exiting --- src/backend/integrations/builtin/obs/constants.ts | 1 + .../builtin/obs/events/obs-event-source.ts | 8 +++++++- src/backend/integrations/builtin/obs/obs-remote.ts | 11 ++++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/backend/integrations/builtin/obs/constants.ts b/src/backend/integrations/builtin/obs/constants.ts index a889577b0..50b7d3b77 100644 --- a/src/backend/integrations/builtin/obs/constants.ts +++ b/src/backend/integrations/builtin/obs/constants.ts @@ -28,3 +28,4 @@ export const OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID = "input-audio-tracks-chang export const OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID = "input-audio-monitor-type-changed"; export const OBS_CONNECTED_EVENT_ID = "connected"; export const OBS_DISCONNECTED_EVENT_ID = "disconnected"; +export const OBS_EXITING_EVENT_ID = "exiting"; diff --git a/src/backend/integrations/builtin/obs/events/obs-event-source.ts b/src/backend/integrations/builtin/obs/events/obs-event-source.ts index 0dc76095c..91aba5534 100644 --- a/src/backend/integrations/builtin/obs/events/obs-event-source.ts +++ b/src/backend/integrations/builtin/obs/events/obs-event-source.ts @@ -29,7 +29,8 @@ import { OBS_INPUT_AUDIO_BALANCE_CHANGED_EVENT_ID, OBS_INPUT_AUDIO_SYNC_OFFSET_CHANGED_EVENT_ID, OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID, - OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID + OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID, + OBS_EXITING_EVENT_ID } from "../constants"; export const OBSEventSource: EventSource = { @@ -288,6 +289,11 @@ export const OBSEventSource: EventSource = { value: "OBS_MONITORING_TYPE_NONE" } } + }, + { + id: OBS_EXITING_EVENT_ID, + name: "OBS Exiting", + description: "When OBS signals that it is exiting/closing" } ] }; \ No newline at end of file diff --git a/src/backend/integrations/builtin/obs/obs-remote.ts b/src/backend/integrations/builtin/obs/obs-remote.ts index 2a6aa408e..33b9646cf 100644 --- a/src/backend/integrations/builtin/obs/obs-remote.ts +++ b/src/backend/integrations/builtin/obs/obs-remote.ts @@ -30,7 +30,8 @@ import { OBS_INPUT_AUDIO_MONITOR_TYPE_CHANGED_EVENT_ID, OBS_INPUT_AUDIO_TRACKS_CHANGED_EVENT_ID, OBS_CONNECTED_EVENT_ID, - OBS_DISCONNECTED_EVENT_ID + OBS_DISCONNECTED_EVENT_ID, + OBS_EXITING_EVENT_ID } from "./constants"; import logger from "../../../logwrapper"; @@ -402,6 +403,14 @@ async function setupRemoteListeners() { ); }); + obs.on("ExitStarted", () => { + eventManager?.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_EXITING_EVENT_ID, + { } + ); + }); + obs.on("CurrentSceneCollectionChanged", async () => { await refreshGroupsAndScenes(); }); From 282a120bb5567c40646599fcf44b32a9ccfb6bc5 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 28 Jan 2026 14:14:43 -0500 Subject: [PATCH 03/71] fix(quotes): ID recalc increment value (#3429) --- src/backend/quotes/quote-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/quotes/quote-manager.ts b/src/backend/quotes/quote-manager.ts index 90cf441f2..b00f76aa6 100644 --- a/src/backend/quotes/quote-manager.ts +++ b/src/backend/quotes/quote-manager.ts @@ -97,7 +97,7 @@ class QuoteManager { try { const result = await this.db.updateAsync( { _id: '__autoid__' }, - { $inc: { seq: number } }, + { seq: number }, { upsert: true, returnUpdatedDocs: true } ); From 8d51595c8640d7d6999229b3707cc75436d0d071 Mon Sep 17 00:00:00 2001 From: Dennis Rijsdijk Date: Tue, 3 Feb 2026 09:31:33 +0100 Subject: [PATCH 04/71] fix: corrupt settings repair (#3432) --- src/backend/common/settings-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/common/settings-manager.ts b/src/backend/common/settings-manager.ts index 624b2b873..3ebda974a 100644 --- a/src/backend/common/settings-manager.ts +++ b/src/backend/common/settings-manager.ts @@ -60,7 +60,7 @@ class SettingsManager extends EventEmitter { private handleCorruptSettingsFile() { logger.warn("settings.json file appears to be corrupt. Resetting file..."); - const settingsPath = this.getLoggedInProfilePath("settings.json"); + const settingsPath = path.join(dataAccess.getUserDataPath(), this.getLoggedInProfilePath("settings.json")); fs.writeFileSync(settingsPath, JSON.stringify({ settings: { firstTimeUse: false From 492974f90d4f1b5db325af217cf437e32da9d1f7 Mon Sep 17 00:00:00 2001 From: Grandpa Celery <163025043+GrandpaCelery@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:03:01 +0100 Subject: [PATCH 05/71] Fix timeout duration description to specify seconds --- .../twitch/variables/chat/moderation/timeout-duration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/streaming-platforms/twitch/variables/chat/moderation/timeout-duration.ts b/src/backend/streaming-platforms/twitch/variables/chat/moderation/timeout-duration.ts index dc51cb2ec..3c2d9b051 100644 --- a/src/backend/streaming-platforms/twitch/variables/chat/moderation/timeout-duration.ts +++ b/src/backend/streaming-platforms/twitch/variables/chat/moderation/timeout-duration.ts @@ -8,7 +8,7 @@ triggers["event"] = ["twitch:timeout"]; const model : ReplaceVariable = { definition: { handle: "timeoutDuration", - description: "How long the user is timed out for in minus", + description: "How long the user is timed out for in seconds", triggers: triggers, categories: ["common", "trigger based"], possibleDataOutput: ["number"] From 3767bf31930027d7917d249ba1a4631e11892d9c Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 3 Feb 2026 13:20:31 -0500 Subject: [PATCH 06/71] feat(events): OBS Scene Item List Reindexed --- src/backend/integrations/builtin/obs/constants.ts | 1 + .../integrations/builtin/obs/events/obs-event-source.ts | 9 +++++++++ .../builtin/obs/filters/scene-name-filter.ts | 6 ++++-- src/backend/integrations/builtin/obs/obs-remote.ts | 9 +++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/backend/integrations/builtin/obs/constants.ts b/src/backend/integrations/builtin/obs/constants.ts index 50b7d3b77..d3fb84c8d 100644 --- a/src/backend/integrations/builtin/obs/constants.ts +++ b/src/backend/integrations/builtin/obs/constants.ts @@ -5,6 +5,7 @@ export const OBS_STREAM_STOPPED_EVENT_ID = "stream-stopped"; export const OBS_RECORDING_STARTED_EVENT_ID = "recording-started"; export const OBS_RECORDING_STOPPED_EVENT_ID = "recording-stopped"; export const OBS_SCENE_ITEM_ENABLE_STATE_CHANGED_EVENT_ID = "scene-item-enable-state-changed"; +export const OBS_SCENE_ITEM_LIST_REINDEXED_EVENT_ID = "scene-item-list-reindexed"; export const OBS_SCENE_TRANSITION_STARTED_EVENT_ID = "scene-transition-started"; export const OBS_SCENE_TRANSITION_ENDED_EVENT_ID = "scene-transition-ended"; export const OBS_CURRENT_PROGRAM_SCENE_CHANGED_EVENT_ID = "current-program-scene-changed"; diff --git a/src/backend/integrations/builtin/obs/events/obs-event-source.ts b/src/backend/integrations/builtin/obs/events/obs-event-source.ts index 91aba5534..f1da9caf1 100644 --- a/src/backend/integrations/builtin/obs/events/obs-event-source.ts +++ b/src/backend/integrations/builtin/obs/events/obs-event-source.ts @@ -13,6 +13,7 @@ import { OBS_REPLAY_BUFFER_SAVED_EVENT_ID, OBS_SCENE_CHANGED_EVENT_ID, OBS_SCENE_ITEM_ENABLE_STATE_CHANGED_EVENT_ID, + OBS_SCENE_ITEM_LIST_REINDEXED_EVENT_ID, OBS_SCENE_TRANSITION_ENDED_EVENT_ID, OBS_SCENE_TRANSITION_STARTED_EVENT_ID, OBS_STREAM_STARTED_EVENT_ID, @@ -93,6 +94,14 @@ export const OBSEventSource: EventSource = { sceneName: "Test Scene Name" } }, + { + id: OBS_SCENE_ITEM_LIST_REINDEXED_EVENT_ID, + name: "OBS Scene Item List Reindexed", + description: "When the items in a scene have been re-ordered", + manualMetadata: { + sceneName: "Test Scene Name" + } + }, { id: OBS_SCENE_TRANSITION_STARTED_EVENT_ID, name: "OBS Scene Transition Started", diff --git a/src/backend/integrations/builtin/obs/filters/scene-name-filter.ts b/src/backend/integrations/builtin/obs/filters/scene-name-filter.ts index 7a8ab41ca..8f9b641a2 100644 --- a/src/backend/integrations/builtin/obs/filters/scene-name-filter.ts +++ b/src/backend/integrations/builtin/obs/filters/scene-name-filter.ts @@ -3,7 +3,8 @@ import { EventFilter } from "../../../../../types/events"; import { OBS_EVENT_SOURCE_ID, OBS_SCENE_CHANGED_EVENT_ID, - OBS_SCENE_ITEM_ENABLE_STATE_CHANGED_EVENT_ID + OBS_SCENE_ITEM_ENABLE_STATE_CHANGED_EVENT_ID, + OBS_SCENE_ITEM_LIST_REINDEXED_EVENT_ID } from "../constants"; const filter: EventFilter = createPresetFilter({ @@ -12,7 +13,8 @@ const filter: EventFilter = createPresetFilter({ description: "Filter on the name of the now active OBS scene", events: [ { eventSourceId: OBS_EVENT_SOURCE_ID, eventId: OBS_SCENE_CHANGED_EVENT_ID }, - { eventSourceId: OBS_EVENT_SOURCE_ID, eventId: OBS_SCENE_ITEM_ENABLE_STATE_CHANGED_EVENT_ID } + { eventSourceId: OBS_EVENT_SOURCE_ID, eventId: OBS_SCENE_ITEM_ENABLE_STATE_CHANGED_EVENT_ID }, + { eventSourceId: OBS_EVENT_SOURCE_ID, eventId: OBS_SCENE_ITEM_LIST_REINDEXED_EVENT_ID } ], eventMetaKey: "sceneName", allowIsNot: true, diff --git a/src/backend/integrations/builtin/obs/obs-remote.ts b/src/backend/integrations/builtin/obs/obs-remote.ts index 33b9646cf..bc9d19983 100644 --- a/src/backend/integrations/builtin/obs/obs-remote.ts +++ b/src/backend/integrations/builtin/obs/obs-remote.ts @@ -12,6 +12,7 @@ import { OBS_REPLAY_BUFFER_SAVED_EVENT_ID, OBS_SCENE_CHANGED_EVENT_ID, OBS_SCENE_ITEM_ENABLE_STATE_CHANGED_EVENT_ID, + OBS_SCENE_ITEM_LIST_REINDEXED_EVENT_ID, OBS_SCENE_TRANSITION_ENDED_EVENT_ID, OBS_SCENE_TRANSITION_STARTED_EVENT_ID, OBS_STREAM_STARTED_EVENT_ID, @@ -528,6 +529,14 @@ async function setupRemoteListeners() { groupInfos[gidx].scenes[sidx].itemId = gis.id; } }); + + eventManager.triggerEvent( + OBS_EVENT_SOURCE_ID, + OBS_SCENE_ITEM_LIST_REINDEXED_EVENT_ID, + { + sceneName + } + ); }); obs.on("StudioModeStateChanged", async ({ studioModeEnabled }) => { From dbc9da7e45d2f241c63276e299b252e4adf3f658 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sat, 14 Mar 2026 03:08:44 -0400 Subject: [PATCH 07/71] fix: use computed baseUrl in overlay effects/widgets --- src/backend/effects/builtin/play-sound.ts | 4 +--- src/backend/effects/builtin/play-video.js | 3 ++- src/backend/effects/builtin/show-image.js | 3 ++- src/backend/overlay-widgets/builtin-types/image/image.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/backend/effects/builtin/play-sound.ts b/src/backend/effects/builtin/play-sound.ts index 317eae10a..f9ac6e610 100644 --- a/src/backend/effects/builtin/play-sound.ts +++ b/src/backend/effects/builtin/play-sound.ts @@ -158,9 +158,7 @@ const model: EffectType<{ onOverlayEvent: (event) => { const data = event; const token = encodeURIComponent(data.resourceToken); - const resourcePath = `http://${ - window.location.hostname - }:7472/resource/${token}`; + const resourcePath = `//${baseUrl}/resource/${token}`; // Generate UUID to use as class name. diff --git a/src/backend/effects/builtin/play-video.js b/src/backend/effects/builtin/play-video.js index 5106b8431..e14f3a73d 100644 --- a/src/backend/effects/builtin/play-video.js +++ b/src/backend/effects/builtin/play-video.js @@ -658,7 +658,8 @@ const playVideo = { const loop = data.loop; const token = encodeURIComponent(data.resourceToken); - const filepathNew = `http://${window.location.hostname}:7472/resource/${token}`; + // eslint-disable-next-line no-undef + const filepathNew = `//${baseUrl}/resource/${token}`; // Generate UUID to use as id diff --git a/src/backend/effects/builtin/show-image.js b/src/backend/effects/builtin/show-image.js index 44cff0a77..e3496b152 100644 --- a/src/backend/effects/builtin/show-image.js +++ b/src/backend/effects/builtin/show-image.js @@ -247,7 +247,8 @@ const showImage = { filepathNew = data.url; } else { const token = encodeURIComponent(data.resourceToken); - filepathNew = `http://${window.location.hostname}:7472/resource/${token}`; + // eslint-disable-next-line no-undef + filepathNew = `//${baseUrl}/resource/${token}`; } // NEW WAY EXAMPLE: diff --git a/src/backend/overlay-widgets/builtin-types/image/image.ts b/src/backend/overlay-widgets/builtin-types/image/image.ts index 86f696276..f190a0741 100644 --- a/src/backend/overlay-widgets/builtin-types/image/image.ts +++ b/src/backend/overlay-widgets/builtin-types/image/image.ts @@ -93,7 +93,7 @@ export const image: OverlayWidgetType = { if (config.settings?.imageType === "url" && config.settings?.url) { imageSrc = config.settings.url; } else if (config.settings?.imageType === "local" && config.resourceTokens.filepath) { - imageSrc = `http://${window.location.hostname}:7472/resource/${config.resourceTokens.filepath}`; + imageSrc = `//${baseUrl}/resource/${config.resourceTokens.filepath}`; } return ` From bffc62a52b287d15d649a27327073af5ed162d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= <48084558+servusrene@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:51:01 +0100 Subject: [PATCH 08/71] fix: listen to user:offline event to individually mark viewers offline The ActiveUserHandler emits "user:offline" when a viewers online cache TTL expires (7.5 min without activity), but no listener existed for this event. This meant viewers were never individually marked offline in the ViewerDB - only bulk-cleared on chat disconnect or stream end. --- src/backend/viewers/viewer-online-status-manager.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/backend/viewers/viewer-online-status-manager.ts b/src/backend/viewers/viewer-online-status-manager.ts index abca848d3..aee9765d1 100644 --- a/src/backend/viewers/viewer-online-status-manager.ts +++ b/src/backend/viewers/viewer-online-status-manager.ts @@ -41,6 +41,10 @@ class ViewerOnlineStatusManager { ActiveUserHandler.on("user:online", (user) => { void this.setChatViewerOnline(user); }); + + ActiveUserHandler.on("user:offline", (userId) => { + void this.setChatViewerOffline(userId); + }); } async getOnlineViewers(): Promise { From 91997b73a550ffee39e605e03d03e6178f130e54 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 28 Apr 2026 12:13:01 -0400 Subject: [PATCH 09/71] feat(widgets): Chat widget (#3272) --- .../chat-listeners/twitch-chat-listeners.js | 12 +- src/backend/chat/frontend-chat-helpers.ts | 129 +++ .../builtin-types/chat/chat.ts | 932 ++++++++++++++++++ .../overlay-widgets/builtin-types/index.ts | 4 +- .../twitch/api/eventsub/eventsub-client.ts | 26 +- src/gui/app/app-main.js | 1 + .../builtin/animation-select.js | 33 + .../dynamic-params/builtin/font-options.js | 2 +- src/resources/overlay/js/overlay-widgets.ts | 1 + src/types/chat.d.ts | 1 + src/types/parameters.d.ts | 21 +- 11 files changed, 1136 insertions(+), 26 deletions(-) create mode 100644 src/backend/chat/frontend-chat-helpers.ts create mode 100644 src/backend/overlay-widgets/builtin-types/chat/chat.ts create mode 100644 src/gui/app/directives/controls/dynamic-params/builtin/animation-select.js diff --git a/src/backend/chat/chat-listeners/twitch-chat-listeners.js b/src/backend/chat/chat-listeners/twitch-chat-listeners.js index e38aaf7e5..32a4f7f47 100644 --- a/src/backend/chat/chat-listeners/twitch-chat-listeners.js +++ b/src/backend/chat/chat-listeners/twitch-chat-listeners.js @@ -1,11 +1,11 @@ "use strict"; -const frontendCommunicator = require("../../common/frontend-communicator"); const chatCommandHandler = require("../commands/chat-command-handler"); const chatHelpers = require("../chat-helpers"); const { AccountAccess } = require("../../common/account-access"); const { ActiveUserHandler } = require("../active-user-handler"); const { ChatModerationManager } = require("../moderation/chat-moderation-manager"); +const { FirebotFrontendChatHelpers } = require("../frontend-chat-helpers"); const { TwitchEventHandlers } = require("../../streaming-platforms/twitch/events"); const twitchRolesManager = require("../../roles/twitch-roles-manager"); const raidMessageChecker = require(".././moderation/raid-message-checker"); @@ -38,7 +38,7 @@ exports.setupChatListeners = (streamerChatClient, botChatClient) => { firebotChatMessage.isAnnouncement = true; firebotChatMessage.announcementColor = announcementInfo.color ?? "PRIMARY"; - frontendCommunicator.send("twitch:chat:message", firebotChatMessage); + FirebotFrontendChatHelpers.sendChatMessageToFrontend(firebotChatMessage); TwitchEventHandlers.announcement.triggerAnnouncement( firebotChatMessage.username, @@ -75,7 +75,7 @@ exports.setupChatListeners = (streamerChatClient, botChatClient) => { imageUrl: "https://static-cdn.jtvnw.net/automatic-reward-images/highlight-4.png" }; } - frontendCommunicator.send("twitch:chat:message", firebotChatMessage); + FirebotFrontendChatHelpers.sendChatMessageToFrontend(firebotChatMessage); exports.events.emit("chat-message", firebotChatMessage); const { ranCommand, command, userCommand } = await chatCommandHandler.handleChatMessage(firebotChatMessage); @@ -116,7 +116,7 @@ exports.setupChatListeners = (streamerChatClient, botChatClient) => { chatCommandHandler.handleChatMessage(firebotChatMessage); - frontendCommunicator.send("twitch:chat:message", firebotChatMessage); + FirebotFrontendChatHelpers.sendChatMessageToFrontend(firebotChatMessage); TwitchEventHandlers.whisper.triggerWhisper( msg.userInfo.userName, @@ -143,7 +143,7 @@ exports.setupChatListeners = (streamerChatClient, botChatClient) => { twitchRolesManager.removeVipFromVipList(msg.userInfo.userId); } - frontendCommunicator.send("twitch:chat:message", firebotChatMessage); + FirebotFrontendChatHelpers.sendChatMessageToFrontend(firebotChatMessage); const { ranCommand, command, userCommand } = await chatCommandHandler.handleChatMessage(firebotChatMessage); @@ -173,7 +173,7 @@ exports.setupChatListeners = (streamerChatClient, botChatClient) => { if (subInfo.message != null && subInfo.message.length > 0) { const firebotChatMessage = await chatHelpers.buildFirebotChatMessage(msg, subInfo.message); - frontendCommunicator.send("twitch:chat:message", firebotChatMessage); + FirebotFrontendChatHelpers.sendChatMessageToFrontend(firebotChatMessage); exports.events.emit("chat-message", firebotChatMessage); } diff --git a/src/backend/chat/frontend-chat-helpers.ts b/src/backend/chat/frontend-chat-helpers.ts new file mode 100644 index 000000000..b1f3f3d2b --- /dev/null +++ b/src/backend/chat/frontend-chat-helpers.ts @@ -0,0 +1,129 @@ +import type { FirebotChatMessage } from "../../types"; +import overlayWidgetsManager from "../overlay-widgets/overlay-widgets-manager"; +import overlayWidgetConfigManager from "../overlay-widgets/overlay-widget-config-manager"; +import frontendCommunicator from "../common/frontend-communicator"; + +class FirebotFrontendChatHelpers { + sendChatMessageToFrontend(chatMessage: FirebotChatMessage): void { + frontendCommunicator.send("twitch:chat:message", chatMessage); + + if (chatMessage.whisper === true + || chatMessage.isAutoModHeld === true + || chatMessage.autoModStatus === "denied" + || chatMessage.autoModStatus === "expired" + ) { + return; + } + + chatMessage.timestamp = new Date().getTime(); + + const chatWidgets = overlayWidgetConfigManager.getConfigsOfType("firebot:chat"); + + for (const chatWidget of chatWidgets) { + const existingChatMessages = (chatWidget.state?.chatMessages ?? []) as FirebotChatMessage[]; + overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { + chatMessages: [...existingChatMessages.slice(-49), chatMessage] + }); + + void overlayWidgetsManager.sendWidgetEventToOverlay( + "message", + chatWidget, + { + messageName: "chat-message", + messageData: { + chatMessage + } + } + ); + } + } + + deleteMessageFromFrontend(messageId: string): void { + frontendCommunicator.send("twitch:chat:message:deleted", messageId); + + const chatWidgets = overlayWidgetConfigManager.getConfigsOfType("firebot:chat"); + for (const chatWidget of chatWidgets) { + const chatMessages = ((chatWidget.state?.chatMessages ?? []) as FirebotChatMessage[]) + .filter(m => m.id !== messageId); + + overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { + chatMessages: chatMessages + }); + + void overlayWidgetsManager.sendWidgetEventToOverlay( + "message", + chatWidget, + { + messageName: "delete-message", + messageData: { + messageId + } + } + ); + } + } + + deleteUserMessagesFromFrontend(username: string) { + frontendCommunicator.send("twitch:chat:user:delete-messages", username); + + const chatWidgets = overlayWidgetConfigManager.getConfigsOfType("firebot:chat"); + for (const chatWidget of chatWidgets) { + const chatMessages = ((chatWidget.state?.chatMessages ?? []) as FirebotChatMessage[]) + .filter(m => m.username !== username); + + overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { + chatMessages: chatMessages + }); + + void overlayWidgetsManager.sendWidgetEventToOverlay( + "message", + chatWidget, + { + messageName: "delete-user-messages", + messageData: { + username + } + } + ); + } + } + + clearChatFeed(moderatorName: string): void { + frontendCommunicator.send("twitch:chat:clear-feed", moderatorName); + + const chatWidgets = overlayWidgetConfigManager.getConfigsOfType("firebot:chat"); + for (const chatWidget of chatWidgets) { + overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { + chatMessages: [] + }); + + void overlayWidgetsManager.sendWidgetEventToOverlay( + "message", + chatWidget, + { + messageName: "clear-chat" + } + ); + } + } + + updateMessageAutomodStatus( + messageId: string, + newStatus: FirebotChatMessage["autoModStatus"], + resolverName: string, + resolverId: string, + flaggedPhrases: string[] + ): void { + frontendCommunicator.send("twitch:chat:automod-update", { + messageId, + newStatus, + resolverName, + resolverId, + flaggedPhrases + }); + } +} + +const frontendChatHelpers = new FirebotFrontendChatHelpers(); + +export { frontendChatHelpers as FirebotFrontendChatHelpers }; \ No newline at end of file diff --git a/src/backend/overlay-widgets/builtin-types/chat/chat.ts b/src/backend/overlay-widgets/builtin-types/chat/chat.ts new file mode 100644 index 000000000..bd40c5796 --- /dev/null +++ b/src/backend/overlay-widgets/builtin-types/chat/chat.ts @@ -0,0 +1,932 @@ +import type { + FirebotChatMessage, + OverlayWidgetType, + IOverlayWidgetEventUtils, + WidgetOverlayEvent, + FontOptions, + Animation +} from "../../../../types"; + +type Settings = { + showTimestamps?: boolean; + showAvatars?: boolean; + showBadges?: boolean; + showSharedChatMessages?: boolean; + showSharedChatInfo?: boolean; + showAnnouncements?: boolean; + messageStyle?: "compact" | "modern"; + chatOrder?: "normal" | "reversed"; + actionDisplayFormat: "modern" | "classic"; + highlightStyle?: "normal" | "highlighted"; + highlightColor?: string; + horizontalAlignment: "left" | "right"; + verticalAlignment: "top" | "bottom"; + spaceBetweenMessages?: number; + newMessageEntryAnimation: Animation; + usernameFontOptions: FontOptions; + messageFontOptions: FontOptions; +}; + +type State = { + chatMessages: Array; +}; + +type ChatMessageMessageData = { + chatMessage: FirebotChatMessage; +}; + +type DeleteMessageMessageData = { + messageId: string; +}; + +type DeleteUserMessagesMessageData = { + username: string; +}; + +export const chat: OverlayWidgetType = { + id: "firebot:chat", + name: "Chat", + description: "A basic chat feed for the overlay", + icon: "fa fa-comments-alt", + userCanConfigure: { + entryAnimation: false, + exitAnimation: false + }, + settingsSchema: [ + { + name: "showTimestamps", + title: "Show Timestamps", + type: "boolean", + default: false + }, + { + name: "showAvatars", + title: "Show Viewer Avatars", + type: "boolean", + default: true + }, + { + name: "showBadges", + title: "Show Chat Badges", + description: "Show chat badges (sub, bits tiers, etc.) next to usernames", + type: "boolean", + default: true + }, + { + name: "showSharedChatMessages", + title: "Show Shared Chat Messages", + description: "Display chat messages sent from other channels during a shared chat session", + type: "boolean", + default: false + }, + { + name: "showSharedChatInfo", + title: "Show Shared Chat Info", + description: "Display info about the channel a chat message was sent in during a shared chat session in the chat feed", + type: "boolean", + default: true, + showIf: { + showSharedChatMessages: true + } + }, + { + name: "showAnnouncements", + title: "Show Announcements", + description: "Display chat announcements sent from the streamer or moderators", + type: "boolean", + default: false, + showBottomHr: true + }, + { + name: "messageStyle", + title: "Message Style", + type: "radio-cards", + default: "compact", + options: [ + { + value: "compact", + label: "Compact", + description: "Username and chat message are displayed on the same line", + iconClass: "fa-horizontal-rule" + }, + { + value: "modern", + label: "Modern (Expanded)", + description: "Username and chat message are displayed on separate lines", + iconClass: "fa-line-height" + } + ] + }, + { + name: "chatOrder", + title: "Chat Order", + type: "radio-cards", + default: "normal", + options: [ + { + value: "normal", + label: "Normal", + description: "New messages will appear at the bottom of the chat feed", + iconClass: "fa-sort-amount-down-alt" + }, + { + value: "reversed", + label: "Reversed", + description: "New messages will appear at the top of the chat feed", + iconClass: "fa-sort-amount-up" + } + ] + }, + { + name: "actionDisplayFormat", + title: "Action Display Format", + description: "The style used to display `/me` actions", + type: "radio-cards", + default: "modern", + options: [ + { + value: "modern", + label: "Modern", + description: "Action text is the same color as normal chat messages but italicized, like Twitch does today", + iconClass: "fa-italic" + }, + { + value: "classic", + label: "Classic", + description: "Action text is the same color as the username but not italicized, like Twitch used to do", + iconClass: "fa-palette" + } + ] + }, + { + name: "highlightStyle", + title: "Highlighted Message Style", + type: "radio-cards", + default: "normal", + options: [ + { + value: "normal", + label: "Normal", + description: "Highlighted messages will look just like regular messages", + iconClass: "fa-tint-slash" + }, + { + value: "highlighted", + label: "Highlighted", + description: "Highlighted messages will have a different background color, similar to how Twitch displays them", + iconClass: "fa-tint" + } + ] + }, + { + name: "highlightColor", + title: "Highlighted Message Background Color", + type: "hexcolor", + allowAlpha: true, + default: "#755ebc", + validation: { + required: true, + pattern: "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" + }, + showIf: { + highlightStyle: "highlighted" + } + }, + { + name: "horizontalAlignment", + title: "Horizontal Alignment", + description: "Horizontal alignment of the chat messages within the widget area.", + type: "radio-cards", + options: [{ + value: "left", label: "Left", iconClass: "fa-align-left" + }, { + value: "right", label: "Right", iconClass: "fa-align-right" + }], + settings: { + gridColumns: 2 + }, + default: "left" + }, + { + name: "verticalAlignment", + title: "Vertical Alignment", + description: "Vertical alignment of the chat messages within the widget area.", + type: "radio-cards", + options: [{ + value: "top", label: "Top", iconClass: "fa-arrow-to-top" + }, { + value: "bottom", label: "Bottom", iconClass: "fa-arrow-to-bottom" + }], + settings: { + gridColumns: 2 + }, + default: "top", + showBottomHr: true + }, + { + name: "spaceBetweenMessages", + title: "Space Between Messages", + description: "How much space (in pixels) to put between messages in the feed", + type: "number", + default: 5 + }, + { + name: "newMessageEntryAnimation", + title: "New Message Entry Animation", + description: "Animation to use when new messages arrive", + type: "animation-select", + animationType: "enter", + showBottomHr: true + }, + { + name: "usernameFontOptions", + title: "Username Font Options", + type: "font-options", + default: { + family: "Inter", + weight: 700, + size: 24, + italic: false + }, + allowAlpha: true, + hideColor: true + }, + { + name: "messageFontOptions", + title: "Chat Message Font Options", + type: "font-options", + default: { + family: "Inter", + weight: 400, + size: 24, + italic: false, + color: "#FFFFFF" + }, + allowAlpha: true + } + ], + initialState: { + chatMessages: [] + }, + supportsLivePreview: true, + livePreviewState: { + chatMessages: [ + { + id: "", + timestamp: 1777003200000, + username: "firebot", + userId: "0", + userDisplayName: "Firebot", + profilePicUrl: "https://firebot.app/_next/image?url=%2Ffirebot-logo.png&w=96&q=75", + color: "#FFCA03", + rawText: "thinks chat widgets are neat", + badges: [ + { + title: "moderator", + url: "https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/1" + } + ], + parts: [ + { + type: "text", + text: "thinks chat widgets are neat" + } + ], + action: true, + whisper: false, + tagged: false, + isSharedChatMessage: false, + roles: [] + }, + { + id: "", + timestamp: 1777003260000, + username: "zunderscore", + userId: "0", + userDisplayName: "zunderscore", + profilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/4fe04c1f-8390-4ded-bcb0-cae9b1d7cb9c-profile_image-70x70.png", + color: "#0066FF", + rawText: "Wow, this IS really neat! zunder2Wow", + badges: [ + { + title: "broadcaster", + url: "https://static-cdn.jtvnw.net/badges/v1/5527c58c-fb7d-422d-b71b-f309dcb85cc1/2" + } + ], + parts: [ + { + type: "text", + text: "Wow, this IS really neat! " + }, + { + type: "emote", + url: "https://static-cdn.jtvnw.net/emoticons/v2/emotesv2_ad007f2d2f77444295c883b0a6eaf572/static/light/3.0", + name: "zunder2Wow" + } + ], + action: false, + whisper: false, + tagged: false, + isSharedChatMessage: false, + roles: [] + }, + { + id: "", + timestamp: 1777003260000, + username: "ebiggz", + userId: "0", + userDisplayName: "ebiggz", + profilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/5545fe76-a341-4ffb-bc79-7ca8075588a1-profile_image-70x70.png", + color: "#00d1ff", + rawText: "Yo, what's going on over there?", + badges: [ + { + title: "glhf", + url: "https://static-cdn.jtvnw.net/badges/v1/3158e758-3cb4-43c5-94b3-7639810451c5/2" + } + ], + parts: [ + { + type: "text", + text: "Yo, what's going on over there?" + } + ], + action: false, + whisper: false, + tagged: false, + isSharedChatMessage: true, + sharedChatRoomDisplayName: "ebiggz", + roles: [] + }, + { + id: "", + timestamp: 1777003320000, + username: "zunderscore", + userId: "0", + userDisplayName: "zunderscore", + profilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/4fe04c1f-8390-4ded-bcb0-cae9b1d7cb9c-profile_image-70x70.png", + color: "#0066FF", + rawText: "Super cool stuff!", + badges: [ + { + title: "broadcaster", + url: "https://static-cdn.jtvnw.net/badges/v1/5527c58c-fb7d-422d-b71b-f309dcb85cc1/2" + } + ], + parts: [ + { + type: "text", + text: "Super cool stuff!" + } + ], + action: false, + whisper: false, + tagged: false, + isSharedChatMessage: false, + isHighlighted: true, + roles: [] + }, + { + id: "", + timestamp: 1777003320000, + username: "firebot", + userId: "0", + userDisplayName: "Firebot", + profilePicUrl: "https://firebot.app/_next/image?url=%2Ffirebot-logo.png&w=96&q=75", + color: "#FFCA03", + rawText: "Don't forget to show love to your fellow Firebot users! <3", + badges: [ + { + title: "moderator", + url: "https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/1" + } + ], + parts: [ + { + type: "text", + text: "Don't forget to show love to your fellow Firebot users! " + }, + { + type: "emote", + url: "https://static-cdn.jtvnw.net/emoticons/v2/9/default/dark/2.0#e=0", + name: "<3" + } + ], + action: false, + isAnnouncement: true, + announcementColor: "ORANGE", + whisper: false, + tagged: false, + isSharedChatMessage: false, + roles: [] + } + ] + }, + overlayExtension: { + eventHandler: (event: WidgetOverlayEvent, utils: IOverlayWidgetEventUtils) => { + const generateAnnouncementBarStyle = ( + announcementColor: FirebotChatMessage["announcementColor"], + horizontalAlignment: typeof event["data"]["widgetConfig"]["settings"]["horizontalAlignment"] + ): Record => { + let announcementBackgroundColor: string; + + switch (announcementColor) { + case "BLUE": + announcementBackgroundColor = "linear-gradient(#00d6d6,#9146ff)"; + break; + + case "GREEN": + announcementBackgroundColor = "linear-gradient(#00db84,#57bee6)"; + break; + + case "ORANGE": + announcementBackgroundColor = "linear-gradient(#ffb31a,#e0e000)"; + break; + + case "PURPLE": + announcementBackgroundColor = "linear-gradient(#9146ff,#ff75e6)"; + break; + + default: + announcementBackgroundColor = "rgb(31, 105, 255)"; + break; + } + + const individualAnnouncementBarStyles: Record = { + "background": announcementBackgroundColor + }; + + switch (horizontalAlignment) { + case "right": + individualAnnouncementBarStyles["margin-left"] = "10px"; + break; + + default: + individualAnnouncementBarStyles["margin-right"] = "10px"; + break; + } + + return individualAnnouncementBarStyles; + }; + + const generateChatMessageHtml = ( + chatMessage: FirebotChatMessage, + config: typeof event["data"]["widgetConfig"] + ): string => { + // Ignore AutoModded messages that aren't approved + if (chatMessage.autoModStatus === "pending" + || chatMessage.autoModStatus === "denied" + || chatMessage.autoModStatus === "expired") { + return; + } + + // Don't render announcements unless we specifically want them + if (chatMessage.isAnnouncement === true && config.settings.showAnnouncements !== true) { + return; + } + + // Same with shared chat messages + if (chatMessage.isSharedChatMessage === true && config.settings.showSharedChatMessages !== true) { + return; + } + + const messageContainerStyle: Record = { }; + + if (chatMessage.isAnnouncement === true) { + messageContainerStyle["display"] = "flex"; + messageContainerStyle["flex-direction"] = "row"; + } + + let messageHtml = ` +
`; + + if (chatMessage.isAnnouncement === true) { + let header = "Announcement"; + + if (chatMessage.isSharedChatMessage) { + header += `, sent from ${chatMessage.sharedChatRoomDisplayName}'s chat`; + } + + if (config.settings.horizontalAlignment === "left") { + const individualAnnouncementBarStyles = generateAnnouncementBarStyle( + chatMessage.announcementColor, + config.settings.horizontalAlignment + ); + + messageHtml += ` +
+
+
${header}
`; + } else { + messageHtml += ` +
+
${header}
`; + } + } else { + if (chatMessage.isSharedChatMessage && config.settings.showSharedChatInfo) { + messageHtml += ` +
+
Sent from ${chatMessage.sharedChatRoomDisplayName}'s chat
`; + } + } + + messageHtml += `
`; + + let timestampString: string; + if (config.settings.showTimestamps === true) { + const timestampAsDate = new Date(chatMessage.timestamp); + const hours = timestampAsDate.getHours() % 12 === 0 + ? "12" + : timestampAsDate.getHours() % 12; + const minutes = String(timestampAsDate.getMinutes()).padStart(2, "0"); + + timestampString = `[${hours}:${minutes}]`; + } + + if (timestampString?.length && config.settings.messageStyle === "compact") { + messageHtml += `${timestampString} `; + } + + if (config.settings.showAvatars === true) { + messageHtml += `avatar`; + } + + if (config.settings.showBadges === true && chatMessage.badges?.length) { + const badgesHtml: Array = []; + + for (const badge of chatMessage.badges) { + badgesHtml.push(`${badge.title}`); + } + + messageHtml += `${badgesHtml.join("")}`; + } + + const individualUsernameStyles: Record = { + "color": chatMessage.color + }; + + messageHtml += `${chatMessage.userDisplayName ?? chatMessage.username}`; + + if (timestampString?.length && config.settings.messageStyle === "modern") { + messageHtml += ` ${timestampString}`; + } + + const chatMessagePartsHtml: string[] = []; + + for (const part of chatMessage.parts) { + switch (part.type) { + case "emote": + case "third-party-emote": + chatMessagePartsHtml.push(`${part.name}`); + break; + + case "cheermote": + chatMessagePartsHtml.push(`${part.name}${part.amount}`); + break; + + case "link": + chatMessagePartsHtml.push(part.url); + break; + + default: + chatMessagePartsHtml.push(part.text); + } + } + + messageHtml += ""; + + if (chatMessage.action === true) { + const individualActionStyles: Record = { }; + + if (config.settings.actionDisplayFormat === "classic") { + individualActionStyles["color"] = chatMessage.color; + } + + messageHtml += `${config.settings.messageStyle === "modern" ? "" : " "}`; + } else { + messageHtml += `${config.settings.messageStyle === "modern" ? "" : ": "}`; + } + + if (chatMessage.isHighlighted === true && config.settings.highlightStyle === "highlighted") { + messageHtml += `${chatMessagePartsHtml.join("")}`; + } else { + messageHtml += `${chatMessagePartsHtml.join("")}`; + } + messageHtml += `
`; + + if (chatMessage.isAnnouncement === true) { + if (config.settings.horizontalAlignment === "right") { + const individualAnnouncementBarStyles = generateAnnouncementBarStyle( + chatMessage.announcementColor, + config.settings.horizontalAlignment + ); + + messageHtml += `
`; + } else { + messageHtml += "
"; + } + } else { + if (chatMessage.isSharedChatMessage && config.settings.showSharedChatInfo) { + messageHtml += ""; + } + } + + messageHtml += ``; + + return messageHtml; + }; + + const generateWidgetHtml = (config: typeof event["data"]["widgetConfig"]) => { + const usernameFontSize = (config.settings?.usernameFontOptions?.size ? `${config.settings.messageFontOptions.size}px` : "12px"); + const messageFontSize = (config.settings?.messageFontOptions?.size ? `${config.settings.messageFontOptions.size}px` : "12px"); + + let height = "auto"; + let maxHeight = "100%"; + let justifyContent = "end"; + let anchorToBottom = false; + + switch (config.settings.chatOrder) { + case "reversed": + switch (config.settings.verticalAlignment) { + case "bottom": + // Special snowflake + anchorToBottom = true; + height = "auto"; + maxHeight = "100%"; + justifyContent = "start"; + break; + + case "top": + default: + height = "auto"; + maxHeight = null; + justifyContent = "start"; + break; + } + break; + + case "normal": + default: + switch (config.settings.verticalAlignment) { + case "bottom": + height = "100%"; + maxHeight = "100%"; + justifyContent = "end"; + break; + + case "top": + default: + // Use default values + break; + } + break; + } + + const chatContainerStyles: Record = { + "display": "flex", + "flex-direction": config.settings.chatOrder === "reversed" ? "column-reverse" : "column", + "align-items": config.settings.horizontalAlignment === "right" ? "end" : "start", + "justify-content": justifyContent, + "height": height, + "width": "100%", + "text-align": config.settings.horizontalAlignment + }; + + if (maxHeight?.length) { + chatContainerStyles["max-height"] = maxHeight; + } + + if (anchorToBottom) { + chatContainerStyles["position"] = "absolute"; + chatContainerStyles["bottom"] = "0"; + } + + const announcementBarStyles: Record = { + "width": "10px" + }; + + const announcementMessageContainerStyles: Record = { + "display": "flex", + "flex-direction": "column" + }; + + const messageHeaderStyles: Record = { + "font-family": (config.settings?.messageFontOptions?.family ? `"${config.settings?.messageFontOptions?.family}"` : "Inter, sans-serif"), + "font-size": `calc(${messageFontSize} * 0.75)`, + "font-weight": config.settings?.messageFontOptions?.weight?.toString() || "400", + "font-style": config.settings?.messageFontOptions?.italic ? "italic" : "normal", + "color": config.settings?.messageFontOptions?.color || "#FFFFFF", + "margin-bottom": "5px" + }; + + const messageContentContainerStyles: Record = { + "display": "inline" + }; + + if (config.settings.messageStyle === "modern") { + messageContentContainerStyles["display"] = "flex"; + messageContentContainerStyles["flex-direction"] = config.settings.messageStyle === "modern" ? "column" : "row"; + } + + const avatarStyles: Record = { + "height": usernameFontSize, + "border-radius": "50%", + "margin-right": "5px" + }; + + const badgeStyles: Record = { + "height": usernameFontSize + }; + + const usernameStyles: Record = { + "font-family": (config.settings?.usernameFontOptions?.family ? `"${config.settings?.messageFontOptions?.family}"` : "Inter, sans-serif"), + "font-size": usernameFontSize, + "font-weight": config.settings?.usernameFontOptions?.weight?.toString() || "700", + "font-style": config.settings?.usernameFontOptions?.italic ? "italic" : "normal" + }; + + const messageStyles: Record = { + "font-family": (config.settings?.messageFontOptions?.family ? `"${config.settings?.messageFontOptions?.family}"` : "Inter, sans-serif"), + "font-size": messageFontSize, + "font-weight": config.settings?.messageFontOptions?.weight?.toString() || "400", + "font-style": config.settings?.messageFontOptions?.italic ? "italic" : "normal", + "color": config.settings?.messageFontOptions?.color || "#FFFFFF" + }; + + const highlightedMessageStyles: Record = { + "background-color": config.settings.highlightColor + }; + + const actionStyles: Record = { + "font-style": config.settings?.actionDisplayFormat === "classic" ? "normal" : "italic" + }; + + const styleMarkup = ` + + `; + + const messageDivs: string[] = []; + + for (const chatMessage of config.state?.chatMessages ?? []) { + const messageHtml = generateChatMessageHtml(chatMessage, config); + + if (messageHtml?.length) { + messageDivs.push(messageHtml); + } + } + + return `${styleMarkup}
${messageDivs.join("\n")}
`; + }; + + switch (event.name) { + case "show": + utils.initializeWidget(generateWidgetHtml(event.data.widgetConfig)); + break; + + case "settings-update": + utils.updateWidgetContent(generateWidgetHtml(event.data.widgetConfig)); + utils.updateWidgetPosition(); + break; + + case "message": + switch (event.data.messageName) { + case "chat-message": + { + const chatMessage = (event.data.messageData as ChatMessageMessageData).chatMessage; + const newMessageHtml = generateChatMessageHtml( + chatMessage, + event.data.widgetConfig + ); + + try { + if (newMessageHtml) { + const chatContainer = document.getElementsByClassName(`chat-${event.data.widgetConfig.id}`)[0]; + chatContainer.insertAdjacentHTML("beforeend", newMessageHtml); + + const animationClass = event.data.widgetConfig.settings.newMessageEntryAnimation?.class; + const animationDuration = event.data.widgetConfig.settings.newMessageEntryAnimation?.duration; + + if (animationClass != null && animationClass !== "" && animationClass !== "none") { + const duration = animationDuration ? `${animationDuration}s` : undefined; + // @ts-ignore + $(`[data-message-id="${chatMessage.id}"]`).animateCss(animationClass, duration); + } + + // Trim excess + while (chatContainer.childElementCount > 50) { + chatContainer.removeChild(chatContainer.firstElementChild); + } + } + } catch { } + } + break; + + case "delete-message": + { + const messageId = (event.data.messageData as DeleteMessageMessageData).messageId; + + try { + const messageToRemove = document.querySelector(`[data-message-id="${messageId}"]`); + const chatContainer = document.getElementsByClassName(`chat-${event.data.widgetConfig.id}`)[0]; + chatContainer.removeChild(messageToRemove); + } catch { } + } + break; + + case "delete-user-messages": + try { + const messagesToRemove = document.querySelectorAll(`[data-username="${(event.data.messageData as DeleteUserMessagesMessageData).username}"]`); + const chatContainer = document.getElementsByClassName(`chat-${event.data.widgetConfig.id}`)[0]; + for (const messageToRemove of messagesToRemove) { + chatContainer.removeChild(messageToRemove); + } + } catch { } + break; + + case "clear-chat": + try { + const chatContainer = document.getElementsByClassName(`chat-${event.data.widgetConfig.id}`)[0]; + chatContainer.innerHTML = ""; + } catch { } + break; + } + break; + + case "remove": + utils.removeWidget(); + break; + + case "state-update": + // We don't really care about state here. We only care about state on reloads, which are handled above. + break; + + default: + break; + } + } + } +}; \ No newline at end of file diff --git a/src/backend/overlay-widgets/builtin-types/index.ts b/src/backend/overlay-widgets/builtin-types/index.ts index f7a5ca4c7..298ad4fd7 100644 --- a/src/backend/overlay-widgets/builtin-types/index.ts +++ b/src/backend/overlay-widgets/builtin-types/index.ts @@ -7,6 +7,7 @@ import { text } from "./text/text"; import { currentDateTime } from "./current-date-time/current-date-time"; import { countdownToDate } from "./countdown-to-date/countdown-to-date"; import { image } from "./image/image"; +import { chat } from "./chat/chat"; export default [ progressbar, @@ -17,5 +18,6 @@ export default [ customAdvanced, currentDateTime, text, - image + image, + chat ]; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-client.ts b/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-client.ts index 657ee7c5b..7493d9569 100644 --- a/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-client.ts +++ b/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-client.ts @@ -4,6 +4,7 @@ import { EventSubWsListener } from "@twurple/eventsub-ws"; import type { SavedChannelReward } from "../../../../../types"; import { AccountAccess } from "../../../../common/account-access"; +import { FirebotFrontendChatHelpers } from "../../../../chat/frontend-chat-helpers"; import { SharedChatCache } from "../../chat/shared-chat-cache"; import { TwitchEventHandlers } from "../../events"; import { TwitchApi } from ".."; @@ -13,7 +14,6 @@ import chatHelpers from "../../../../chat/chat-helpers"; import rewardManager from "../../../../channel-rewards/channel-reward-manager"; import twitchStreamInfoPoll from "../../stream-info-manager"; import viewerDatabase from "../../../../viewers/viewer-database"; -import frontendCommunicator from "../../../../common/frontend-communicator"; import logger from "../../../../logwrapper"; import { getChannelRewardImageUrl, @@ -76,8 +76,6 @@ class TwitchEventSubClient { ); break; } - case "combo": - break; case "power_up": { const totalBits = (await TwitchApi.bits.getChannelBitsLeaderboard(1, "all", new Date(), event.userId))[0]?.amount ?? 0; switch (event.powerUp.type) { @@ -124,21 +122,21 @@ class TwitchEventSubClient { // AutoMod message hold v2 const autoModMessageHoldSub = this._eventSubListener.onAutoModMessageHoldV2(streamer.userId, streamer.userId, async (event) => { const firebotChatMessage = await chatHelpers.buildViewerFirebotChatMessageFromAutoModMessage(event); - frontendCommunicator.send("twitch:chat:message", firebotChatMessage); + FirebotFrontendChatHelpers.sendChatMessageToFrontend(firebotChatMessage); }); this._subscriptions.push(autoModMessageHoldSub); // AutoMod message update v2 const autoModMessageUpdateSub = this._eventSubListener.onAutoModMessageUpdateV2(streamer.userId, streamer.userId, (event) => { - frontendCommunicator.send("twitch:chat:automod-update", { - messageId: event.messageId, - newStatus: event.status, - resolverName: event.moderatorName, - resolverId: event.moderatorId, - flaggedPhrases: event.reason === "automod" + FirebotFrontendChatHelpers.updateMessageAutomodStatus( + event.messageId, + event.status, + event.moderatorName, + event.moderatorId, + event.reason === "automod" ? event.autoMod?.boundaries?.map(b => b.text) ?? [] : event.blockedTerms?.map(b => b.text) ?? [] - }); + ); }); this._subscriptions.push(autoModMessageUpdateSub); @@ -567,7 +565,7 @@ class TwitchEventSubClient { ); } - frontendCommunicator.send("twitch:chat:user:delete-messages", event.userName); + FirebotFrontendChatHelpers.deleteUserMessagesFromFrontend(event.userName); }); this._subscriptions.push(banSubscription); @@ -672,7 +670,7 @@ class TwitchEventSubClient { const channelModerateSubscription = this._eventSubListener.onChannelModerate(streamer.userId, streamer.userId, (event) => { switch (event.moderationAction) { case "clear": - frontendCommunicator.send("twitch:chat:clear-feed", event.moderatorName); + FirebotFrontendChatHelpers.clearChatFeed(event.moderatorName); TwitchEventHandlers.chat.triggerChatCleared(event.moderatorName, event.moderatorId); break; case "mod": @@ -724,7 +722,7 @@ class TwitchEventSubClient { event.messageText, event.messageId ); - frontendCommunicator.send("twitch:chat:message:deleted", event.messageId); + FirebotFrontendChatHelpers.deleteMessageFromFrontend(event.messageId); break; // Outbound Raid Starting diff --git a/src/gui/app/app-main.js b/src/gui/app/app-main.js index 4717656d5..d82737004 100644 --- a/src/gui/app/app-main.js +++ b/src/gui/app/app-main.js @@ -256,6 +256,7 @@ dynamicParameterRegistry.register("codemirror", { tag: "fb-param-code-mirror" }); dynamicParameterRegistry.register("counter-select", { tag: "fb-param-counter-select" }); dynamicParameterRegistry.register("sort-tag-select", { tag: "fb-param-sort-tag-select" }); + dynamicParameterRegistry.register("animation-select", { tag: "fb-param-animation-select" }); uiExtensionsService.setAsReady(); }); diff --git a/src/gui/app/directives/controls/dynamic-params/builtin/animation-select.js b/src/gui/app/directives/controls/dynamic-params/builtin/animation-select.js new file mode 100644 index 000000000..5f6758baf --- /dev/null +++ b/src/gui/app/directives/controls/dynamic-params/builtin/animation-select.js @@ -0,0 +1,33 @@ +"use strict"; +(function() { + angular.module("firebotApp").component("fbParamAnimationSelect", { + bindings: { + schema: '<', + value: '<', + onInput: '&', + onTouched: '&' + }, + template: ` +
+ +
+ `, + controller: function($scope) { + const $ctrl = this; + $ctrl.$onInit = function() { + $ctrl.local = $ctrl.value; + }; + $ctrl.$onChanges = function(chg) { + if (chg.value != null && chg.value.currentValue !== $ctrl.local) { + $ctrl.local = chg.value.currentValue; + } + }; + $scope.$watch('$ctrl.local', (newValue) => { + $ctrl.onInput({ value: newValue }); + }); + } + }); +}()); \ No newline at end of file diff --git a/src/gui/app/directives/controls/dynamic-params/builtin/font-options.js b/src/gui/app/directives/controls/dynamic-params/builtin/font-options.js index fc1b80274..48ff918f1 100644 --- a/src/gui/app/directives/controls/dynamic-params/builtin/font-options.js +++ b/src/gui/app/directives/controls/dynamic-params/builtin/font-options.js @@ -14,7 +14,7 @@ ng-model="$ctrl.local.family" ng-click="$ctrl.onTouched()" /> -
+
{ diff --git a/src/types/chat.d.ts b/src/types/chat.d.ts index 367ba9b8e..f469e4662 100644 --- a/src/types/chat.d.ts +++ b/src/types/chat.d.ts @@ -70,6 +70,7 @@ export type FirebotParsedMessagePart = { export type FirebotChatMessage = { id: string; + timestamp?: number; username: string; userId: string; userDisplayName?: string; diff --git a/src/types/parameters.d.ts b/src/types/parameters.d.ts index 83f1d5144..5c71f22ee 100644 --- a/src/types/parameters.d.ts +++ b/src/types/parameters.d.ts @@ -1,4 +1,5 @@ import type { EffectList } from "./effects"; +import type { Animation } from "./overlay-widgets"; export type BaseParameter = { /** @@ -215,13 +216,14 @@ export type FontOptions = { size: number; weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; italic: boolean; - color: string; + color?: string; }; export type FontOptionsParameter = BaseParameter & { type: "font-options"; default: FontOptions; allowAlpha?: boolean; + hideColor?: boolean; }; export type RadioCardsParameter = BaseParameter & { @@ -261,6 +263,12 @@ export type SortTagSelectParameter = BaseParameter & { default?: never; }; +export type AnimationSelectParameter = BaseParameter & { + type: "animation-select"; + animationType: "enter" | "exit" | "inbetween"; + default?: Animation; +}; + export type UnknownParameter = BaseParameter & { [key: string]: unknown; }; @@ -288,7 +296,8 @@ type FirebotParameter = | RadioCardsParameter | CodeMirrorParameter | CounterSelectParameter - | SortTagSelectParameter; + | SortTagSelectParameter + | AnimationSelectParameter; export type ParametersConfig

= { [K in keyof P]: (P[K] extends string @@ -323,7 +332,9 @@ export type ParametersConfig

= { : P[K] extends EffectList ? EffectListParameter : P[K] extends FontOptions - ? FontOptionsParameter : FirebotParameter) & { value?: P[K] }; + ? FontOptionsParameter + : P[K] extends Animation + ? AnimationSelectParameter : FirebotParameter) & { value?: P[K] }; }; export type ParametersWithNameConfig

= { @@ -359,7 +370,9 @@ export type ParametersWithNameConfig

= { : P[K] extends EffectList ? EffectListParameter : P[K] extends FontOptions - ? FontOptionsParameter : FirebotParameter) & { name: K, showIf?: { [K2 in keyof P]?: P[K2] | Array } }; + ? FontOptionsParameter + : P[K] extends Animation + ? AnimationSelectParameter : FirebotParameter) & { name: K, showIf?: { [K2 in keyof P]?: P[K2] | Array } }; }; type FirebotParamCategory> = { From 8ede6f1dc0c84f9e472bb4d6e4b9eaa79315d3ce Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 28 Apr 2026 17:11:39 -0400 Subject: [PATCH 10/71] feat(widgets): Chat widget user filter, message timeout, delay --- src/backend/chat/frontend-chat-helpers.ts | 118 ++++++++++++------ .../builtin-types/chat/chat.ts | 108 ++++++++++++++-- 2 files changed, 180 insertions(+), 46 deletions(-) diff --git a/src/backend/chat/frontend-chat-helpers.ts b/src/backend/chat/frontend-chat-helpers.ts index b1f3f3d2b..d932a5421 100644 --- a/src/backend/chat/frontend-chat-helpers.ts +++ b/src/backend/chat/frontend-chat-helpers.ts @@ -1,9 +1,49 @@ -import type { FirebotChatMessage } from "../../types"; +import type { FirebotChatMessage, OverlayWidgetConfig } from "../../types"; +import type { ChatWidgetSettings, ChatWidgetState } from "../overlay-widgets/builtin-types/chat/chat"; import overlayWidgetsManager from "../overlay-widgets/overlay-widgets-manager"; import overlayWidgetConfigManager from "../overlay-widgets/overlay-widget-config-manager"; import frontendCommunicator from "../common/frontend-communicator"; +import logger from "../logwrapper"; class FirebotFrontendChatHelpers { + private _pendingMessageCache: Record = { }; + + private sendChatMessageToChatWidget(chatWidget: OverlayWidgetConfig, chatMessage: FirebotChatMessage): void { + if (chatWidget.settings.delayMessages === true) { + if (this._pendingMessageCache[chatWidget.id].some(m => m === chatMessage.id)) { + // Remove it from the pending list so we know we've taken care of it + this._pendingMessageCache[chatWidget.id] = this._pendingMessageCache[chatWidget.id] + .filter(m => m !== chatMessage.id); + } else { + logger.info(`Chat message ${chatMessage.id} not in pending cache for widget ${chatWidget.id}; ignoring`); + return; + } + } + + const existingChatMessages = chatWidget.state?.chatMessages ?? []; + overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { + chatMessages: [...existingChatMessages.slice(-99), chatMessage] + }); + + void overlayWidgetsManager.sendWidgetEventToOverlay( + "message", + chatWidget, + { + messageName: "chat-message", + messageData: { + chatMessage + } + } + ); + + const messageTimeout = chatWidget.settings.messageTimeout; + if (chatWidget.settings.autoRemoveMessages === true && messageTimeout != null && messageTimeout > 0) { + setTimeout(() => { + this.deleteMessageFromChatWidget(chatWidget, chatMessage.id, true); + }, messageTimeout * 1000); + } + } + sendChatMessageToFrontend(chatMessage: FirebotChatMessage): void { frontendCommunicator.send("twitch:chat:message", chatMessage); @@ -17,58 +57,62 @@ class FirebotFrontendChatHelpers { chatMessage.timestamp = new Date().getTime(); - const chatWidgets = overlayWidgetConfigManager.getConfigsOfType("firebot:chat"); + const chatWidgets = overlayWidgetConfigManager.getConfigsOfType>("firebot:chat"); for (const chatWidget of chatWidgets) { - const existingChatMessages = (chatWidget.state?.chatMessages ?? []) as FirebotChatMessage[]; - overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { - chatMessages: [...existingChatMessages.slice(-49), chatMessage] - }); + if (chatWidget.settings.delayMessages === true && chatWidget.settings.messageDelay) { + this._pendingMessageCache[chatWidget.id] ??= []; - void overlayWidgetsManager.sendWidgetEventToOverlay( - "message", - chatWidget, - { - messageName: "chat-message", - messageData: { - chatMessage - } - } - ); + this._pendingMessageCache[chatWidget.id].push(chatMessage.id); + + setTimeout(() => { + this.sendChatMessageToChatWidget(chatWidget, chatMessage); + }, chatWidget.settings.messageDelay * 1000); + } else { + this.sendChatMessageToChatWidget(chatWidget, chatMessage); + } } } - deleteMessageFromFrontend(messageId: string): void { - frontendCommunicator.send("twitch:chat:message:deleted", messageId); + private deleteMessageFromChatWidget(chatWidget: OverlayWidgetConfig, messageId: string, animate = false): void { + this._pendingMessageCache[chatWidget.id] = (this._pendingMessageCache[chatWidget.id] ?? []) + .filter(m => m !== messageId) ?? []; - const chatWidgets = overlayWidgetConfigManager.getConfigsOfType("firebot:chat"); - for (const chatWidget of chatWidgets) { - const chatMessages = ((chatWidget.state?.chatMessages ?? []) as FirebotChatMessage[]) - .filter(m => m.id !== messageId); + const chatMessages = (chatWidget.state?.chatMessages ?? []) + .filter(m => m.id !== messageId); - overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { - chatMessages: chatMessages - }); + overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { + chatMessages: chatMessages + }); - void overlayWidgetsManager.sendWidgetEventToOverlay( - "message", - chatWidget, - { - messageName: "delete-message", - messageData: { - messageId - } + void overlayWidgetsManager.sendWidgetEventToOverlay( + "message", + chatWidget, + { + messageName: "delete-message", + messageData: { + messageId, + animate } - ); + } + ); + } + + deleteMessageFromFrontend(messageId: string, animate = false): void { + frontendCommunicator.send("twitch:chat:message:deleted", messageId); + + const chatWidgets = overlayWidgetConfigManager.getConfigsOfType>("firebot:chat"); + for (const chatWidget of chatWidgets) { + this.deleteMessageFromChatWidget(chatWidget, messageId, animate); } } deleteUserMessagesFromFrontend(username: string) { frontendCommunicator.send("twitch:chat:user:delete-messages", username); - const chatWidgets = overlayWidgetConfigManager.getConfigsOfType("firebot:chat"); + const chatWidgets = overlayWidgetConfigManager.getConfigsOfType>("firebot:chat"); for (const chatWidget of chatWidgets) { - const chatMessages = ((chatWidget.state?.chatMessages ?? []) as FirebotChatMessage[]) + const chatMessages = (chatWidget.state?.chatMessages ?? []) .filter(m => m.username !== username); overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { @@ -91,7 +135,7 @@ class FirebotFrontendChatHelpers { clearChatFeed(moderatorName: string): void { frontendCommunicator.send("twitch:chat:clear-feed", moderatorName); - const chatWidgets = overlayWidgetConfigManager.getConfigsOfType("firebot:chat"); + const chatWidgets = overlayWidgetConfigManager.getConfigsOfType>("firebot:chat"); for (const chatWidget of chatWidgets) { overlayWidgetConfigManager.setWidgetStateById(chatWidget.id, { chatMessages: [] diff --git a/src/backend/overlay-widgets/builtin-types/chat/chat.ts b/src/backend/overlay-widgets/builtin-types/chat/chat.ts index bd40c5796..a6359aa2e 100644 --- a/src/backend/overlay-widgets/builtin-types/chat/chat.ts +++ b/src/backend/overlay-widgets/builtin-types/chat/chat.ts @@ -7,7 +7,7 @@ import type { Animation } from "../../../../types"; -type Settings = { +export type ChatWidgetSettings = { showTimestamps?: boolean; showAvatars?: boolean; showBadges?: boolean; @@ -19,15 +19,21 @@ type Settings = { actionDisplayFormat: "modern" | "classic"; highlightStyle?: "normal" | "highlighted"; highlightColor?: string; + hiddenUsers: string[]; + delayMessages?: boolean; + messageDelay?: number; horizontalAlignment: "left" | "right"; verticalAlignment: "top" | "bottom"; spaceBetweenMessages?: number; newMessageEntryAnimation: Animation; + autoRemoveMessages?: boolean; + messageTimeout?: number; + messageExitAnimation?: Animation; usernameFontOptions: FontOptions; messageFontOptions: FontOptions; }; -type State = { +export type ChatWidgetState = { chatMessages: Array; }; @@ -37,13 +43,14 @@ type ChatMessageMessageData = { type DeleteMessageMessageData = { messageId: string; + animate: boolean; }; type DeleteUserMessagesMessageData = { username: string; }; -export const chat: OverlayWidgetType = { +export const chat: OverlayWidgetType = { id: "firebot:chat", name: "Chat", description: "A basic chat feed for the overlay", @@ -192,6 +199,38 @@ export const chat: OverlayWidgetType = { highlightStyle: "highlighted" } }, + { + name: "hiddenUsers", + title: "Hidden Users", + description: "List of usernames whose messages will not be displayed in the chat widget", + type: "editable-list", + default: [], + settings: { + useTextArea: false, + sortable: true, + addLabel: "Add Username", + editLabel: "Edit Username", + noneAddedText: "No users hidden in chat widget" + } + }, + { + name: "delayMessages", + title: "Add Message Delay", + description: "Adds a delay between when a message arrives and when it is sent to the widget. Useful for moderation.", + type: "boolean", + default: false + }, + { + name: "messageDelay", + title: "Message Delay", + description: "How long (in seconds) to wait before sending a chat message to the widget", + type: "number", + default: 0, + showIf: { + delayMessages: true + }, + showBottomHr: true + }, { name: "horizontalAlignment", title: "Horizontal Alignment", @@ -235,8 +274,34 @@ export const chat: OverlayWidgetType = { title: "New Message Entry Animation", description: "Animation to use when new messages arrive", type: "animation-select", - animationType: "enter", - showBottomHr: true + animationType: "enter" + }, + { + name: "autoRemoveMessages", + title: "Automatically Remove Messages", + description: "Removes messages from the chat widget after a specified amount of time", + type: "boolean", + default: false + }, + { + name: "messageTimeout", + title: "Message Timeout", + description: "Amount of time (in seconds) to automatically remove chat messages from the widget", + type: "number", + default: 10, + showIf: { + autoRemoveMessages: true + } + }, + { + name: "messageExitAnimation", + title: "Message Exit Animation", + description: "Animation to use when messages are automatically removed from the widget", + type: "animation-select", + animationType: "exit", + showIf: { + autoRemoveMessages: true + } }, { name: "usernameFontOptions", @@ -423,7 +488,7 @@ export const chat: OverlayWidgetType = { ] }, overlayExtension: { - eventHandler: (event: WidgetOverlayEvent, utils: IOverlayWidgetEventUtils) => { + eventHandler: (event: WidgetOverlayEvent, utils: IOverlayWidgetEventUtils) => { const generateAnnouncementBarStyle = ( announcementColor: FirebotChatMessage["announcementColor"], horizontalAlignment: typeof event["data"]["widgetConfig"]["settings"]["horizontalAlignment"] @@ -480,6 +545,13 @@ export const chat: OverlayWidgetType = { return; } + // Check the ignore list + if (config.settings.hiddenUsers?.some(u => + u.toLowerCase() === chatMessage.username.toLowerCase() || u.toLowerCase() === chatMessage.userDisplayName?.toLowerCase() + )) { + return; + } + // Don't render announcements unless we specifically want them if (chatMessage.isAnnouncement === true && config.settings.showAnnouncements !== true) { return; @@ -873,11 +945,11 @@ export const chat: OverlayWidgetType = { if (animationClass != null && animationClass !== "" && animationClass !== "none") { const duration = animationDuration ? `${animationDuration}s` : undefined; // @ts-ignore - $(`[data-message-id="${chatMessage.id}"]`).animateCss(animationClass, duration); + $(`.chat-${event.data.widgetConfig.id}`).find(`[data-message-id="${chatMessage.id}"]`).animateCss(animationClass, duration); } // Trim excess - while (chatContainer.childElementCount > 50) { + while (chatContainer.childElementCount > 100) { chatContainer.removeChild(chatContainer.firstElementChild); } } @@ -888,11 +960,29 @@ export const chat: OverlayWidgetType = { case "delete-message": { const messageId = (event.data.messageData as DeleteMessageMessageData).messageId; + const animate = (event.data.messageData as DeleteMessageMessageData).animate; try { const messageToRemove = document.querySelector(`[data-message-id="${messageId}"]`); const chatContainer = document.getElementsByClassName(`chat-${event.data.widgetConfig.id}`)[0]; - chatContainer.removeChild(messageToRemove); + + if (messageToRemove) { + if (animate === true) { + const animationClass = event.data.widgetConfig.settings.messageExitAnimation?.class; + const animationDuration = event.data.widgetConfig.settings.messageExitAnimation?.duration; + + if (animationClass != null && animationClass !== "" && animationClass !== "none") { + const duration = animationDuration ? `${animationDuration}s` : undefined; + + // @ts-ignore + $(`.chat-${event.data.widgetConfig.id}`).find(`[data-message-id="${messageId}"]`).animateCss(animationClass, duration, null, null, () => { + chatContainer.removeChild(messageToRemove); + }); + } + } else { + chatContainer.removeChild(messageToRemove); + } + } } catch { } } break; From ed3706ede6169462df49c7af83e2e26c4d594e72 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 28 Apr 2026 22:03:55 -0400 Subject: [PATCH 11/71] feat(chat): assign random user color when not specified --- src/backend/chat/chat-helpers.ts | 15 ++++++++++++++- .../api/eventsub/eventsub-chat-helpers.ts | 19 ++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/backend/chat/chat-helpers.ts b/src/backend/chat/chat-helpers.ts index 66d964249..c1ffdb340 100644 --- a/src/backend/chat/chat-helpers.ts +++ b/src/backend/chat/chat-helpers.ts @@ -1,6 +1,7 @@ import { HelixChatBadgeSet, HelixCheermoteList, HelixEmoteScale } from "@twurple/api"; import { ChatMessage, ParsedMessageCheerPart, ParsedMessagePart, findCheermotePositions, parseChatMessage } from "@twurple/chat"; import { EventSubAutoModMessageHoldV2Event } from "@twurple/eventsub-base"; +import tinycolor from "tinycolor2"; import { FirebotChatMessage, @@ -40,6 +41,7 @@ interface HelixEmoteBase { class FirebotChatHelpers { private _badgeCache: HelixChatBadgeSet[] = []; + private _colorCache: Record = {}; private _getAllTwitchEmotes = false; private _twitchEmotes: { @@ -283,6 +285,16 @@ class FirebotChatHelpers { } } + private cacheUserColor(userId: string, color?: string): string { + if (color?.length) { + this._colorCache[userId] = color; + } else if (this._colorCache[userId] == null) { + this._colorCache[userId] = tinycolor.random().toHexString(); + } + + return this._colorCache[userId]; + } + private _parseMessageParts(firebotChatMessage: FirebotChatMessage, parts: ParsedMessagePart[] | FirebotParsedMessagePart[]) { if (firebotChatMessage == null || parts == null) { return []; @@ -600,7 +612,7 @@ class FirebotChatHelpers { firebotChatMessage.isCheer = msg.isCheer === true; - firebotChatMessage.color = msg.userInfo.color; + firebotChatMessage.color = this.cacheUserColor(msg.userInfo.userId, msg.userInfo.color); return firebotChatMessage; } @@ -644,6 +656,7 @@ class FirebotChatHelpers { userDisplayName: msg.userDisplayName, rawText: msg.messageText, profilePicUrl: profilePicUrl, + color: this.cacheUserColor(msg.userId), whisper: false, action: false, tagged: false, diff --git a/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-chat-helpers.ts b/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-chat-helpers.ts index 021f21d34..872a5a20c 100644 --- a/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-chat-helpers.ts +++ b/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-chat-helpers.ts @@ -9,6 +9,7 @@ import { type EventSubAutoModMessageHoldV2Event, type EventSubChannelChatNotificationEvent, type EventSubUserWhisperMessageEvent, + type EventSubChannelChatAnnouncementColor, EventSubChannelChatAnnouncementNotificationEvent, EventSubChannelChatMessageEvent } from "@twurple/eventsub-base"; @@ -19,6 +20,7 @@ import type { EventSubChatMessageMentionPart, EventSubChatMessagePart } from "../twurple-private-types"; +import tinycolor from "tinycolor2"; import type { FirebotChatMessage, @@ -67,6 +69,7 @@ class TwitchEventSubChatHelpers { readonly HIGHLIGHT_MESSAGE_REWARD_ID = "highlight-message"; private _badgeCache: HelixChatBadgeSet[] = []; + private _colorCache: Record = { }; private _getAllTwitchEmotes = false; private _twitchEmotes: { @@ -524,6 +527,16 @@ class TwitchEventSubChatHelpers { } } + private cacheUserColor(userId: string, color?: string | null): string { + if (color?.length) { + this._colorCache[userId] = color; + } else if (this._colorCache[userId] == null) { + this._colorCache[userId] = tinycolor.random().toHexString(); + } + + return this._colorCache[userId]; + } + /** * Parses out any control characters from a chat message (like ACTION when /me is used) * @param rawText Raw message text @@ -547,7 +560,7 @@ class TwitchEventSubChatHelpers { && event.sourceBroadcasterId !== AccountAccess.getAccounts().streamer.userId; let isAnnouncement = false; - let announcementColor = undefined; + let announcementColor: string | undefined = undefined; if (event instanceof EventSubChannelChatAnnouncementNotificationEvent) { isAnnouncement = true; @@ -560,7 +573,7 @@ class TwitchEventSubChatHelpers { userId: event.chatterId, userDisplayName: event.chatterDisplayName, rawText: isAction ? this.getChatMessage(event.messageText) : event.messageText, - color: event.color, + color: this.cacheUserColor(event.chatterId, event.color), badges: this.parseChatBadges(event.badges), parts: [], roles: [], @@ -571,7 +584,7 @@ class TwitchEventSubChatHelpers { action: isAction, isAnnouncement, // eslint-disable-next-line - announcementColor: announcementColor ? announcementColor.toUpperCase() : undefined, + announcementColor: (announcementColor ? announcementColor.toUpperCase() : undefined) as FirebotChatMessage["announcementColor"], isCheer: false, isReply: false, isHiddenFromChatFeed: false, From 2791951244823430952342ca53d4b4004e63156b Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 28 Apr 2026 22:33:09 -0400 Subject: [PATCH 12/71] feat(widgets): chat widget third-party emotes --- .../builtin-types/chat/chat.ts | 162 ++++++++++-------- 1 file changed, 95 insertions(+), 67 deletions(-) diff --git a/src/backend/overlay-widgets/builtin-types/chat/chat.ts b/src/backend/overlay-widgets/builtin-types/chat/chat.ts index a6359aa2e..ae5c90a98 100644 --- a/src/backend/overlay-widgets/builtin-types/chat/chat.ts +++ b/src/backend/overlay-widgets/builtin-types/chat/chat.ts @@ -14,21 +14,22 @@ export type ChatWidgetSettings = { showSharedChatMessages?: boolean; showSharedChatInfo?: boolean; showAnnouncements?: boolean; + delayMessages?: boolean; + messageDelay?: number; + newMessageEntryAnimation: Animation; + autoRemoveMessages?: boolean; + messageTimeout?: number; + messageExitAnimation?: Animation; messageStyle?: "compact" | "modern"; chatOrder?: "normal" | "reversed"; actionDisplayFormat: "modern" | "classic"; highlightStyle?: "normal" | "highlighted"; highlightColor?: string; + thirdPartyEmotes: string[]; hiddenUsers: string[]; - delayMessages?: boolean; - messageDelay?: number; horizontalAlignment: "left" | "right"; verticalAlignment: "top" | "bottom"; spaceBetweenMessages?: number; - newMessageEntryAnimation: Animation; - autoRemoveMessages?: boolean; - messageTimeout?: number; - messageExitAnimation?: Animation; usernameFontOptions: FontOptions; messageFontOptions: FontOptions; }; @@ -101,8 +102,58 @@ export const chat: OverlayWidgetType = { title: "Show Announcements", description: "Display chat announcements sent from the streamer or moderators", type: "boolean", - default: false, - showBottomHr: true + default: false + }, + { + name: "delayMessages", + title: "Add Message Delay", + description: "Adds a delay between when a message arrives and when it is sent to the widget. Useful for moderation.", + type: "boolean", + default: false + }, + { + name: "messageDelay", + title: "Message Delay", + description: "How long (in seconds) to wait before sending a chat message to the widget", + type: "number", + default: 0, + showIf: { + delayMessages: true + } + }, + { + name: "newMessageEntryAnimation", + title: "New Message Entry Animation", + description: "Animation to use when new messages arrive", + type: "animation-select", + animationType: "enter" + }, + { + name: "autoRemoveMessages", + title: "Automatically Remove Messages", + description: "Removes messages from the chat widget after a specified amount of time", + type: "boolean", + default: false + }, + { + name: "messageTimeout", + title: "Message Timeout", + description: "Amount of time (in seconds) to automatically remove chat messages from the widget", + type: "number", + default: 10, + showIf: { + autoRemoveMessages: true + } + }, + { + name: "messageExitAnimation", + title: "Message Exit Animation", + description: "Animation to use when messages are automatically removed from the widget", + type: "animation-select", + animationType: "exit", + showIf: { + autoRemoveMessages: true + } }, { name: "messageStyle", @@ -189,8 +240,8 @@ export const chat: OverlayWidgetType = { name: "highlightColor", title: "Highlighted Message Background Color", type: "hexcolor", - allowAlpha: true, default: "#755ebc", + allowAlpha: true, validation: { required: true, pattern: "^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$" @@ -199,6 +250,29 @@ export const chat: OverlayWidgetType = { highlightStyle: "highlighted" } }, + { + name: "thirdPartyEmotes", + title: "Enabled Third-Party Emote Providers", + description: "NOTE: Any third-party emote services enabled here must also be enabled in Dashboard settings in order to show emotes from those services.", + type: "multiselect", + default: [], + settings: { + options: [ + { + id: "BTTV", + name: "BTTV" + }, + { + id: "7TV", + name: "7TV" + }, + { + id: "FFZ", + name: "FFZ" + } + ] + } + }, { name: "hiddenUsers", title: "Hidden Users", @@ -213,29 +287,12 @@ export const chat: OverlayWidgetType = { noneAddedText: "No users hidden in chat widget" } }, - { - name: "delayMessages", - title: "Add Message Delay", - description: "Adds a delay between when a message arrives and when it is sent to the widget. Useful for moderation.", - type: "boolean", - default: false - }, - { - name: "messageDelay", - title: "Message Delay", - description: "How long (in seconds) to wait before sending a chat message to the widget", - type: "number", - default: 0, - showIf: { - delayMessages: true - }, - showBottomHr: true - }, { name: "horizontalAlignment", title: "Horizontal Alignment", description: "Horizontal alignment of the chat messages within the widget area.", type: "radio-cards", + default: "left", options: [{ value: "left", label: "Left", iconClass: "fa-align-left" }, { @@ -243,14 +300,14 @@ export const chat: OverlayWidgetType = { }], settings: { gridColumns: 2 - }, - default: "left" + } }, { name: "verticalAlignment", title: "Vertical Alignment", description: "Vertical alignment of the chat messages within the widget area.", type: "radio-cards", + default: "top", options: [{ value: "top", label: "Top", iconClass: "fa-arrow-to-top" }, { @@ -258,9 +315,7 @@ export const chat: OverlayWidgetType = { }], settings: { gridColumns: 2 - }, - default: "top", - showBottomHr: true + } }, { name: "spaceBetweenMessages", @@ -269,40 +324,6 @@ export const chat: OverlayWidgetType = { type: "number", default: 5 }, - { - name: "newMessageEntryAnimation", - title: "New Message Entry Animation", - description: "Animation to use when new messages arrive", - type: "animation-select", - animationType: "enter" - }, - { - name: "autoRemoveMessages", - title: "Automatically Remove Messages", - description: "Removes messages from the chat widget after a specified amount of time", - type: "boolean", - default: false - }, - { - name: "messageTimeout", - title: "Message Timeout", - description: "Amount of time (in seconds) to automatically remove chat messages from the widget", - type: "number", - default: 10, - showIf: { - autoRemoveMessages: true - } - }, - { - name: "messageExitAnimation", - title: "Message Exit Animation", - description: "Animation to use when messages are automatically removed from the widget", - type: "animation-select", - animationType: "exit", - showIf: { - autoRemoveMessages: true - } - }, { name: "usernameFontOptions", title: "Username Font Options", @@ -651,10 +672,17 @@ export const chat: OverlayWidgetType = { for (const part of chatMessage.parts) { switch (part.type) { case "emote": - case "third-party-emote": chatMessagePartsHtml.push(`${part.name}`); break; + case "third-party-emote": + if (config.settings.thirdPartyEmotes.some(e => e === part.origin)) { + chatMessagePartsHtml.push(`${part.name}`); + } else { + chatMessagePartsHtml.push(part.name); + } + break; + case "cheermote": chatMessagePartsHtml.push(`${part.name}${part.amount}`); break; From 336dde67094621014add1c7a0afd127b73af1bee Mon Sep 17 00:00:00 2001 From: Dennis Rijsdijk Date: Thu, 30 Apr 2026 23:36:14 +0200 Subject: [PATCH 13/71] fix: clientside handling of relay websocket pings --- .../crowbar-relay/crowbar-relay-websocket.ts | 17 ++++++++++++++++ src/backend/reconnecting-websocket/events.ts | 10 ++++++---- src/backend/reconnecting-websocket/index.ts | 20 +++++++++++++------ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/backend/crowbar-relay/crowbar-relay-websocket.ts b/src/backend/crowbar-relay/crowbar-relay-websocket.ts index ec9f4d0ab..c7b3b68bf 100644 --- a/src/backend/crowbar-relay/crowbar-relay-websocket.ts +++ b/src/backend/crowbar-relay/crowbar-relay-websocket.ts @@ -52,16 +52,33 @@ class CrowbarRelayWebSocket extends TypedEmitter<{ } }); + let pingTimeout: NodeJS.Timeout; + + function setPingTimeout() { + clearTimeout(pingTimeout); + pingTimeout = setTimeout(() => { + logger.warn("Crowbar Relay WebSocket ping timeout, reconnecting..."); + this.ws.reconnect(); + }, 75_000); + } + + setPingTimeout(); + this.ws.addEventListener("open", () => { logger.info("Crowbar Relay WebSocket connected!"); this.emit("ready"); }); + this.ws.addEventListener("ping", () => { + setPingTimeout(); + }); + this.ws.addEventListener("error", (err) => { logger.error("Crowbar Relay WebSocket errored", err); }); this.ws.addEventListener("close", (closedEvent) => { + clearTimeout(pingTimeout); const unauthorized = closedEvent.target?._ws?._req?.res?.statusCode === 401; if (unauthorized) { logger.error("Crowbar Relay WebSocket unauthorized!"); diff --git a/src/backend/reconnecting-websocket/events.ts b/src/backend/reconnecting-websocket/events.ts index 70fb4249a..9717323b7 100644 --- a/src/backend/reconnecting-websocket/events.ts +++ b/src/backend/reconnecting-websocket/events.ts @@ -33,11 +33,13 @@ export interface WebSocketEventMap { error: ErrorEvent; message: MessageEvent; open: Event; + ping: Event; } export interface WebSocketEventListenerMap { - close: (event: CloseEvent) => void | {handleEvent: (event: CloseEvent) => void}; - error: (event: ErrorEvent) => void | {handleEvent: (event: ErrorEvent) => void}; - message: (event: MessageEvent) => void | {handleEvent: (event: MessageEvent) => void}; - open: (event: Event) => void | {handleEvent: (event: Event) => void}; + close: (event: CloseEvent) => void | { handleEvent: (event: CloseEvent) => void }; + error: (event: ErrorEvent) => void | { handleEvent: (event: ErrorEvent) => void }; + message: (event: MessageEvent) => void | { handleEvent: (event: MessageEvent) => void }; + open: (event: Event) => void | { handleEvent: (event: Event) => void }; + ping: (event: Event) => void | { handleEvent: (event: Event) => void }; } \ No newline at end of file diff --git a/src/backend/reconnecting-websocket/index.ts b/src/backend/reconnecting-websocket/index.ts index db4221e34..70269aa76 100644 --- a/src/backend/reconnecting-websocket/index.ts +++ b/src/backend/reconnecting-websocket/index.ts @@ -22,7 +22,7 @@ export type Options = { maxEnqueuedMessages?: number; startClosed?: boolean; debug?: boolean; - wsOptions?: ClientOptions + wsOptions?: ClientOptions; }; const DEFAULT = { @@ -46,6 +46,7 @@ export type ListenersMap = { message: Array; open: Array; close: Array; + ping: Array; }; export default class ReconnectingWebSocket { @@ -54,7 +55,8 @@ export default class ReconnectingWebSocket { error: [], message: [], open: [], - close: [] + close: [], + ping: [] }; private _retryCount = -1; private _uptimeTimeout: any; @@ -243,7 +245,7 @@ export default class ReconnectingWebSocket { this._debug('send', data); this._ws.send(data as any); } else { - const {maxEnqueuedMessages = DEFAULT.maxEnqueuedMessages} = this._options; + const { maxEnqueuedMessages = DEFAULT.maxEnqueuedMessages } = this._options; if (this._messageQueue.length < maxEnqueuedMessages) { this._debug('enqueue', data); this._messageQueue.push(data); @@ -360,14 +362,14 @@ export default class ReconnectingWebSocket { if (!isWebSocket(WebSocket)) { throw Error('No valid WebSocket class provided'); } - this._wait() + void this._wait() .then(() => this._getNextUrl(this._url)) .then((url) => { // close could be called before creating the ws if (this._closeCalled) { return; } - this._debug('connect', {url, protocols: this._protocols}); + this._debug('connect', { url, protocols: this._protocols }); this._ws = new WebSocket(url, this._protocols, wsOptions); this._ws.binaryType = this._binaryType as any; this._connectLock = false; @@ -416,7 +418,7 @@ export default class ReconnectingWebSocket { private _handleOpen = (event: Event) => { this._debug('open event'); - const {minUptime = DEFAULT.minUptime} = this._options; + const { minUptime = DEFAULT.minUptime } = this._options; clearTimeout(this._connectTimeout); this._uptimeTimeout = setTimeout(() => this._acceptOpen(), minUptime); @@ -455,6 +457,10 @@ export default class ReconnectingWebSocket { this._connect(); }; + private _handlePing = (event: Event) => { + this._listeners.ping.forEach(listener => this._callEventListener(event, listener)); + }; + private _handleClose = (event: Events.CloseEvent) => { this._debug('close event'); this._clearTimeouts(); @@ -479,6 +485,7 @@ export default class ReconnectingWebSocket { this._ws.removeEventListener('message', this._handleMessage as any); // @ts-ignore this._ws.removeEventListener('error', this._handleError); + this._ws.removeListener('ping', this._handlePing); } private _addListeners() { @@ -491,6 +498,7 @@ export default class ReconnectingWebSocket { this._ws.addEventListener('message', this._handleMessage as any); // @ts-ignore this._ws.addEventListener('error', this._handleError); + this._ws.addListener('ping', this._handlePing); } private _clearTimeouts() { From 017ab13bcc2b803b0c1c9f4ff95c38a8ca24cd33 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 1 May 2026 20:23:30 -0400 Subject: [PATCH 14/71] fix(widgets): chat widget HTML gen --- src/backend/overlay-widgets/builtin-types/chat/chat.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/backend/overlay-widgets/builtin-types/chat/chat.ts b/src/backend/overlay-widgets/builtin-types/chat/chat.ts index ae5c90a98..b3c93b3e0 100644 --- a/src/backend/overlay-widgets/builtin-types/chat/chat.ts +++ b/src/backend/overlay-widgets/builtin-types/chat/chat.ts @@ -715,7 +715,8 @@ export const chat: OverlayWidgetType = { } else { messageHtml += `${chatMessagePartsHtml.join("")}`; } - messageHtml += `

`; + + messageHtml += ``; if (chatMessage.isAnnouncement === true) { if (config.settings.horizontalAlignment === "right") { @@ -724,7 +725,7 @@ export const chat: OverlayWidgetType = { config.settings.horizontalAlignment ); - messageHtml += `
`; + messageHtml += `
`; } else { messageHtml += ""; } @@ -732,9 +733,11 @@ export const chat: OverlayWidgetType = { if (chatMessage.isSharedChatMessage && config.settings.showSharedChatInfo) { messageHtml += ""; } + + messageHtml += ``; } - messageHtml += ``; + messageHtml += ``; return messageHtml; }; From 4cbb4665a3753447a734a2a4698970c4e15bb242 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 1 May 2026 22:53:27 -0400 Subject: [PATCH 15/71] chore(pronouns, vars): pronoun manager, refactor pronouns var --- .../electron/events/when-ready.ts | 4 + src/backend/chat/chat-helpers.ts | 3 + src/backend/pronouns/pronoun-manager.ts | 120 ++++++++++++++++++ .../api/eventsub/eventsub-chat-helpers.ts | 18 ++- .../variables/builtin/user/pronouns.ts | 77 ++++------- .../chat/feed items/chat-message.js | 9 +- src/gui/app/index.html | 1 - src/gui/app/services/chat-messages.service.js | 4 +- src/gui/app/services/pronouns.service.js | 78 ------------ src/types/chat.d.ts | 1 + src/types/index.d.ts | 1 + src/types/pronouns.d.ts | 11 ++ 12 files changed, 178 insertions(+), 149 deletions(-) create mode 100644 src/backend/pronouns/pronoun-manager.ts delete mode 100644 src/gui/app/services/pronouns.service.js create mode 100644 src/types/pronouns.d.ts diff --git a/src/backend/app-management/electron/events/when-ready.ts b/src/backend/app-management/electron/events/when-ready.ts index edb4d07c0..21dc4dc09 100644 --- a/src/backend/app-management/electron/events/when-ready.ts +++ b/src/backend/app-management/electron/events/when-ready.ts @@ -23,6 +23,10 @@ export async function whenReady() { const { ensureRequiredFoldersExist } = await import("../../data-tasks"); await ensureRequiredFoldersExist(); + windowManagement.updateSplashScreenStatus("Loading pronoun cache..."); + const { FirebotPronounManager } = await import ("../../../pronouns/pronoun-manager"); + await FirebotPronounManager.cachePronouns(); + // load twitch auth windowManagement.updateSplashScreenStatus("Loading Twitch login system..."); await import("../../../auth/auth-manager"); diff --git a/src/backend/chat/chat-helpers.ts b/src/backend/chat/chat-helpers.ts index c1ffdb340..b33f267fb 100644 --- a/src/backend/chat/chat-helpers.ts +++ b/src/backend/chat/chat-helpers.ts @@ -11,6 +11,7 @@ import { import { FirebotAccount } from "../../types/accounts"; import { AccountAccess } from "../common/account-access"; +import { FirebotPronounManager } from "../pronouns/pronoun-manager"; import { SettingsManager } from "../common/settings-manager"; import { SharedChatCache } from "../streaming-platforms/twitch/chat/shared-chat-cache"; import { TwitchApi } from "../streaming-platforms/twitch/api"; @@ -497,6 +498,7 @@ class FirebotChatHelpers { username: msg.userInfo.userName, userId: msg.userInfo.userId, userDisplayName: msg.userInfo.displayName, + pronouns: await FirebotPronounManager.getUserFriendlyPronounString(msg.userInfo.userName), customRewardId: msg.tags.get("custom-reward-id") || undefined, isHighlighted: msg.tags.get("msg-id") === "highlighted-message", isAnnouncement: false, @@ -656,6 +658,7 @@ class FirebotChatHelpers { userDisplayName: msg.userDisplayName, rawText: msg.messageText, profilePicUrl: profilePicUrl, + pronouns: await FirebotPronounManager.getUserFriendlyPronounString(msg.userName), color: this.cacheUserColor(msg.userId), whisper: false, action: false, diff --git a/src/backend/pronouns/pronoun-manager.ts b/src/backend/pronouns/pronoun-manager.ts new file mode 100644 index 000000000..4a9f25a62 --- /dev/null +++ b/src/backend/pronouns/pronoun-manager.ts @@ -0,0 +1,120 @@ +import { app } from "electron"; +import NodeCache from "node-cache"; + +import { Pronoun, UserPronoun } from "../../types"; + +import frontendCommunicator from "../common/frontend-communicator"; +import logger from "../logwrapper"; + +const PRONOUN_SERVICE_BASE_URL = "https://api.pronouns.alejo.io/v1/"; + +type UserPronounResponse = { + channel_id: string; + channel_login: string; + pronoun_id: string; + alt_pronoun_id?: string; +}; + +class FirebotPronounManager { + private _appVersion = app.getVersion(); + private _pronounCache: Record = { }; + private _userPronounCache = new NodeCache({ + stdTTL: 15 * 60 + }); + private _userNoPronounCache = new NodeCache({ + stdTTL: 15 * 60 + }); + + constructor() { + frontendCommunicator.onAsync("pronouns:get-user-pronouns", this.getUserPronouns); + } + + private async callUrl(url: string): Promise { + return await fetch(url, { + headers: { + "User-Agent": `Firebot/${this._appVersion}` + } + }); + } + + async cachePronouns(): Promise { + const url = `${PRONOUN_SERVICE_BASE_URL}pronouns`; + + try { + const response = await this.callUrl(url); + + if (response.ok) { + this._pronounCache = await response.json() as Record; + } + } catch (error) { + logger.error("Unable to cache pronoun definitions", error); + } + } + + async getUserPronouns(username: string): Promise { + const cachedPronouns = this._userPronounCache.get(username); + + if (cachedPronouns) { + return cachedPronouns; + } else if (this._userNoPronounCache[username] === true) { + return; + } + + const url = `${PRONOUN_SERVICE_BASE_URL}users/${username}?t=${new Date().getTime()}`; + + try { + const response = await this.callUrl(url); + + if (response.ok) { + const pronouns = await response.json() as UserPronounResponse; + this._userPronounCache.set(username, { + primary: pronouns.pronoun_id, + secondary: pronouns.alt_pronoun_id + }); + return this._userPronounCache.get(username); + } else if (response.status === 404) { + logger.debug(`No pronouns set for ${username}`); + this._userNoPronounCache.set(username, true); + return; + } + } catch (error) { + logger.warn(`Unable to get pronouns for ${username}`, error); + } + + return; + }; + + async getUserFriendlyPronounString(username: string, type: "subject" | "object" | "both" = "both") { + const pronouns = await this.getUserPronouns(username); + + if (pronouns) { + return this.getFriendlyPronounString(pronouns.primary, pronouns.secondary, type); + } + } + + getFriendlyPronounString(primary: string, secondary: string | undefined, type: "subject" | "object" | "both" = "both"): string | undefined { + const primaryPronoun = this._pronounCache[primary]; + const secondaryPronoun = secondary?.length + ? this._pronounCache[secondary] + : undefined; + + if (primaryPronoun) { + switch (type) { + case "subject": + return primaryPronoun.subject; + + case "object": + return primaryPronoun.object; + + default: + return `${primaryPronoun.subject}/${secondaryPronoun ? secondaryPronoun.subject : primaryPronoun.object}`; + } + } + + return; + } +} + +const pronounManager = new FirebotPronounManager(); + +export { pronounManager as FirebotPronounManager }; \ No newline at end of file diff --git a/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-chat-helpers.ts b/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-chat-helpers.ts index 872a5a20c..0277ec00e 100644 --- a/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-chat-helpers.ts +++ b/src/backend/streaming-platforms/twitch/api/eventsub/eventsub-chat-helpers.ts @@ -35,6 +35,7 @@ import type { import type { FirebotAccount } from "../../../../../types/accounts"; import { AccountAccess } from "../../../../common/account-access"; +import { FirebotPronounManager } from "../../../../pronouns/pronoun-manager"; import { SettingsManager } from "../../../../common/settings-manager"; import { TwitchApi } from "../"; import viewerDatabase from "../../../../viewers/viewer-database"; @@ -528,13 +529,13 @@ class TwitchEventSubChatHelpers { } private cacheUserColor(userId: string, color?: string | null): string { - if (color?.length) { - this._colorCache[userId] = color; - } else if (this._colorCache[userId] == null) { - this._colorCache[userId] = tinycolor.random().toHexString(); - } + if (color?.length) { + this._colorCache[userId] = color; + } else if (this._colorCache[userId] == null) { + this._colorCache[userId] = tinycolor.random().toHexString(); + } - return this._colorCache[userId]; + return this._colorCache[userId]; } /** @@ -572,6 +573,7 @@ class TwitchEventSubChatHelpers { username: event.chatterName, userId: event.chatterId, userDisplayName: event.chatterDisplayName, + pronouns: await FirebotPronounManager.getUserFriendlyPronounString(event.chatterName), rawText: isAction ? this.getChatMessage(event.messageText) : event.messageText, color: this.cacheUserColor(event.chatterId, event.color), badges: this.parseChatBadges(event.badges), @@ -583,7 +585,7 @@ class TwitchEventSubChatHelpers { tagged: false, action: isAction, isAnnouncement, - // eslint-disable-next-line + announcementColor: (announcementColor ? announcementColor.toUpperCase() : undefined) as FirebotChatMessage["announcementColor"], isCheer: false, isReply: false, @@ -692,6 +694,7 @@ class TwitchEventSubChatHelpers { username: message.senderUserName, userId: message.senderUserId, userDisplayName: message.senderUserDisplayName, + pronouns: await FirebotPronounManager.getUserFriendlyPronounString(message.senderUserName), rawText: isAction ? this.getChatMessage(message.messageText) : message.messageText, badges: [], parts: [], @@ -734,6 +737,7 @@ class TwitchEventSubChatHelpers { userDisplayName: event.userDisplayName, rawText: event.messageText, profilePicUrl: profilePicUrl, + pronouns: await FirebotPronounManager.getUserFriendlyPronounString(event.userName), whisper: false, action: false, tagged: false, diff --git a/src/backend/variables/builtin/user/pronouns.ts b/src/backend/variables/builtin/user/pronouns.ts index be61d1178..ba0f007e3 100644 --- a/src/backend/variables/builtin/user/pronouns.ts +++ b/src/backend/variables/builtin/user/pronouns.ts @@ -1,31 +1,11 @@ -import { app } from "electron"; - -import type { ReplaceVariable } from "../../../../types/variables"; - +import type { ReplaceVariable } from "../../../../types"; +import { FirebotPronounManager } from "../../../pronouns/pronoun-manager"; import logger from "../../../logwrapper"; -const callUrl = async (url: string): Promise => { - try { - const appVersion = app.getVersion(); - const response = await fetch(url, { - headers: { - "User-Agent": `Firebot/${appVersion}` - } - }); - - if (response) { - return response; - } - } catch (error) { - logger.warn(`error calling readApi url: ${url}`, (error as Error).message); - return null; - } -}; - const model : ReplaceVariable = { definition: { handle: "pronouns", - description: "Returns the pronouns of the given user. It uses https://pronouns.alejo.io/ to get the pronouns.", + description: "Returns the pronouns of the given user. It uses https://pr.alejo.io/ to get the pronouns.", examples: [ { usage: 'pronouns[username, 0, they/them]', @@ -47,52 +27,41 @@ const model : ReplaceVariable = { trigger, username: string, pronounNumber: number | string = 0, - fallback : string = "they/them" ) => { - if (typeof pronounNumber === 'string' || pronounNumber instanceof String) { pronounNumber = Number(`${pronounNumber}`); } if (!Number.isFinite(pronounNumber)) { - logger.warn(`Pronoun index not a number using ${fallback}`); + logger.warn(`Pronoun index not a number. Using "${fallback}"`); return fallback; } - try { - const pronouns = await (await callUrl('https://pronouns.alejo.io/api/pronouns')).json(); - let userPronounData = (await (await callUrl(`https://pronouns.alejo.io/api/users/${username}`)).json())[0]; - let pronounArray = []; - if (userPronounData == null || userPronounData === undefined) { - userPronounData = { "pronoun_id": `${fallback}`.replace("/", "") }; - } + const fallbackParts = (fallback ?? "").split("/"); + let type: "subject" | "object" | "both"; - let pronoun = pronouns.find(p => p.name === userPronounData.pronoun_id); - if (pronoun != null) { - pronounArray = pronoun.display.split('/'); - } else { - pronoun = { "display": `${fallback}` }; - pronounArray = fallback.split('/'); - } + switch (pronounNumber) { + case 1: + type = "subject"; + fallback = fallbackParts[0]; + break; - if (pronounNumber === 0) { - return pronoun.display; - } + case 2: + type = "object"; + fallback = fallbackParts[1] ?? fallbackParts[0]; + break; - if (pronounArray.length === 1) { - return pronounArray[0]; - } + default: + type = "both"; + break; + } - if (pronounArray.length >= pronounNumber) { - return pronounArray[pronounNumber - 1]; - } + const pronoun = await FirebotPronounManager.getUserFriendlyPronounString(username, type); - } catch (err) { - logger.warn("error when parsing pronoun api", err); - return fallback; - } - return fallback; + return !!pronoun?.length + ? pronoun + : fallback; } }; export default model; \ No newline at end of file diff --git a/src/gui/app/directives/chat/feed items/chat-message.js b/src/gui/app/directives/chat/feed items/chat-message.js index 31f1f9fa3..6b2d92eac 100644 --- a/src/gui/app/directives/chat/feed items/chat-message.js +++ b/src/gui/app/directives/chat/feed items/chat-message.js @@ -148,9 +148,9 @@ class="pronoun" uib-tooltip="Pronouns" tooltip-append-to-body="true" - ng-click="$root.openLinkExternally('https://pronouns.alejo.io/')" - ng-show="$ctrl.showPronoun && $ctrl.pronouns.pronounCache[$ctrl.message.username] != null" - >{{$ctrl.pronouns.pronounCache[$ctrl.message.username]}} + ng-click="$root.openLinkExternally('https://pr.alejo.io/')" + ng-show="$ctrl.showPronoun && $ctrl.message.pronouns != null" + >{{ $ctrl.message.pronouns }} { diff --git a/src/gui/app/index.html b/src/gui/app/index.html index dc94fd997..26015a617 100644 --- a/src/gui/app/index.html +++ b/src/gui/app/index.html @@ -141,7 +141,6 @@ - diff --git a/src/gui/app/services/chat-messages.service.js b/src/gui/app/services/chat-messages.service.js index 1fdfc4f61..e190dd75e 100644 --- a/src/gui/app/services/chat-messages.service.js +++ b/src/gui/app/services/chat-messages.service.js @@ -7,7 +7,7 @@ angular .module('firebotApp') .factory('chatMessagesService', function (logger, settingsService, - soundService, backendCommunicator, pronounsService, accountAccess, ngToast) { + soundService, backendCommunicator, accountAccess, ngToast) { const service = {}; // Chat Message Queue @@ -414,8 +414,6 @@ }, 5 * 60 * 1000); } - pronounsService.getUserPronoun(chatMessage.username); - const now = moment(); chatMessage.timestamp = now; chatMessage.timestampDisplay = now.format('h:mm A'); diff --git a/src/gui/app/services/pronouns.service.js b/src/gui/app/services/pronouns.service.js deleted file mode 100644 index a55a40f2a..000000000 --- a/src/gui/app/services/pronouns.service.js +++ /dev/null @@ -1,78 +0,0 @@ -"use strict"; -(function() { - - angular - .module("firebotApp") - .factory("pronounsService", function(logger, $rootScope, $http) { - const service = {}; - - /** - * @typedef Pronoun - * @property {string} name - * @property {string} display - */ - - /** @type {Pronoun[]} */ - let pronouns = []; - - service.pronounCache = {}; - - service.userHasPronoun = username => service.pronounCache[username] != null; - - service.getUserPronoun = (username) => { - if (username == null) { - return null; - } - - if (service.pronounCache[username]) { - return service.pronounCache[username]; - } - - $http.get(`https://pronouns.alejo.io/api/users/${username}`) - .then((resp) => { - if (resp.status === 200) { - const userPronounData = resp.data[0]; - if (userPronounData == null) { - return; - } - - const pronoun = pronouns.find(p => p.name - === userPronounData.pronoun_id); - if (pronoun != null) { - service.pronounCache[username] = pronoun.display; - } - } else { - logger.error(`Failed to get pronoun for ${username}`); - } - }, (error) => { - logger.error(`Failed to get retrieve pronoun for ${username}`, error.message); - }); - - return null; - }; - - service.retrieveAllPronouns = () => { - service.pronounCache = {}; - - $http.get('https://pronouns.alejo.io/api/pronouns') - .then((resp) => { - if (resp.status === 200) { - pronouns = resp.data; - } else { - logger.error("Failed to get pronouns list", resp); - } - }, (error) => { - logger.error("Failed to get pronouns:", error.message); - }); - }; - - $rootScope.$on("connection:update", (_, { type, status }) => { - if (type === "chat" && status === "connected") { - service.retrieveAllPronouns(); - } - }); - - - return service; - }); -}()); diff --git a/src/types/chat.d.ts b/src/types/chat.d.ts index f469e4662..6f7de603f 100644 --- a/src/types/chat.d.ts +++ b/src/types/chat.d.ts @@ -75,6 +75,7 @@ export type FirebotChatMessage = { userId: string; userDisplayName?: string; profilePicUrl?: string; + pronouns?: string; isExtension?: boolean; roles: string[]; badges: Array<{ diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 7a70a87da..94be5dec9 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -18,6 +18,7 @@ export * from "./moderation"; export * from "./modules"; export * from "./overlay-widgets"; export * from "./parameters"; +export * from "./pronouns"; export * from "./quick-actions"; export * from "./quotes"; export * from "./ranks"; diff --git a/src/types/pronouns.d.ts b/src/types/pronouns.d.ts new file mode 100644 index 000000000..dc91ed257 --- /dev/null +++ b/src/types/pronouns.d.ts @@ -0,0 +1,11 @@ +export type Pronoun = { + name: string; + subject: string; + object: string; + singular: boolean; +}; + +export type UserPronoun = { + primary: string; + secondary?: string; +}; \ No newline at end of file From 5899e976ce28e89bfae9a825d567ed71a6ead797 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Fri, 1 May 2026 22:54:06 -0400 Subject: [PATCH 16/71] feat(widgets): chat widget pronouns --- .../builtin-types/chat/chat.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/backend/overlay-widgets/builtin-types/chat/chat.ts b/src/backend/overlay-widgets/builtin-types/chat/chat.ts index b3c93b3e0..c2ddfc0cc 100644 --- a/src/backend/overlay-widgets/builtin-types/chat/chat.ts +++ b/src/backend/overlay-widgets/builtin-types/chat/chat.ts @@ -11,6 +11,7 @@ export type ChatWidgetSettings = { showTimestamps?: boolean; showAvatars?: boolean; showBadges?: boolean; + showPronouns?: boolean; showSharedChatMessages?: boolean; showSharedChatInfo?: boolean; showAnnouncements?: boolean; @@ -80,6 +81,13 @@ export const chat: OverlayWidgetType = { type: "boolean", default: true }, + { + name: "showPronouns", + title: "Show Pronouns", + description: "Show chatter pronouns (provided by [https://pr.alejo.io/](https://pr.alejo.io/))", + type: "boolean", + default: false + }, { name: "showSharedChatMessages", title: "Show Shared Chat Messages", @@ -391,6 +399,7 @@ export const chat: OverlayWidgetType = { userId: "0", userDisplayName: "zunderscore", profilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/4fe04c1f-8390-4ded-bcb0-cae9b1d7cb9c-profile_image-70x70.png", + pronouns: "He/Him", color: "#0066FF", rawText: "Wow, this IS really neat! zunder2Wow", badges: [ @@ -423,6 +432,7 @@ export const chat: OverlayWidgetType = { userId: "0", userDisplayName: "ebiggz", profilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/5545fe76-a341-4ffb-bc79-7ca8075588a1-profile_image-70x70.png", + pronouns: "He/Him", color: "#00d1ff", rawText: "Yo, what's going on over there?", badges: [ @@ -451,6 +461,7 @@ export const chat: OverlayWidgetType = { userId: "0", userDisplayName: "zunderscore", profilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/4fe04c1f-8390-4ded-bcb0-cae9b1d7cb9c-profile_image-70x70.png", + pronouns: "He/Him", color: "#0066FF", rawText: "Super cool stuff!", badges: [ @@ -657,6 +668,10 @@ export const chat: OverlayWidgetType = { messageHtml += `${badgesHtml.join("")}`; } + if (config.settings.showPronouns === true && !!chatMessage.pronouns?.length) { + messageHtml += `${chatMessage.pronouns}`; + } + const individualUsernameStyles: Record = { "color": chatMessage.color }; @@ -844,6 +859,17 @@ export const chat: OverlayWidgetType = { "height": usernameFontSize }; + const pronounStyles: Record = { + "border": `solid calc(${messageFontSize} * 0.05) ${config.settings?.messageFontOptions?.color || "#FFFFFF"}`, + "border-radius": `calc(${messageFontSize} * 0.25)`, + "font-family": (config.settings?.messageFontOptions?.family ? `"${config.settings?.messageFontOptions?.family}"` : "Inter, sans-serif"), + "font-size": `calc(${messageFontSize} * 0.75)`, + "font-weight": config.settings?.messageFontOptions?.weight?.toString() || "400", + "font-style": config.settings?.messageFontOptions?.italic ? "italic" : "normal", + "padding": `calc(${messageFontSize} * 0.15)`, + "margin-right": "5px" + }; + const usernameStyles: Record = { "font-family": (config.settings?.usernameFontOptions?.family ? `"${config.settings?.messageFontOptions?.family}"` : "Inter, sans-serif"), "font-size": usernameFontSize, @@ -913,6 +939,10 @@ export const chat: OverlayWidgetType = { ${utils.stylesToString(badgeStyles)} } + .chat-pronouns-${config.id} { + ${utils.stylesToString(pronounStyles)} + } + .chat-username-${config.id} { ${utils.stylesToString(usernameStyles)} } From 242a9837155a3657cb9651cc92a0f77c3f993cff Mon Sep 17 00:00:00 2001 From: Alastor <6044734+Alastor-git@users.noreply.github.com> Date: Sat, 2 May 2026 15:35:10 +0200 Subject: [PATCH 17/71] fix: missing utils.stylesToString() in custom-advanced widgets (#3479) --- .../overlay-widgets/builtin-types/custom/custom-advanced.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/overlay-widgets/builtin-types/custom/custom-advanced.ts b/src/backend/overlay-widgets/builtin-types/custom/custom-advanced.ts index 8fc0263e8..4533788a9 100644 --- a/src/backend/overlay-widgets/builtin-types/custom/custom-advanced.ts +++ b/src/backend/overlay-widgets/builtin-types/custom/custom-advanced.ts @@ -115,7 +115,8 @@ if (eventName === "show") { overlayWrapperElement: document.body.querySelector(".wrapper"), utils: { ...utils, - sendMessageToFirebot: utils.sendMessageToFirebot.bind(utils) + sendMessageToFirebot: utils.sendMessageToFirebot.bind(utils), + stylesToString: utils.stylesToString.bind(utils) } }); } From 64616f5da174e370e90d2ca76ebecb3db349e01c Mon Sep 17 00:00:00 2001 From: Dennis Rijsdijk Date: Sat, 2 May 2026 20:17:00 +0200 Subject: [PATCH 18/71] fix: currency restriction message --- src/backend/restrictions/restriction-manager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/restrictions/restriction-manager.ts b/src/backend/restrictions/restriction-manager.ts index 61aaee3af..0b8ff00ec 100644 --- a/src/backend/restrictions/restriction-manager.ts +++ b/src/backend/restrictions/restriction-manager.ts @@ -84,7 +84,8 @@ class RestrictionsManager extends TypedEmitter { await restrictionDef.predicate(triggerData, restriction, restrictionsAreInherited); restrictionPassed = true; } catch (reason) { - failedReason = (reason as string)?.toLowerCase(); + failedReason = (reason instanceof Error ? reason.message : (reason as string))?.toLowerCase() + ?? "You don't meet the requirements."; } if (restriction.invertCondition) { From 8138848986198aea3a72a3098d40b39b441e18c8 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 3 May 2026 14:12:29 -0400 Subject: [PATCH 19/71] feat(commands): show description tooltip in command list (#1946) --- .../app/controllers/commands.controller.js | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/gui/app/controllers/commands.controller.js b/src/gui/app/controllers/commands.controller.js index 05aed4ecc..5f6d3d4ef 100644 --- a/src/gui/app/controllers/commands.controller.js +++ b/src/gui/app/controllers/commands.controller.js @@ -191,8 +191,9 @@ tooltip-append-to-body="true" >{{data.trigger}} - ` + `, + cellController: ($scope) => { + $scope.showTooltip = (command) => { + return !!command.description?.length + || (command.triggerIsRegex && !!command.regexDescription?.length); + }; + + $scope.getTooltip = (command) => { + const tooltipParts = []; + + if (!!command.description?.length) { + tooltipParts.push(command.description); + } + + if (command.triggerIsRegex && !!command.regexDescription?.length) { + tooltipParts.push(`RegEx Description: ${command.regexDescription}`); + } + + return tooltipParts.join("

"); + }; + } }, { name: "COOLDOWNS", From 44c80df52afbe3fe2df92d2e4a8f56ca5dbf8556 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 3 May 2026 19:39:07 -0400 Subject: [PATCH 20/71] chore(widgets): refactor chat widget HTML gen --- .../builtin-types/chat/chat.ts | 237 +++++++++++------- 1 file changed, 152 insertions(+), 85 deletions(-) diff --git a/src/backend/overlay-widgets/builtin-types/chat/chat.ts b/src/backend/overlay-widgets/builtin-types/chat/chat.ts index c2ddfc0cc..c7eb231db 100644 --- a/src/backend/overlay-widgets/builtin-types/chat/chat.ts +++ b/src/backend/overlay-widgets/builtin-types/chat/chat.ts @@ -569,7 +569,7 @@ export const chat: OverlayWidgetType = { const generateChatMessageHtml = ( chatMessage: FirebotChatMessage, config: typeof event["data"]["widgetConfig"] - ): string => { + ): string | undefined => { // Ignore AutoModded messages that aren't approved if (chatMessage.autoModStatus === "pending" || chatMessage.autoModStatus === "denied" @@ -594,24 +594,23 @@ export const chat: OverlayWidgetType = { return; } - const messageContainerStyle: Record = { }; + const rootMessageElem = document.createElement("div"); + rootMessageElem.setAttribute("data-message-id", chatMessage.id); + rootMessageElem.setAttribute("data-username", chatMessage.username); + + let messageHeaderText = ""; if (chatMessage.isAnnouncement === true) { + const messageContainerStyle: Record = { }; messageContainerStyle["display"] = "flex"; messageContainerStyle["flex-direction"] = "row"; - } - let messageHtml = ` -
`; + rootMessageElem.setAttribute("style", utils.stylesToString(messageContainerStyle)); - if (chatMessage.isAnnouncement === true) { - let header = "Announcement"; + messageHeaderText = "Announcement"; if (chatMessage.isSharedChatMessage) { - header += `, sent from ${chatMessage.sharedChatRoomDisplayName}'s chat`; + messageHeaderText += `, sent from ${chatMessage.sharedChatRoomDisplayName}'s chat`; } if (config.settings.horizontalAlignment === "left") { @@ -620,27 +619,36 @@ export const chat: OverlayWidgetType = { config.settings.horizontalAlignment ); - messageHtml += ` -
-
-
${header}
`; - } else { - messageHtml += ` -
-
${header}
`; + const announcementBarElem = document.createElement("div"); + announcementBarElem.classList.add(`chat-announcement-bar-${config.id}`); + announcementBarElem.setAttribute("style", utils.stylesToString(individualAnnouncementBarStyles)); + + rootMessageElem.appendChild(announcementBarElem); } } else { if (chatMessage.isSharedChatMessage && config.settings.showSharedChatInfo) { - messageHtml += ` -
-
Sent from ${chatMessage.sharedChatRoomDisplayName}'s chat
`; + messageHeaderText = `Sent from ${chatMessage.sharedChatRoomDisplayName}'s chat`; } } - messageHtml += `
`; + const messageContainerElem = document.createElement("div"); + messageContainerElem.classList.add(`chat-message-container-${config.id}`); + + if (messageHeaderText.length > 0) { + const messageHeaderElem = document.createElement("div"); + messageHeaderElem.classList.add(`chat-message-header-${config.id}`); + messageHeaderElem.innerText = messageHeaderText; + + messageContainerElem.appendChild(messageHeaderElem); + } + + const messageContentContainerElem = document.createElement("div"); + messageContentContainerElem.classList.add(`chat-message-content-container-${config.id}`); - let timestampString: string; - if (config.settings.showTimestamps === true) { + const messageInfoElem = document.createElement("span"); + + let timestampString: string | undefined = undefined; + if (config.settings.showTimestamps === true && chatMessage.timestamp) { const timestampAsDate = new Date(chatMessage.timestamp); const hours = timestampAsDate.getHours() % 12 === 0 ? "12" @@ -650,119 +658,178 @@ export const chat: OverlayWidgetType = { timestampString = `[${hours}:${minutes}]`; } - if (timestampString?.length && config.settings.messageStyle === "compact") { - messageHtml += `${timestampString} `; + if (!!timestampString?.length && config.settings.messageStyle === "compact") { + const timestampSpan = document.createElement("span"); + timestampSpan.classList.add(`chat-message-${config.id}`); + timestampSpan.innerText = `${timestampString} `; + + messageInfoElem.appendChild(timestampSpan); } if (config.settings.showAvatars === true) { - messageHtml += `avatar`; + const avatarElem = document.createElement("img"); + avatarElem.classList.add(`chat-avatar-${config.id}`); + avatarElem.src = chatMessage.profilePicUrl!; + avatarElem.alt = "avatar"; + + messageInfoElem.appendChild(avatarElem); } if (config.settings.showBadges === true && chatMessage.badges?.length) { - const badgesHtml: Array = []; + const badgeContainerElem = document.createElement("span"); + badgeContainerElem.classList.add(`chat-badge-container-${config.id}`); for (const badge of chatMessage.badges) { - badgesHtml.push(`${badge.title}`); + const badgeElem = document.createElement("img"); + badgeElem.classList.add(`chat-badge-${config.id}`); + badgeElem.src = badge.url; + badgeElem.alt = badge.title; + + badgeContainerElem.appendChild(badgeElem); } - messageHtml += `${badgesHtml.join("")}`; + messageInfoElem.appendChild(badgeContainerElem); } if (config.settings.showPronouns === true && !!chatMessage.pronouns?.length) { - messageHtml += `${chatMessage.pronouns}`; + const pronounElem = document.createElement("span"); + pronounElem.classList.add(`chat-pronouns-${config.id}`); + pronounElem.innerText = chatMessage.pronouns; + + messageInfoElem.appendChild(pronounElem); } const individualUsernameStyles: Record = { - "color": chatMessage.color + "color": `${chatMessage.color}` }; - messageHtml += `${chatMessage.userDisplayName ?? chatMessage.username}`; + const usernameElem = document.createElement("span"); + usernameElem.classList.add(`chat-username-${config.id}`); + usernameElem.setAttribute("style", utils.stylesToString(individualUsernameStyles)); + usernameElem.innerText = chatMessage.userDisplayName ?? chatMessage.username; + + messageInfoElem.appendChild(usernameElem); + + if (!!timestampString?.length && config.settings.messageStyle === "modern") { + const timestampSpan = document.createElement("span"); + timestampSpan.classList.add(`chat-message-${config.id}`); + timestampSpan.innerText = ` ${timestampString}`; + + messageInfoElem.appendChild(timestampSpan); + } + + messageContentContainerElem.appendChild(messageInfoElem); + + const messageTextElem = document.createElement("span"); + messageTextElem.classList.add(`chat-message-${config.id}`); + + let messageText: string; + + if (chatMessage.action === true) { + const individualActionStyles: Record = { }; + + if (config.settings.actionDisplayFormat === "classic") { + individualActionStyles["color"] = chatMessage.color!; + } + + messageTextElem.classList.add(`chat-action-${config.id}`); + messageTextElem.setAttribute("style", utils.stylesToString(individualActionStyles)); + + messageText = config.settings.messageStyle === "modern" ? "" : " "; + } else { + messageText = config.settings.messageStyle === "modern" ? "" : ": "; + } + + if (!!messageText?.length) { + messageTextElem.innerText = messageText; + } - if (timestampString?.length && config.settings.messageStyle === "modern") { - messageHtml += ` ${timestampString}`; + const innerMessageElem = document.createElement("span"); + if (chatMessage.isHighlighted === true && config.settings.highlightStyle === "highlighted") { + innerMessageElem.classList.add(`chat-highlighted-message-${config.id}`); } - const chatMessagePartsHtml: string[] = []; + const chatMessagePartsHtml: Array = []; for (const part of chatMessage.parts) { switch (part.type) { case "emote": - chatMessagePartsHtml.push(`${part.name}`); + { + const emoteElem = document.createElement("img"); + emoteElem.src = (part.animatedUrl ?? part.url)!; + emoteElem.alt = part.name!; + + chatMessagePartsHtml.push(emoteElem.outerHTML); + } break; case "third-party-emote": if (config.settings.thirdPartyEmotes.some(e => e === part.origin)) { - chatMessagePartsHtml.push(`${part.name}`); + const emoteElem = document.createElement("img"); + emoteElem.src = (part.animatedUrl ?? part.url)!; + emoteElem.alt = part.name!; + + chatMessagePartsHtml.push(emoteElem.outerHTML); } else { - chatMessagePartsHtml.push(part.name); + chatMessagePartsHtml.push(part.name!); } break; case "cheermote": - chatMessagePartsHtml.push(`${part.name}${part.amount}`); + { + const cheermoteElem = document.createElement("img"); + cheermoteElem.src = (part.animatedUrl ?? part.url)!; + cheermoteElem.alt = part.name!; + + const cheerAmountElem = document.createElement("strong"); + cheerAmountElem.style.color = part.color!; + cheerAmountElem.innerText = `${part.amount}`; + + chatMessagePartsHtml.push(cheermoteElem.outerHTML); + chatMessagePartsHtml.push(cheerAmountElem.outerHTML); + } break; case "link": - chatMessagePartsHtml.push(part.url); + chatMessagePartsHtml.push(part.url!); break; default: - chatMessagePartsHtml.push(part.text); + chatMessagePartsHtml.push(part.text!); } } - messageHtml += ""; + innerMessageElem.innerHTML = chatMessagePartsHtml.join(""); - if (chatMessage.action === true) { - const individualActionStyles: Record = { }; + messageTextElem.appendChild(innerMessageElem); - if (config.settings.actionDisplayFormat === "classic") { - individualActionStyles["color"] = chatMessage.color; - } + messageContentContainerElem.appendChild(messageTextElem); + messageContainerElem.appendChild(messageContentContainerElem); - messageHtml += `${config.settings.messageStyle === "modern" ? "" : " "}`; - } else { - messageHtml += `${config.settings.messageStyle === "modern" ? "" : ": "}`; - } + rootMessageElem.appendChild(messageContainerElem); - if (chatMessage.isHighlighted === true && config.settings.highlightStyle === "highlighted") { - messageHtml += `${chatMessagePartsHtml.join("")}`; - } else { - messageHtml += `${chatMessagePartsHtml.join("")}`; - } + if (chatMessage.isAnnouncement === true && config.settings.horizontalAlignment === "right") { + const individualAnnouncementBarStyles = generateAnnouncementBarStyle( + chatMessage.announcementColor, + config.settings.horizontalAlignment + ); - messageHtml += ``; + const announcementBarElem = document.createElement("div"); + announcementBarElem.classList.add(`chat-announcement-bar-${config.id}`); + announcementBarElem.setAttribute("style", utils.stylesToString(individualAnnouncementBarStyles)); - if (chatMessage.isAnnouncement === true) { - if (config.settings.horizontalAlignment === "right") { - const individualAnnouncementBarStyles = generateAnnouncementBarStyle( - chatMessage.announcementColor, - config.settings.horizontalAlignment - ); - - messageHtml += `
`; - } else { - messageHtml += "
"; - } - } else { - if (chatMessage.isSharedChatMessage && config.settings.showSharedChatInfo) { - messageHtml += "
"; - } - - messageHtml += ``; + rootMessageElem.appendChild(announcementBarElem); } - messageHtml += ``; - - return messageHtml; + return rootMessageElem.outerHTML; }; const generateWidgetHtml = (config: typeof event["data"]["widgetConfig"]) => { - const usernameFontSize = (config.settings?.usernameFontOptions?.size ? `${config.settings.messageFontOptions.size}px` : "12px"); + const usernameFontSize = (config.settings?.usernameFontOptions?.size ? `${config.settings.usernameFontOptions.size}px` : "12px"); const messageFontSize = (config.settings?.messageFontOptions?.size ? `${config.settings.messageFontOptions.size}px` : "12px"); let height = "auto"; - let maxHeight = "100%"; + let maxHeight: string | undefined = "100%"; let justifyContent = "end"; let anchorToBottom = false; @@ -780,7 +847,7 @@ export const chat: OverlayWidgetType = { case "top": default: height = "auto"; - maxHeight = null; + maxHeight = undefined; justifyContent = "start"; break; } @@ -871,7 +938,7 @@ export const chat: OverlayWidgetType = { }; const usernameStyles: Record = { - "font-family": (config.settings?.usernameFontOptions?.family ? `"${config.settings?.messageFontOptions?.family}"` : "Inter, sans-serif"), + "font-family": (config.settings?.usernameFontOptions?.family ? `"${config.settings?.usernameFontOptions?.family}"` : "Inter, sans-serif"), "font-size": usernameFontSize, "font-weight": config.settings?.usernameFontOptions?.weight?.toString() || "700", "font-style": config.settings?.usernameFontOptions?.italic ? "italic" : "normal" @@ -886,7 +953,7 @@ export const chat: OverlayWidgetType = { }; const highlightedMessageStyles: Record = { - "background-color": config.settings.highlightColor + "background-color": `${config.settings.highlightColor}` }; const actionStyles: Record = { @@ -1011,7 +1078,7 @@ export const chat: OverlayWidgetType = { // Trim excess while (chatContainer.childElementCount > 100) { - chatContainer.removeChild(chatContainer.firstElementChild); + chatContainer.removeChild(chatContainer.firstElementChild!); } } } catch { } From 5557ef56bc9eb154babca0875a588f4b7967be46 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 3 May 2026 19:42:35 -0400 Subject: [PATCH 21/71] fix: linting --- src/backend/overlay-widgets/builtin-types/chat/chat.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend/overlay-widgets/builtin-types/chat/chat.ts b/src/backend/overlay-widgets/builtin-types/chat/chat.ts index c7eb231db..ed1eec9dd 100644 --- a/src/backend/overlay-widgets/builtin-types/chat/chat.ts +++ b/src/backend/overlay-widgets/builtin-types/chat/chat.ts @@ -771,7 +771,7 @@ export const chat: OverlayWidgetType = { chatMessagePartsHtml.push(emoteElem.outerHTML); } else { - chatMessagePartsHtml.push(part.name!); + chatMessagePartsHtml.push(part.name); } break; @@ -791,11 +791,11 @@ export const chat: OverlayWidgetType = { break; case "link": - chatMessagePartsHtml.push(part.url!); + chatMessagePartsHtml.push(part.url); break; default: - chatMessagePartsHtml.push(part.text!); + chatMessagePartsHtml.push(part.text); } } @@ -1078,7 +1078,7 @@ export const chat: OverlayWidgetType = { // Trim excess while (chatContainer.childElementCount > 100) { - chatContainer.removeChild(chatContainer.firstElementChild!); + chatContainer.removeChild(chatContainer.firstElementChild); } } } catch { } From 293fb83f6e0e46ef0a25170fbaec7dff8ae91806 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Mon, 4 May 2026 01:26:51 -0400 Subject: [PATCH 22/71] feat(widgets): chat widget shared chat avatar option --- .../builtin-types/chat/chat.ts | 118 +++++++++++------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/src/backend/overlay-widgets/builtin-types/chat/chat.ts b/src/backend/overlay-widgets/builtin-types/chat/chat.ts index ed1eec9dd..45b9033c9 100644 --- a/src/backend/overlay-widgets/builtin-types/chat/chat.ts +++ b/src/backend/overlay-widgets/builtin-types/chat/chat.ts @@ -13,7 +13,7 @@ export type ChatWidgetSettings = { showBadges?: boolean; showPronouns?: boolean; showSharedChatMessages?: boolean; - showSharedChatInfo?: boolean; + sharedChatInfoStyle?: "none" | "avatar" | "banner"; showAnnouncements?: boolean; delayMessages?: boolean; messageDelay?: number; @@ -96,11 +96,34 @@ export const chat: OverlayWidgetType = { default: false }, { - name: "showSharedChatInfo", - title: "Show Shared Chat Info", - description: "Display info about the channel a chat message was sent in during a shared chat session in the chat feed", - type: "boolean", - default: true, + name: "sharedChatInfoStyle", + title: "Shared Chat Info Style", + description: "Optionally display info about the channel a chat message was sent in during a shared chat session", + type: "radio-cards", + default: "none", + options: [ + { + value: "none", + label: "None", + description: "Don't show any info about the shared chat source channel", + iconClass: "fa-times" + }, + { + value: "avatar", + label: "Avatar", + description: "Show the avatar for the source channel next to the message", + iconClass: "fa-user-circle" + }, + { + value: "banner", + label: "Banner", + description: `Show a banner above the message (e.g. "Sent from Firebot's chat")`, + iconClass: "fa-grip-lines" + } + ], + settings: { + gridColumns: 3 + }, showIf: { showSharedChatMessages: true } @@ -390,6 +413,7 @@ export const chat: OverlayWidgetType = { whisper: false, tagged: false, isSharedChatMessage: false, + sharedChatRoomProfilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/4fe04c1f-8390-4ded-bcb0-cae9b1d7cb9c-profile_image-70x70.png", roles: [] }, { @@ -423,6 +447,7 @@ export const chat: OverlayWidgetType = { whisper: false, tagged: false, isSharedChatMessage: false, + sharedChatRoomProfilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/4fe04c1f-8390-4ded-bcb0-cae9b1d7cb9c-profile_image-70x70.png", roles: [] }, { @@ -452,6 +477,7 @@ export const chat: OverlayWidgetType = { tagged: false, isSharedChatMessage: true, sharedChatRoomDisplayName: "ebiggz", + sharedChatRoomProfilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/5545fe76-a341-4ffb-bc79-7ca8075588a1-profile_image-70x70.png", roles: [] }, { @@ -480,6 +506,7 @@ export const chat: OverlayWidgetType = { whisper: false, tagged: false, isSharedChatMessage: false, + sharedChatRoomProfilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/4fe04c1f-8390-4ded-bcb0-cae9b1d7cb9c-profile_image-70x70.png", isHighlighted: true, roles: [] }, @@ -515,6 +542,7 @@ export const chat: OverlayWidgetType = { whisper: false, tagged: false, isSharedChatMessage: false, + sharedChatRoomProfilePicUrl: "https://static-cdn.jtvnw.net/jtv_user_pictures/4fe04c1f-8390-4ded-bcb0-cae9b1d7cb9c-profile_image-70x70.png", roles: [] } ] @@ -522,8 +550,7 @@ export const chat: OverlayWidgetType = { overlayExtension: { eventHandler: (event: WidgetOverlayEvent, utils: IOverlayWidgetEventUtils) => { const generateAnnouncementBarStyle = ( - announcementColor: FirebotChatMessage["announcementColor"], - horizontalAlignment: typeof event["data"]["widgetConfig"]["settings"]["horizontalAlignment"] + announcementColor: FirebotChatMessage["announcementColor"] ): Record => { let announcementBackgroundColor: string; @@ -553,16 +580,6 @@ export const chat: OverlayWidgetType = { "background": announcementBackgroundColor }; - switch (horizontalAlignment) { - case "right": - individualAnnouncementBarStyles["margin-left"] = "10px"; - break; - - default: - individualAnnouncementBarStyles["margin-right"] = "10px"; - break; - } - return individualAnnouncementBarStyles; }; @@ -595,38 +612,44 @@ export const chat: OverlayWidgetType = { } const rootMessageElem = document.createElement("div"); + rootMessageElem.classList.add(`chat-message-root-container-${config.id}`); rootMessageElem.setAttribute("data-message-id", chatMessage.id); rootMessageElem.setAttribute("data-username", chatMessage.username); let messageHeaderText = ""; - if (chatMessage.isAnnouncement === true) { - const messageContainerStyle: Record = { }; - messageContainerStyle["display"] = "flex"; - messageContainerStyle["flex-direction"] = "row"; + const showSharedChatAvatar = config.settings.sharedChatInfoStyle === "avatar" + && chatMessage.sharedChatRoomProfilePicUrl; + + if (showSharedChatAvatar) { + const sharedChatAvatarContainerElem = document.createElement("div"); - rootMessageElem.setAttribute("style", utils.stylesToString(messageContainerStyle)); + const sharedChatAvatarElem = document.createElement("img"); + sharedChatAvatarElem.classList.add(`chat-avatar-${config.id}`); + sharedChatAvatarElem.src = chatMessage.sharedChatRoomProfilePicUrl!; + sharedChatAvatarContainerElem.appendChild(sharedChatAvatarElem); + rootMessageElem.appendChild(sharedChatAvatarContainerElem); + } + + if (chatMessage.isAnnouncement === true) { messageHeaderText = "Announcement"; - if (chatMessage.isSharedChatMessage) { + if (chatMessage.isSharedChatMessage && config.settings.sharedChatInfoStyle === "banner") { messageHeaderText += `, sent from ${chatMessage.sharedChatRoomDisplayName}'s chat`; } - if (config.settings.horizontalAlignment === "left") { - const individualAnnouncementBarStyles = generateAnnouncementBarStyle( - chatMessage.announcementColor, - config.settings.horizontalAlignment - ); + const individualAnnouncementBarStyles = generateAnnouncementBarStyle( + chatMessage.announcementColor + ); - const announcementBarElem = document.createElement("div"); - announcementBarElem.classList.add(`chat-announcement-bar-${config.id}`); - announcementBarElem.setAttribute("style", utils.stylesToString(individualAnnouncementBarStyles)); + const announcementBarElem = document.createElement("div"); + announcementBarElem.classList.add(`chat-announcement-bar-${config.id}`); + announcementBarElem.setAttribute("style", utils.stylesToString(individualAnnouncementBarStyles)); - rootMessageElem.appendChild(announcementBarElem); - } + rootMessageElem.appendChild(announcementBarElem); } else { - if (chatMessage.isSharedChatMessage && config.settings.showSharedChatInfo) { + if (chatMessage.isSharedChatMessage && config.settings.sharedChatInfoStyle === "banner") { messageHeaderText = `Sent from ${chatMessage.sharedChatRoomDisplayName}'s chat`; } } @@ -808,19 +831,6 @@ export const chat: OverlayWidgetType = { rootMessageElem.appendChild(messageContainerElem); - if (chatMessage.isAnnouncement === true && config.settings.horizontalAlignment === "right") { - const individualAnnouncementBarStyles = generateAnnouncementBarStyle( - chatMessage.announcementColor, - config.settings.horizontalAlignment - ); - - const announcementBarElem = document.createElement("div"); - announcementBarElem.classList.add(`chat-announcement-bar-${config.id}`); - announcementBarElem.setAttribute("style", utils.stylesToString(individualAnnouncementBarStyles)); - - rootMessageElem.appendChild(announcementBarElem); - } - return rootMessageElem.outerHTML; }; @@ -889,6 +899,14 @@ export const chat: OverlayWidgetType = { chatContainerStyles["bottom"] = "0"; } + const messageRootContainerStyles: Record = { + "display": "flex", + "gap": "10px", + "flex-direction": config.settings.horizontalAlignment === "right" + ? "row-reverse" + : "row" + }; + const announcementBarStyles: Record = { "width": "10px" }; @@ -974,6 +992,10 @@ export const chat: OverlayWidgetType = { ${config.settings.chatOrder === "reversed" ? "margin-bottom" : "margin-top"}: ${config.settings.spaceBetweenMessages ?? 5}px; } + .chat-message-root-container-${config.id} { + ${utils.stylesToString(messageRootContainerStyles)} + } + .chat-announcement-bar-${config.id} { ${utils.stylesToString(announcementBarStyles)} } From d029a08d737975731c49cd9b10f003a61c62071f Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Mon, 4 May 2026 16:57:44 -0400 Subject: [PATCH 23/71] fix(widgets): chat widget remove message when no exit animation --- src/backend/overlay-widgets/builtin-types/chat/chat.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/overlay-widgets/builtin-types/chat/chat.ts b/src/backend/overlay-widgets/builtin-types/chat/chat.ts index 45b9033c9..4b041e8f4 100644 --- a/src/backend/overlay-widgets/builtin-types/chat/chat.ts +++ b/src/backend/overlay-widgets/builtin-types/chat/chat.ts @@ -1128,6 +1128,8 @@ export const chat: OverlayWidgetType = { $(`.chat-${event.data.widgetConfig.id}`).find(`[data-message-id="${messageId}"]`).animateCss(animationClass, duration, null, null, () => { chatContainer.removeChild(messageToRemove); }); + } else { + chatContainer.removeChild(messageToRemove); } } else { chatContainer.removeChild(messageToRemove); From 79b697f28922acd0140c060aeb8ecede7c67de37 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Sat, 9 May 2026 02:04:41 -0600 Subject: [PATCH 24/71] chore: add vs code settings to use workspace ts --- .vscode/settings.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 15e1aa5ac..5da0e9749 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,10 +31,12 @@ "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "[typescript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "prettier.trailingComma": "none", "[scss]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } -} \ No newline at end of file + }, + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.tsdk.promptToUseWorkspaceVersion": true +} From 9a6ad584facbbbfdc9f08f4037cbac9cca16e5fc Mon Sep 17 00:00:00 2001 From: Dennis Rijsdijk Date: Sat, 9 May 2026 15:03:51 +0200 Subject: [PATCH 25/71] feat: module import for overlayExtension js dependencies (#3482) --- src/resources/overlay/index.ejs | 6 +++++- src/server/http-server-manager.ts | 14 ++++++++++++-- src/types/effects.d.ts | 2 +- src/types/overlay-widgets.d.ts | 2 +- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/resources/overlay/index.ejs b/src/resources/overlay/index.ejs index 4447561de..8230901ed 100644 --- a/src/resources/overlay/index.ejs +++ b/src/resources/overlay/index.ejs @@ -45,7 +45,11 @@ <% for(let jsDep of dependencies.js) { %> - + <% if (jsDep.module) { %> + + <% } else { %> + + <% } %> <% } %> - + diff --git a/src/gui/app/lang/locale-en.json b/src/gui/app/lang/locale-en.json index e617f943c..d9050b522 100644 --- a/src/gui/app/lang/locale-en.json +++ b/src/gui/app/lang/locale-en.json @@ -1,82 +1,82 @@ { - "MAIN": { - "MANAGE_LOGINS": "Manage Logins", - "LOADING": "Loading..." - }, - "SIDEBAR": { - "CHAT": { - "CHAT": "Chat", - "COMMANDS": "Commands", - "CHAT_FEED": "Dashboard" + "MAIN": { + "MANAGE_LOGINS": "Manage Logins", + "LOADING": "Loading..." }, - "OTHER": { - "OTHER": "Other", - "EVENTS": "Events", - "PRESET_EFFECT_LISTS": "Preset Effect Lists", - "EFFECT_QUEUES": "Effect Queues", - "TIME_BASED": "Time-Based", - "HOTKEYS": "Hotkeys", - "CHANNELREWARDS": "Channel Rewards", - "COUNTERS": "Counters" + "SIDEBAR": { + "CHAT": { + "CHAT": "Chat", + "COMMANDS": "Commands", + "CHAT_FEED": "Dashboard" + }, + "OTHER": { + "OTHER": "Other", + "EVENTS": "Events", + "PRESET_EFFECT_LISTS": "Preset Effect Lists", + "EFFECT_QUEUES": "Effect Queues", + "TIME_BASED": "Time-Based", + "HOTKEYS": "Hotkeys", + "POWERUPSANDREWARDS": "Power-ups & Rewards", + "COUNTERS": "Counters" + }, + "MANAGEMENT": { + "MANAGEMENT": "Management", + "VIEWER_GROUPS": "Viewer Groups", + "VIEWER_ROLES": "Roles & Ranks", + "MODERATION": "Moderation", + "VIEWERS": "Viewers", + "SETTINGS": "Settings", + "UPDATES": "Updates", + "CURRENCY": "Currency", + "QUOTES": "Quotes" + }, + "CONNECTIONS": { + "CONNECTIONS": "Connections", + "TOGGLE": "Click to toggle connections", + "OPEN_CONNECTION_PANNEL": "Open Connection Panel" + }, + "ABOUT": "About" }, - "MANAGEMENT": { - "MANAGEMENT": "Management", - "VIEWER_GROUPS": "Viewer Groups", - "VIEWER_ROLES": "Roles & Ranks", - "MODERATION": "Moderation", - "VIEWERS": "Viewers", - "SETTINGS": "Settings", - "UPDATES": "Updates", - "CURRENCY": "Currency", - "QUOTES": "Quotes" + "BUTTONS": { + "BUTTONS": "Buttons", + "CHANGE_BOARD": { + "CHANGE_BOARD": "Change Board", + "ADD_NEW_BOARD": "Add New Board", + "DELETE_BOARD": "Delete Board", + "RESYNC_BOARD": "Resync Board" + }, + "MIXER_STUDIO": "Mixer Studio", + "SCENES": "Scenes", + "EDIT": "Edit", + "SEARCH_CONTROLS": "Search controls", + "LOADING_BOARDS": "Loading boards...", + "ADD_FIRST_BOARD": "Add First Board", + "COULD_NOT_AUTOSELECT": "We couldn't auto select a board but don't worry, you have boards saved! Click the dropdown above and select a board.", + "ID": "ID", + "NAME": "Name", + "COST": "Cost" }, - "CONNECTIONS": { - "CONNECTIONS": "Connections", - "TOGGLE": "Click to toggle connections", - "OPEN_CONNECTION_PANNEL": "Open Connection Panel" + "LIVE_EVENTS": { + "EVENTS": "Events", + "EVENT_GROUPS": "Event Groups", + "CHANGE_GROUP": "Change Group", + "CHANGE_EVENT_GROUP": { + "CHANGE_BOARD": "Change Event Group", + "ADD_NEW_EVENT_GROUP": "Add New Event Group", + "EDIT_EVENT_GROUP": "Edit Event Group", + "DELETE_EVENT_GROUP": "Delete Event Group" + }, + "EDIT": "Edit", + "SEARCH_EVENTS": "Search events", + "ADD_NEW_EVENT": "Add New Event", + "ADD_FIRST_EVENT_GROUP": "Add First Event Group", + "COULD_NOT_AUTOSELECT": "We could not auto select a live event group. Click the dropdown and select one!", + "ID": "ID", + "NAME": "Name", + "TYPE": "Type" }, - "ABOUT": "About" - }, - "BUTTONS": { - "BUTTONS": "Buttons", - "CHANGE_BOARD": { - "CHANGE_BOARD": "Change Board", - "ADD_NEW_BOARD": "Add New Board", - "DELETE_BOARD": "Delete Board", - "RESYNC_BOARD": "Resync Board" - }, - "MIXER_STUDIO": "Mixer Studio", - "SCENES": "Scenes", - "EDIT": "Edit", - "SEARCH_CONTROLS": "Search controls", - "LOADING_BOARDS": "Loading boards...", - "ADD_FIRST_BOARD": "Add First Board", - "COULD_NOT_AUTOSELECT": "We couldn't auto select a board but don't worry, you have boards saved! Click the dropdown above and select a board.", - "ID": "ID", - "NAME": "Name", - "COST": "Cost" - }, - "LIVE_EVENTS": { - "EVENTS": "Events", - "EVENT_GROUPS": "Event Groups", - "CHANGE_GROUP": "Change Group", - "CHANGE_EVENT_GROUP": { - "CHANGE_BOARD": "Change Event Group", - "ADD_NEW_EVENT_GROUP": "Add New Event Group", - "EDIT_EVENT_GROUP": "Edit Event Group", - "DELETE_EVENT_GROUP": "Delete Event Group" - }, - "EDIT": "Edit", - "SEARCH_EVENTS": "Search events", - "ADD_NEW_EVENT": "Add New Event", - "ADD_FIRST_EVENT_GROUP": "Add First Event Group", - "COULD_NOT_AUTOSELECT": "We could not auto select a live event group. Click the dropdown and select one!", - "ID": "ID", - "NAME": "Name", - "TYPE": "Type" - }, - "NEW": "New", - "CONNECTED": "Connected", - "DISCONNECTED": "Disconnected", - "RUNNING_NOT_CONNECTED": "Ready, but nothing is connected at this time." + "NEW": "New", + "CONNECTED": "Connected", + "DISCONNECTED": "Disconnected", + "RUNNING_NOT_CONNECTED": "Ready, but nothing is connected at this time." } diff --git a/src/gui/app/services/chat-messages.service.js b/src/gui/app/services/chat-messages.service.js index e190dd75e..c15be20cf 100644 --- a/src/gui/app/services/chat-messages.service.js +++ b/src/gui/app/services/chat-messages.service.js @@ -323,7 +323,29 @@ service.chatQueue.push({ id: randomUUID(), - type: "redemption", + type: "reward-redemption", + data: redemption + }); + }); + + backendCommunicator.on("twitch:chat:powerupredemption", (redemption) => { + if (service.chatQueue && service.chatQueue.length > 0) { + const lastQueueItem = service.chatQueue[service.chatQueue.length - 1]; + if (!lastQueueItem.powerUpMatched && + lastQueueItem.type === "message" && + // not sure if customRewardId is the right field to be checking against here until we have access to the feature + lastQueueItem.data.customRewardId != null && + lastQueueItem.data.customRewardId === redemption.powerUp.id && + lastQueueItem.data.userId === redemption.user.id) { + lastQueueItem.powerUpMatched = true; + lastQueueItem.data.powerUp = redemption.powerUp; + return; + } + } + + service.chatQueue.push({ + id: randomUUID(), + type: "power-up-redemption", data: redemption }); }); diff --git a/src/gui/app/services/power-ups.service.js b/src/gui/app/services/power-ups.service.js new file mode 100644 index 000000000..e718463be --- /dev/null +++ b/src/gui/app/services/power-ups.service.js @@ -0,0 +1,88 @@ +"use strict"; + +(function () { + + angular + .module("firebotApp") + .factory("powerUpsService", function ($q, + backendCommunicator, utilityService) { + const service = {}; + + service.powerUps = []; + + service.selectedSortTag = null; + + service.searchQuery = ""; + + function updatePowerUp(powerUp) { + const index = service.powerUps.findIndex(p => p.id === powerUp.id); + if (index > -1) { + service.powerUps[index] = powerUp; + } else { + service.powerUps.push(powerUp); + } + } + + service.loadPowerUps = () => { + service.powerUps = backendCommunicator.fireEventSync("power-ups:get-all"); + }; + + service.savePowerUp = (powerUp) => { + return $q.when(backendCommunicator.fireEventAsync("power-ups:save", powerUp)) + .then((savedPowerUp) => { + if (savedPowerUp) { + updatePowerUp(savedPowerUp); + return true; + } + return false; + }); + }; + + service.saveAllPowerUps = (powerUps) => { + service.powerUps = powerUps; + backendCommunicator.fireEvent("power-ups:save-all", powerUps); + }; + + service.showEditPowerUpModal = (powerUp) => { + utilityService.showModal({ + component: "editPowerUp", + windowClass: "no-padding-modal", + resolveObj: { + powerUp: () => powerUp + }, + closeCallback: () => { } + }); + }; + + service.manuallyTriggerPowerUp = (itemId) => { + backendCommunicator.fireEvent("power-ups:manually-trigger", itemId); + }; + + let currentlySyncing = false; + service.syncPowerUps = () => { + if (currentlySyncing) { + return; + } + + currentlySyncing = true; + + $q.when(backendCommunicator.fireEventAsync("power-ups:sync")) + .then((powerUps) => { + if (powerUps) { + service.powerUps = powerUps; + } + currentlySyncing = false; + }); + }; + + backendCommunicator.on("power-ups:updated-all", (powerUps) => { + service.powerUps = powerUps; + }); + + backendCommunicator.on("power-ups:updated", (powerUp) => { + updatePowerUp(powerUp); + }); + + return service; + }); +}()); diff --git a/src/gui/app/services/sidebar-manager.service.js b/src/gui/app/services/sidebar-manager.service.js index 3c34d07ba..22cdc044b 100644 --- a/src/gui/app/services/sidebar-manager.service.js +++ b/src/gui/app/services/sidebar-manager.service.js @@ -51,7 +51,7 @@ "effect queues", "events", "timers", - "channel rewards", + "power-ups and rewards", "roles and ranks", "moderation", "buttons", @@ -78,7 +78,7 @@ "commands", "events", "timers", - "channel rewards", + "power-ups and rewards", "roles and ranks", "preset effect lists", "variable macros", @@ -119,10 +119,10 @@ }); break; - case "channel-rewards": - $translate("SIDEBAR.OTHER.CHANNELREWARDS").then((tabName) => { - service.setTab("channel rewards", tabName); - $location.path("/channel-rewards"); + case "power-ups-and-rewards": + $translate("SIDEBAR.OTHER.POWERUPSANDREWARDS").then((tabName) => { + service.setTab("power-ups & rewards", tabName); + $location.path("/power-ups-and-rewards"); }); break; @@ -192,9 +192,9 @@ controller: "effectQueuesController" }) - .when("/channel-rewards", { - templateUrl: "./templates/_channel-rewards.html", - controller: "channelRewardsController" + .when("/power-ups-and-rewards", { + templateUrl: "./templates/_power-ups-and-rewards.html", + controller: "powerUpsAndRewardsController" }) .when("/moderation", { diff --git a/src/gui/app/templates/_channel-rewards.html b/src/gui/app/templates/_power-ups-and-rewards.html similarity index 54% rename from src/gui/app/templates/_channel-rewards.html rename to src/gui/app/templates/_power-ups-and-rewards.html index 8c6fb0c3d..076d1e973 100644 --- a/src/gui/app/templates/_channel-rewards.html +++ b/src/gui/app/templates/_power-ups-and-rewards.html @@ -1,10 +1,59 @@ + + + + + + +
+
+ Power-up Limit + + + + {{powerUpsService.powerUps.length}} / 50 + +
+
+
+
+
+
+
+
+
+
-
In order to use Channel Rewards, please login with an eligible account.
+
In order to use Power-ups & Channel Rewards, please login with an eligible account.
\ No newline at end of file diff --git a/src/gui/app/templates/chat/_chat-messages.html b/src/gui/app/templates/chat/_chat-messages.html index 427e1bebe..1017d7f69 100644 --- a/src/gui/app/templates/chat/_chat-messages.html +++ b/src/gui/app/templates/chat/_chat-messages.html @@ -80,7 +80,11 @@ font-family-style="{{customFontFamilyStyle}}" /> + .chatEmoticon { +.gigantify :last-child > .chatEmoticon { display: block !important; height: 8rem !important; } @@ -482,7 +484,7 @@ align-items: center; justify-content: space-between; padding: 0 10px; - flex-shrink: 0 + flex-shrink: 0; } .thread-close-btn { background: none; @@ -722,7 +724,7 @@ border-radius: 7px; margin: 0 5px 3px; &.acknowledged { - color: rgba($content-text-color, .15); + color: rgba($content-text-color, 0.15); } } } @@ -991,4 +993,4 @@ .dashboard-settings-button-container i { margin: 0 !important; -} \ No newline at end of file +} diff --git a/src/gui/scss/core/_global.scss b/src/gui/scss/core/_global.scss index d6d0c14db..fe5c42612 100644 --- a/src/gui/scss/core/_global.scss +++ b/src/gui/scss/core/_global.scss @@ -248,7 +248,7 @@ body { .fb-nav { position: relative; height: 100%; - width: 223px; + width: 240px; display: flex; flex-shrink: 0; flex-grow: 0; @@ -428,7 +428,7 @@ body { font-family: "LEMONMILK-Bold", "Inter", sans-serif; -webkit-background-clip: text; background-clip: text; - background-image: linear-gradient(to right, #ebb11f, #FFCA05); + background-image: linear-gradient(to right, #ebb11f, #ffca05); } .nav-header-title.contracted { diff --git a/src/shared/effect-constants.js b/src/shared/effect-constants.js index 02b0a3053..88a581b5e 100644 --- a/src/shared/effect-constants.js +++ b/src/shared/effect-constants.js @@ -35,6 +35,7 @@ const EffectTrigger = Object.freeze({ COUNTER: "counter", PRESET_LIST: "preset", CHANNEL_REWARD: "channel_reward", + POWER_UP: "power_up", MANUAL: "manual", QUICK_ACTION: "quick_action", OVERLAY_WIDGET: "overlay_widget", diff --git a/src/types/power-ups.d.ts b/src/types/power-ups.d.ts new file mode 100644 index 000000000..530f4baec --- /dev/null +++ b/src/types/power-ups.d.ts @@ -0,0 +1,22 @@ +import type { CustomPowerUp } from "../backend/streaming-platforms/twitch/api/resource/power-ups"; +import type { EffectList } from "./effects"; +import type { RestrictionData } from "./restrictions"; + +export type SavedPowerUp = { + id: string; + twitchData: CustomPowerUp; + effects?: EffectList; + restrictionData?: RestrictionData; + sortTags?: string[]; +}; + +export type PowerUpRedemptionMetadata = { + username: string; + userId: string; + userDisplayName: string; + messageText: string; + powerUpId: string; + powerUpImage: string; + powerUpName: string; + bits: number; +}; diff --git a/src/types/triggers.d.ts b/src/types/triggers.d.ts index 33944ae17..82499fb7f 100644 --- a/src/types/triggers.d.ts +++ b/src/types/triggers.d.ts @@ -15,6 +15,7 @@ export type TriggerType = | "quick_action" | "manual" | "channel_reward" + | "power_up" | "overlay_widget"; export type TriggerMeta = { @@ -57,4 +58,4 @@ export type Trigger = { export type TriggersObject = { [T in TriggerType]?: T extends "event" ? string[] | boolean : boolean; -}; \ No newline at end of file +}; From b07ab823e1da6ad3f236a24883e356c4900f1bdd Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Tue, 12 May 2026 12:48:59 -0600 Subject: [PATCH 37/71] fix(power-ups): manual redemption add subscription broken after Twurple update --- .../power-up-redemption-add-subscription.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/backend/streaming-platforms/twitch/api/eventsub/custom-subscriptions/power-up-redemption-add-subscription.ts b/src/backend/streaming-platforms/twitch/api/eventsub/custom-subscriptions/power-up-redemption-add-subscription.ts index 6f99f0246..3e3260204 100644 --- a/src/backend/streaming-platforms/twitch/api/eventsub/custom-subscriptions/power-up-redemption-add-subscription.ts +++ b/src/backend/streaming-platforms/twitch/api/eventsub/custom-subscriptions/power-up-redemption-add-subscription.ts @@ -47,21 +47,23 @@ export class EventSubPowerUpRedemptionAddSubscription extends EventSubSubscripti } protected async _subscribe(): Promise { - return await this._client._apiClient.asUser( - this._broadcasterId, - async ctx => - await ctx.eventSub.createSubscription( - "channel.custom_power_up_redemption.add", - "beta", - { + return this._client._config.managed + ? await this._client._config.apiClient.asUser( + this._broadcasterId, + async ctx => + await ctx.eventSub.createSubscription( + "channel.custom_power_up_redemption.add", + "beta", + { // eslint-disable-next-line camelcase - broadcaster_user_id: this._broadcasterId - }, - await this._getTransportOptions(), - this._broadcasterId, - ["bits:read"], - true - ) - ); + broadcaster_user_id: this._broadcasterId + }, + await this._getTransportOptions(), + this._broadcasterId, + ["bits:read"], + true + ) + ) + : undefined; } } From 1ac26a4cfbdd6573586aaf0ec4618defe31c80c4 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Tue, 12 May 2026 13:02:37 -0600 Subject: [PATCH 38/71] fix(power-ups): ensure username variable can be used in power ups --- src/backend/variables/builtin/metadata/user.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/variables/builtin/metadata/user.ts b/src/backend/variables/builtin/metadata/user.ts index 90d2c281d..8c682bcad 100644 --- a/src/backend/variables/builtin/metadata/user.ts +++ b/src/backend/variables/builtin/metadata/user.ts @@ -7,6 +7,7 @@ triggers["manual"] = true; triggers["custom_script"] = true; triggers["preset"] = true; triggers["channel_reward"] = true; +triggers["power_up"] = true; const model : ReplaceVariable = { definition: { From 8a9578c7b6efff15661901f0f4edfd1942dbdf51 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Tue, 12 May 2026 13:13:17 -0600 Subject: [PATCH 39/71] fix: add modal breadcrumb names for edit channel reward and power up modals --- src/gui/app/services/channel-rewards.service.js | 1 + src/gui/app/services/power-ups.service.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/gui/app/services/channel-rewards.service.js b/src/gui/app/services/channel-rewards.service.js index c3ef57df7..ca3152aa2 100644 --- a/src/gui/app/services/channel-rewards.service.js +++ b/src/gui/app/services/channel-rewards.service.js @@ -56,6 +56,7 @@ service.showAddOrEditRewardModal = (reward) => { utilityService.showModal({ + breadcrumbName: reward ? "Edit Channel Reward" : "Add Channel Reward", component: "addOrEditChannelReward", windowClass: "no-padding-modal", resolveObj: { diff --git a/src/gui/app/services/power-ups.service.js b/src/gui/app/services/power-ups.service.js index e718463be..f8bd767fe 100644 --- a/src/gui/app/services/power-ups.service.js +++ b/src/gui/app/services/power-ups.service.js @@ -46,6 +46,7 @@ service.showEditPowerUpModal = (powerUp) => { utilityService.showModal({ component: "editPowerUp", + breadcrumbName: "Edit Power-Up", windowClass: "no-padding-modal", resolveObj: { powerUp: () => powerUp From 89b485b72d5e53fa4af086369d488bb1399e6958 Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Tue, 12 May 2026 13:13:33 -0600 Subject: [PATCH 40/71] chore: color tweak for power up prompt text --- src/gui/app/directives/modals/power-ups/edit-power-up.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/app/directives/modals/power-ups/edit-power-up.js b/src/gui/app/directives/modals/power-ups/edit-power-up.js index 235dbcc53..23b5c6984 100644 --- a/src/gui/app/directives/modals/power-ups/edit-power-up.js +++ b/src/gui/app/directives/modals/power-ups/edit-power-up.js @@ -25,7 +25,7 @@ {{$ctrl.powerUp.twitchData.bits}} Bits -

+

{{$ctrl.powerUp.twitchData.prompt}}

From 8f5f180f127ae8a9df2990aa771523455a962201 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 12 May 2026 22:20:44 -0400 Subject: [PATCH 41/71] fix(vars): rename powerUpBits to powerUpCost --- src/backend/streaming-platforms/twitch/events/bits.ts | 8 ++++---- src/backend/streaming-platforms/twitch/events/index.ts | 2 +- .../twitch/variables/power-ups/power-up-cost.ts | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/backend/streaming-platforms/twitch/events/bits.ts b/src/backend/streaming-platforms/twitch/events/bits.ts index 8e9e75497..a357c0efe 100644 --- a/src/backend/streaming-platforms/twitch/events/bits.ts +++ b/src/backend/streaming-platforms/twitch/events/bits.ts @@ -106,7 +106,7 @@ export function handleCustomPowerUpRedemption( powerUpId: string, powerUpTitle: string, powerUpPrompt: string, - powerUpBits: number, + powerUpCost: number, powerUpImageUrl: string ): void { frontendCommunicator.send("twitch:chat:twitch:chat:powerupredemption", { @@ -121,7 +121,7 @@ export function handleCustomPowerUpRedemption( powerUp: { id: powerUpId, name: powerUpTitle, - bits: powerUpBits, + bits: powerUpCost, imageUrl: powerUpImageUrl ?? "https://static-cdn.jtvnw.net/twilight-static-assets/Default-Power-up-Line-Lightshade-112x112.png" @@ -140,7 +140,7 @@ export function handleCustomPowerUpRedemption( powerUpImage: powerUpImageUrl, powerUpName: powerUpTitle, powerUpDescription: powerUpPrompt, - powerUpBits + powerUpCost }; void void EventManager.triggerEvent("twitch", "power-up-redemption", redemptionMeta); @@ -153,7 +153,7 @@ export function handleCustomPowerUpRedemption( powerUpId, powerUpImage: powerUpImageUrl, powerUpName: powerUpTitle, - bits: powerUpBits + bits: powerUpCost }); }, 100); } diff --git a/src/backend/streaming-platforms/twitch/events/index.ts b/src/backend/streaming-platforms/twitch/events/index.ts index 22a2fad30..e30270029 100644 --- a/src/backend/streaming-platforms/twitch/events/index.ts +++ b/src/backend/streaming-platforms/twitch/events/index.ts @@ -670,7 +670,7 @@ export const TwitchEventSource: EventSource = { powerUpName: "Test Power-up", powerUpDescription: "Example Power-up Description", powerUpImage: "https://static-cdn.jtvnw.net/custom-reward-images/default-4.png", - powerUpBits: 100, + powerUpCost: 100, messageText: "Test message" }, activityFeed: { diff --git a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-cost.ts b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-cost.ts index 353e77fb4..543641696 100644 --- a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-cost.ts +++ b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-cost.ts @@ -4,11 +4,11 @@ import { TwitchApi } from "../../api"; const model: ReplaceVariable = { definition: { - handle: "powerUpBits", + handle: "powerUpCost", description: "The bit cost of the power-up", examples: [ { - usage: "powerUpBits[powerUpName]", + usage: "powerUpCost[powerUpName]", description: "The bit cost of the given power-up. Name must be exact!" } ], @@ -19,8 +19,8 @@ const model: ReplaceVariable = { if (!powerUpName) { const data = trigger.metadata.eventData ? trigger.metadata.eventData : trigger.metadata; const bits = - (data as { powerUpBits?: number, bits?: number }).powerUpBits ?? - (data as { powerUpBits?: number, bits?: number }).bits; + (data as { powerUpCost?: number, bits?: number }).powerUpCost ?? + (data as { powerUpCost?: number, bits?: number }).bits; return bits ?? -1; } From bc881c904783556cfbcbd439edfd6f64f75ad5ec Mon Sep 17 00:00:00 2001 From: Erik Bigler Date: Wed, 13 May 2026 11:56:43 -0600 Subject: [PATCH 42/71] fix: remove pause/unpaused header for power-ups since there's no way to change it at this time --- src/gui/app/controllers/power-ups-and-rewards.controller.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/gui/app/controllers/power-ups-and-rewards.controller.js b/src/gui/app/controllers/power-ups-and-rewards.controller.js index d3f10e99c..832bbcc3b 100644 --- a/src/gui/app/controllers/power-ups-and-rewards.controller.js +++ b/src/gui/app/controllers/power-ups-and-rewards.controller.js @@ -104,10 +104,6 @@ sortable: true, cellTemplate: `{{data.twitchData.bits}}`, cellController: () => { } - }, - { - cellTemplate: `{{data.twitchData.isPaused ? 'Paused' : 'Unpaused' }}`, - cellController: () => { } } ]; From f31eec14ebb8f41916f2a65ddf73e81697622c73 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 13 May 2026 16:11:39 -0400 Subject: [PATCH 43/71] chore(power-ups): update Power-Up UI casing to match Twitch --- .../events/filters/builtin/twitch/power-up.ts | 4 ++-- src/backend/power-ups/power-ups-manager.ts | 4 ++-- .../twitch/events/index.ts | 24 +++++++++---------- .../variables/power-ups/power-up-cost.ts | 4 ++-- .../power-ups/power-up-description.ts | 8 +++---- .../twitch/variables/power-ups/power-up-id.ts | 2 +- .../variables/power-ups/power-up-image-url.ts | 6 ++--- .../variables/power-ups/power-up-message.ts | 2 +- .../variables/power-ups/power-up-name.ts | 2 +- .../power-ups/power-up-redemption-id.ts | 2 +- .../modals/power-ups/edit-power-up.js | 6 ++--- src/gui/app/directives/sidebar/sidebar.js | 2 +- src/gui/app/lang/locale-en.json | 2 +- .../app/templates/_power-ups-and-rewards.html | 16 ++++++------- 14 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/backend/events/filters/builtin/twitch/power-up.ts b/src/backend/events/filters/builtin/twitch/power-up.ts index 21345d6d0..7c5c0e285 100644 --- a/src/backend/events/filters/builtin/twitch/power-up.ts +++ b/src/backend/events/filters/builtin/twitch/power-up.ts @@ -2,8 +2,8 @@ import { createPresetFilter } from "../../filter-factory"; const filter = createPresetFilter({ id: "firebot:power-up", - name: "Power-up", - description: "Filter to a Custom Power-up", + name: "Power-Up", + description: "Filter to a Custom Power-Up", events: [{ eventSourceId: "twitch", eventId: "power-up-redemption" }], eventMetaKey: "powerUpId", allowIsNot: true, diff --git a/src/backend/power-ups/power-ups-manager.ts b/src/backend/power-ups/power-ups-manager.ts index 2bbfbf0f7..a1773de6d 100644 --- a/src/backend/power-ups/power-ups-manager.ts +++ b/src/backend/power-ups/power-ups-manager.ts @@ -44,7 +44,7 @@ class PowerUpsManager { void this.triggerPowerUp( powerUpId, { - messageText: "Testing power-up", + messageText: "Testing Power-Up", powerUpId: savedPowerUp.id, bits: savedPowerUp.twitchData.bits, powerUpImage: savedPowerUp.twitchData.image @@ -249,7 +249,7 @@ class PowerUpsManager { if (restrictionData.sendFailMessage || restrictionData.sendFailMessage == null) { const restrictionMessage = restrictionData.useCustomFailMessage ? restrictionData.failMessage - : "Sorry @{user}, you cannot use this power-up because: {reason}"; + : "Sorry @{user}, you cannot use this Power-Up because: {reason}"; await TwitchApi.chat.sendChatMessage( restrictionMessage.replaceAll("{user}", metadata.username).replaceAll("{reason}", reason), diff --git a/src/backend/streaming-platforms/twitch/events/index.ts b/src/backend/streaming-platforms/twitch/events/index.ts index e30270029..4e0eaaede 100644 --- a/src/backend/streaming-platforms/twitch/events/index.ts +++ b/src/backend/streaming-platforms/twitch/events/index.ts @@ -426,8 +426,8 @@ export const TwitchEventSource: EventSource = { }, { id: "bits-powerup-message-effect", - name: "Power-up: Message Effects", - description: 'When a viewer uses the "Message Effects" Power-up in your channel.', + name: "Power-Up: Message Effects", + description: 'When a viewer uses the "Message Effects" Power-Up in your channel.', cached: false, manualMetadata: { username: "firebot", @@ -443,14 +443,14 @@ export const TwitchEventSource: EventSource = { const showUserIdName = eventData.username.toLowerCase() !== eventData.userDisplayName.toLowerCase(); return `**${eventData.userDisplayName}${ showUserIdName ? ` (${eventData.username})` : "" - }** used a Message Effects Power-up for **${eventData.bits}** bits`; + }** used a Message Effects Power-Up for **${eventData.bits}** bits`; } } }, { id: "bits-powerup-celebration", - name: "Power-up: On-Screen Celebration", - description: 'When a viewer uses the "On-Screen Celebration" Power-up in your channel.', + name: "Power-Up: On-Screen Celebration", + description: 'When a viewer uses the "On-Screen Celebration" Power-Up in your channel.', cached: false, manualMetadata: { username: "firebot", @@ -465,14 +465,14 @@ export const TwitchEventSource: EventSource = { const showUserIdName = eventData.username.toLowerCase() !== eventData.userDisplayName.toLowerCase(); return `**${eventData.userDisplayName}${ showUserIdName ? ` (${eventData.username})` : "" - }** used a Celebration Power-up for **${eventData.bits}** bits`; + }** used a Celebration Power-Up for **${eventData.bits}** bits`; } } }, { id: "bits-powerup-gigantified-emote", - name: "Power-up: Gigantify an Emote", - description: 'When a viewer uses the "Gigantify an Emote" Power-up in your channel.', + name: "Power-Up: Gigantify an Emote", + description: 'When a viewer uses the "Gigantify an Emote" Power-Up in your channel.', cached: false, manualMetadata: { username: "firebot", @@ -658,8 +658,8 @@ export const TwitchEventSource: EventSource = { }, { id: "power-up-redemption", - name: "Power-up Redemption", - description: "When someone redeems a custom power-up", + name: "Custom Power-Up Redemption", + description: "When someone redeems a Custom Power-Up", cached: true, cacheMetaKey: "username", cacheTtlInSecs: 1, @@ -667,8 +667,8 @@ export const TwitchEventSource: EventSource = { username: "firebot", userDisplayName: "Firebot", userId: "", - powerUpName: "Test Power-up", - powerUpDescription: "Example Power-up Description", + powerUpName: "Test Power-Up", + powerUpDescription: "Example Power-Up Description", powerUpImage: "https://static-cdn.jtvnw.net/custom-reward-images/default-4.png", powerUpCost: 100, messageText: "Test message" diff --git a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-cost.ts b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-cost.ts index 543641696..4c4b772f1 100644 --- a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-cost.ts +++ b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-cost.ts @@ -5,11 +5,11 @@ import { TwitchApi } from "../../api"; const model: ReplaceVariable = { definition: { handle: "powerUpCost", - description: "The bit cost of the power-up", + description: "The bit cost of the Power-Up", examples: [ { usage: "powerUpCost[powerUpName]", - description: "The bit cost of the given power-up. Name must be exact!" + description: "The bit cost of the given Power-Up. Name must be exact!" } ], categories: ["common"], diff --git a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-description.ts b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-description.ts index 3a61ed9f9..5dba51fdd 100644 --- a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-description.ts +++ b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-description.ts @@ -5,11 +5,11 @@ import { TwitchApi } from "../../api"; const model: ReplaceVariable = { definition: { handle: "powerUpDescription", - description: "The description of the power-up", + description: "The description of the Power-Up", examples: [ { usage: "powerUpDescription[powerUpName]", - description: "The description of the given power-up. Name must be exact!" + description: "The description of the given Power-Up. Name must be exact!" } ], categories: ["common"], @@ -32,12 +32,12 @@ const model: ReplaceVariable = { const powerUpId = powerUpsManager.getPowerUpIdByName(powerUpName); if (powerUpId == null) { - return "[Can't find power-up by name]"; + return "[Can't find Power-Up by name]"; } const powerUp = await TwitchApi.powerUps.getCustomPowerUpById(powerUpId); if (powerUp == null) { - return "[No power-up found]"; + return "[No Power-Up found]"; } return powerUp.prompt; diff --git a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-id.ts b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-id.ts index a7d8695f3..e8533686d 100644 --- a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-id.ts +++ b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-id.ts @@ -9,7 +9,7 @@ triggers["manual"] = true; const model: ReplaceVariable = { definition: { handle: "powerUpId", - description: "The ID of the power-up", + description: "The ID of the Power-Up", triggers: triggers, categories: ["common", "trigger based"], possibleDataOutput: ["text"] diff --git a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-image-url.ts b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-image-url.ts index e317fa227..e2877972d 100644 --- a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-image-url.ts +++ b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-image-url.ts @@ -9,7 +9,7 @@ const model: ReplaceVariable = { examples: [ { usage: "powerUpImageUrl[powerUpName]", - description: "The image URL of the given power-up. Name must be exact!" + description: "The image URL of the given Power-Up. Name must be exact!" } ], categories: ["common"], @@ -23,12 +23,12 @@ const model: ReplaceVariable = { const powerUpId = powerUpsManager.getPowerUpIdByName(powerUpName); if (powerUpId == null) { - return "[Can't find power-up by name]"; + return "[Can't find Power-Up by name]"; } const powerUp = await TwitchApi.powerUps.getCustomPowerUpById(powerUpId); if (powerUp == null) { - return "[No power-up found]"; + return "[No Power-Up found]"; } return powerUp.image?.url4x ?? powerUp.defaultImage.url4x; diff --git a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-message.ts b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-message.ts index 7b4f23387..cc7e29146 100644 --- a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-message.ts +++ b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-message.ts @@ -9,7 +9,7 @@ triggers["manual"] = true; const model: ReplaceVariable = { definition: { handle: "powerUpMessage", - description: "The message text entered by the viewer for the power-up", + description: "The message text entered by the viewer for the Power-Up", triggers: triggers, categories: ["common", "trigger based"], possibleDataOutput: ["text"] diff --git a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-name.ts b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-name.ts index 804d11e05..8ca9f9e58 100644 --- a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-name.ts +++ b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-name.ts @@ -9,7 +9,7 @@ triggers["manual"] = true; const model: ReplaceVariable = { definition: { handle: "powerUpName", - description: "The name of the power-up", + description: "The name of the Power-Up", triggers: triggers, categories: ["common", "trigger based"], possibleDataOutput: ["text"] diff --git a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-redemption-id.ts b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-redemption-id.ts index a6204dc20..1dd794059 100644 --- a/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-redemption-id.ts +++ b/src/backend/streaming-platforms/twitch/variables/power-ups/power-up-redemption-id.ts @@ -9,7 +9,7 @@ triggers["manual"] = true; const model: ReplaceVariable = { definition: { handle: "powerUpRedemptionId", - description: "The ID of the power-up redemption", + description: "The ID of the Power-Up redemption", triggers: triggers, categories: ["common", "trigger based"], possibleDataOutput: ["text"] diff --git a/src/gui/app/directives/modals/power-ups/edit-power-up.js b/src/gui/app/directives/modals/power-ups/edit-power-up.js index 23b5c6984..3d4a63548 100644 --- a/src/gui/app/directives/modals/power-ups/edit-power-up.js +++ b/src/gui/app/directives/modals/power-ups/edit-power-up.js @@ -7,7 +7,7 @@