From 1142762a357ff3fecda16bd5e65a5393dafb4677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Bergh=C3=A4ll?= Date: Sat, 15 Apr 2023 17:41:40 +0300 Subject: [PATCH 01/20] feat/block-chat-phrases: Add blocked phrases feature --- CHANGELOG-nightly.md | 1 + src/composable/chat/useChatBlocking.ts | 168 ++++++++++++ .../settings/SettingsConfigBlocking.vue | 242 ++++++++++++++++++ .../modules/chat-input/ChatInputModule.vue | 15 +- .../twitch.tv/modules/chat/ChatController.vue | 6 + src/site/twitch.tv/modules/chat/ChatList.vue | 10 +- 6 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 src/composable/chat/useChatBlocking.ts create mode 100644 src/site/global/settings/SettingsConfigBlocking.vue diff --git a/CHANGELOG-nightly.md b/CHANGELOG-nightly.md index 62963047b..7d012726a 100644 --- a/CHANGELOG-nightly.md +++ b/CHANGELOG-nightly.md @@ -4,6 +4,7 @@ - Links in chat messages now respect known TLDs instead of matching any url-like pattern - Added an option to show timeouts/bans directly in the chat without being a moderator +- Added a blocked phrases feature ### Version 3.0.5.1000 diff --git a/src/composable/chat/useChatBlocking.ts b/src/composable/chat/useChatBlocking.ts new file mode 100644 index 000000000..65ca57f4b --- /dev/null +++ b/src/composable/chat/useChatBlocking.ts @@ -0,0 +1,168 @@ +import { markRaw, reactive, toRaw, watch } from "vue"; +import { toReactive } from "@vueuse/core"; +import { debounceFn } from "@/common/Async"; +import { log } from "@/common/Logger"; +import type { ChatMessage } from "@/common/chat/ChatMessage"; +import { ChannelContext } from "@/composable/channel/useChannelContext"; +import { useConfig } from "../useSettings"; + +interface ChatBlocking { + blockedPhrases: Record; +} + +export interface BlockedPhraseDef { + id: string; + + pattern?: string; + test?: (msg: ChatMessage) => boolean; + regexp?: boolean; + readonly cachedRegExp?: RegExp; + + label: string; + caseSensitive?: boolean; + persist?: boolean; +} + +const m = new WeakMap(); + +const blockedPhrases = useConfig>("chat_input.blocking.phrases"); + +export function useChatBlocking(ctx: ChannelContext) { + let data = m.get(ctx); + if (!data) { + data = reactive({ + blockedPhrases: {} + }); + + watch( + blockedPhrases, + (h) => { + if (!data) return; + + for (const [k, v] of Object.entries(data.blockedPhrases)) { + if (!v.persist) continue; + + delete data.blockedPhrases[k]; + } + + for (const [, v] of h) { + data.blockedPhrases[v.id] = v; + } + }, + { + immediate: true, + }, + ); + + m.set(ctx, data); + } + + const save = debounceFn(function (): void { + if (!data) return; + + const items: [string, BlockedPhraseDef][] = Array.from(Object.values(data.blockedPhrases)) + .filter((h) => h.persist) + .map((h) => [ + h.id, + toRaw(h), + ]); + + blockedPhrases.value = new Map(items); + }, 250); + + function define(id: string, def: Omit, persist?: boolean): BlockedPhraseDef { + if (!data) return {} as BlockedPhraseDef; + + const h = (data.blockedPhrases[id] = { ...def, id, persist }); + + if (!persist) return h; + + // Store to DB + blockedPhrases.value.set(id, markRaw(h)); + save(); + + return h; + } + + function remove(id: string): void { + if (!data) return; + + delete data.blockedPhrases[id]; + save(); + } + + function checkMatch(key: string, msg: ChatMessage): boolean { + if (!data) return false; + + const h = data?.blockedPhrases[key]; + + if (!h) return false; + + let ok = false; + + if (h.regexp) { + let regexp = h.cachedRegExp; + if (!regexp) { + try { + regexp = new RegExp(h.pattern as string, "i"); + Object.defineProperty(h, "cachedRegExp", { value: regexp }); + } catch (err) { + log.warn("", "Invalid regexp:", h.pattern ?? ""); + + msg.setHighlight("#878787", "Error " + (err as Error).message); + return false; + } + } + + ok = regexp.test(msg.body); + } else if (h.pattern) { + ok = h.caseSensitive + ? msg.body.includes(h.pattern) + : msg.body.toLowerCase().includes(h.pattern.toLowerCase()); + } else if (typeof h.test === "function") { + ok = h.test(msg); + } + + return ok; + } + + function doesMessageContainBlockedPhrase(message: ChatMessage) { + const messageIds: string[] = []; + for (const blockedPhraseID in getAll()) { + if (checkMatch(blockedPhraseID, message)) { + messageIds.push(message.id) + } + } + return messageIds.includes(message.id); + } + + function getAll(): Record { + if (!data) return {}; + + return toReactive(data.blockedPhrases); + } + + function updateId(oldId: string, newId: string): void { + if (!data) return; + + const h = data.blockedPhrases[oldId]; + if (!h) return; + + data.blockedPhrases[newId] = h; + delete data.blockedPhrases[oldId]; + + h.id = newId; + + save(); + } + + return { + define, + remove, + getAll, + doesMessageContainBlockedPhrase, + save, + updateId, + checkMatch + }; +} diff --git a/src/site/global/settings/SettingsConfigBlocking.vue b/src/site/global/settings/SettingsConfigBlocking.vue new file mode 100644 index 000000000..8f330061f --- /dev/null +++ b/src/site/global/settings/SettingsConfigBlocking.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/src/site/twitch.tv/modules/chat-input/ChatInputModule.vue b/src/site/twitch.tv/modules/chat-input/ChatInputModule.vue index 5a2bda003..7824ea2e6 100644 --- a/src/site/twitch.tv/modules/chat-input/ChatInputModule.vue +++ b/src/site/twitch.tv/modules/chat-input/ChatInputModule.vue @@ -11,7 +11,7 @@ diff --git a/src/site/twitch.tv/modules/chat/ChatController.vue b/src/site/twitch.tv/modules/chat/ChatController.vue index c1179eb5b..93a7b85ac 100644 --- a/src/site/twitch.tv/modules/chat/ChatController.vue +++ b/src/site/twitch.tv/modules/chat/ChatController.vue @@ -41,6 +41,7 @@ import { HookedInstance, awaitComponents } from "@/common/ReactHooks"; import { defineFunctionHook, definePropertyHook, unsetPropertyHook } from "@/common/Reflection"; import { ChatMessage } from "@/common/chat/ChatMessage"; import { useChannelContext } from "@/composable/channel/useChannelContext"; +import { useChatBlocking } from "@/composable/chat/useChatBlocking"; import { useChatEmotes } from "@/composable/chat/useChatEmotes"; import { useChatMessages } from "@/composable/chat/useChatMessages"; import { useChatProperties } from "@/composable/chat/useChatProperties"; @@ -93,6 +94,7 @@ const scroller = useChatScroller(ctx, { }); const properties = useChatProperties(ctx); const tools = useChatTools(ctx); +const chatBlocking = useChatBlocking(ctx); // line limit const lineLimit = useConfig("chat.line_limit", 150); @@ -253,6 +255,10 @@ watch(messageBufferComponentDbc, (msgBuf, old) => { m.historical = true; chatList.value?.onChatMessage(m, msg as Twitch.ChatMessage, false); + if (chatBlocking.doesMessageContainBlockedPhrase(m)) { + continue; + } + historical.push(m); continue; } diff --git a/src/site/twitch.tv/modules/chat/ChatList.vue b/src/site/twitch.tv/modules/chat/ChatList.vue index 386dd0a05..957d422d2 100644 --- a/src/site/twitch.tv/modules/chat/ChatList.vue +++ b/src/site/twitch.tv/modules/chat/ChatList.vue @@ -31,6 +31,7 @@ import { convertCheerEmote, convertTwitchEmote } from "@/common/Transform"; import { ChatMessage, ChatMessageModeration, ChatUser } from "@/common/chat/ChatMessage"; import { IsChatMessage, IsDisplayableMessage, IsModerationMessage } from "@/common/type-predicates/Messages"; import { useChannelContext } from "@/composable/channel/useChannelContext"; +import { useChatBlocking } from "@/composable/chat/useChatBlocking"; import { useChatEmotes } from "@/composable/chat/useChatEmotes"; import { useChatHighlights } from "@/composable/chat/useChatHighlights"; import { useChatMessages } from "@/composable/chat/useChatMessages"; @@ -56,6 +57,7 @@ const displayedMessages = toRef(messages, "displayed"); const scroller = useChatScroller(ctx); const properties = useChatProperties(ctx); const chatHighlights = useChatHighlights(ctx); +const chatBlocking = useChatBlocking(ctx); const pageVisibility = useDocumentVisibility(); const isHovering = toRef(properties, "hovering"); const pausedByVisibility = ref(false); @@ -130,7 +132,9 @@ const onMessage = (msgData: Twitch.AnyMessage): boolean => { }; function onChatMessage(msg: ChatMessage, msgData: Twitch.AnyMessage, shouldRender = true) { + let shouldRenderMessage = shouldRender; const c = getMessageComponent(msgData.type); + if (c) { msg.setComponent(c, { msgData: msgData }); } @@ -291,6 +295,10 @@ function onChatMessage(msg: ChatMessage, msgData: Twitch.AnyMessage, shouldRende chatHighlights.checkMatch(highlightID, msg); } + if (chatBlocking.doesMessageContainBlockedPhrase(msg)) { + shouldRenderMessage = false; + } + if (properties.isModerator) { msg.pinnable = true; msg.deletable = true; @@ -298,7 +306,7 @@ function onChatMessage(msg: ChatMessage, msgData: Twitch.AnyMessage, shouldRende // Add message to store // it will be rendered on the next tick - if (shouldRender) messages.add(msg); + if (shouldRenderMessage) messages.add(msg); } function onModerationMessage(msgData: Twitch.ModerationMessage) { From 11d27dd15a557617fef231a7b7db92ef42c840f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Bergh=C3=A4ll?= Date: Sat, 15 Apr 2023 18:05:14 +0300 Subject: [PATCH 02/20] feat/block-chat-phrases: Use Object.values instead of for..in --- src/composable/chat/useChatBlocking.ts | 38 ++++++++++---------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/composable/chat/useChatBlocking.ts b/src/composable/chat/useChatBlocking.ts index 65ca57f4b..a9a93b6a4 100644 --- a/src/composable/chat/useChatBlocking.ts +++ b/src/composable/chat/useChatBlocking.ts @@ -91,23 +91,19 @@ export function useChatBlocking(ctx: ChannelContext) { save(); } - function checkMatch(key: string, msg: ChatMessage): boolean { - if (!data) return false; - - const h = data?.blockedPhrases[key]; - - if (!h) return false; + function checkMatch(blockedPhraseDef: BlockedPhraseDef, msg: ChatMessage): boolean { + if (!data || !blockedPhraseDef) return false; let ok = false; - if (h.regexp) { - let regexp = h.cachedRegExp; + if (blockedPhraseDef.regexp) { + let regexp = blockedPhraseDef.cachedRegExp; if (!regexp) { try { - regexp = new RegExp(h.pattern as string, "i"); - Object.defineProperty(h, "cachedRegExp", { value: regexp }); + regexp = new RegExp(blockedPhraseDef.pattern as string, "i"); + Object.defineProperty(blockedPhraseDef, "cachedRegExp", { value: regexp }); } catch (err) { - log.warn("", "Invalid regexp:", h.pattern ?? ""); + log.warn("", "Invalid regexp:", blockedPhraseDef.pattern ?? ""); msg.setHighlight("#878787", "Error " + (err as Error).message); return false; @@ -115,25 +111,19 @@ export function useChatBlocking(ctx: ChannelContext) { } ok = regexp.test(msg.body); - } else if (h.pattern) { - ok = h.caseSensitive - ? msg.body.includes(h.pattern) - : msg.body.toLowerCase().includes(h.pattern.toLowerCase()); - } else if (typeof h.test === "function") { - ok = h.test(msg); + } else if (blockedPhraseDef.pattern) { + ok = blockedPhraseDef.caseSensitive + ? msg.body.includes(blockedPhraseDef.pattern) + : msg.body.toLowerCase().includes(blockedPhraseDef.pattern.toLowerCase()); + } else if (typeof blockedPhraseDef.test === "function") { + ok = blockedPhraseDef.test(msg); } return ok; } function doesMessageContainBlockedPhrase(message: ChatMessage) { - const messageIds: string[] = []; - for (const blockedPhraseID in getAll()) { - if (checkMatch(blockedPhraseID, message)) { - messageIds.push(message.id) - } - } - return messageIds.includes(message.id); + return Object.values(getAll()).some((s) => checkMatch(s, message)) } function getAll(): Record { From 3b46c7e61eab103a3701c5de993c3a3e56a3e705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Bergh=C3=A4ll?= Date: Sat, 15 Apr 2023 18:31:03 +0300 Subject: [PATCH 03/20] feat/block-chat-phrases: Fix CI issues --- src/composable/chat/useChatBlocking.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/composable/chat/useChatBlocking.ts b/src/composable/chat/useChatBlocking.ts index a9a93b6a4..7bda9a6e8 100644 --- a/src/composable/chat/useChatBlocking.ts +++ b/src/composable/chat/useChatBlocking.ts @@ -31,7 +31,7 @@ export function useChatBlocking(ctx: ChannelContext) { let data = m.get(ctx); if (!data) { data = reactive({ - blockedPhrases: {} + blockedPhrases: {}, }); watch( @@ -62,10 +62,7 @@ export function useChatBlocking(ctx: ChannelContext) { const items: [string, BlockedPhraseDef][] = Array.from(Object.values(data.blockedPhrases)) .filter((h) => h.persist) - .map((h) => [ - h.id, - toRaw(h), - ]); + .map((h) => [h.id, toRaw(h)]); blockedPhrases.value = new Map(items); }, 250); @@ -123,7 +120,7 @@ export function useChatBlocking(ctx: ChannelContext) { } function doesMessageContainBlockedPhrase(message: ChatMessage) { - return Object.values(getAll()).some((s) => checkMatch(s, message)) + return Object.values(getAll()).some((s) => checkMatch(s, message)); } function getAll(): Record { @@ -153,6 +150,6 @@ export function useChatBlocking(ctx: ChannelContext) { doesMessageContainBlockedPhrase, save, updateId, - checkMatch + checkMatch, }; } From feb3fbaa0a72975b7d7e2198f392dfcdb935acea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Bergh=C3=A4ll?= Date: Sat, 15 Apr 2023 19:06:46 +0300 Subject: [PATCH 04/20] feat/block-chat-phrases: Remove unnecessary loop keys --- src/site/global/settings/SettingsConfigBlocking.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/site/global/settings/SettingsConfigBlocking.vue b/src/site/global/settings/SettingsConfigBlocking.vue index 8f330061f..132c76882 100644 --- a/src/site/global/settings/SettingsConfigBlocking.vue +++ b/src/site/global/settings/SettingsConfigBlocking.vue @@ -10,7 +10,7 @@ -