Skip to content

Commit

Permalink
Declutterer 1.0.0
Browse files Browse the repository at this point in the history
New add-on. Declutterer is an add-on to remove some repetitive messages and help slow down spammy chats.
  • Loading branch information
ae9is authored Nov 6, 2023
1 parent c6626c3 commit 4a7931a
Show file tree
Hide file tree
Showing 4 changed files with 398 additions and 0 deletions.
10 changes: 10 additions & 0 deletions src/declutter/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use strict";

export const DEFAULT_SETTINGS = {
enabled: false,
similarity_threshold: 80,
repetitions_threshold: 3,
ignore_mods: true,
force_enable_when_mod: false,
cache_ttl: 30,
};
209 changes: 209 additions & 0 deletions src/declutter/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"use strict";
const { createElement } = FrankerFaceZ.utilities.dom;
import Logic from "./logic";
import { DEFAULT_SETTINGS } from "./constants";

/**
* This index addon file is responsible for user settings and
* enables/disables the chat filtering "logic" addon.
*/
class Declutter extends Addon {
constructor(...args) {
super(...args);
this.toggleEnabled = this.toggleEnabled.bind(this);
this.inject("chat");
this.inject("settings");
this.inject("site.fine"); // this.fine
this.inject("i18n");
this.register("logic", Logic, true); // name, module, inject_reference

this.settings.add("addon.declutter.enabled", {
default: DEFAULT_SETTINGS.enabled,
ui: {
path: "Add-Ons > Declutterer >> General Settings",
title: "Enable by Default",
description:
"Enable add-on by default. Otherwise, enable the add-on per channel via the toggle button next to chat settings",
component: "setting-check-box",
},
});

this.settings.add("addon.declutter.similarity_threshold", {
default: DEFAULT_SETTINGS.similarity_threshold,
ui: {
path: "Add-Ons > Declutterer >> Filter Settings",
title: "Similarity threshold in %",
description:
"Minimum similarity between 2 messages to count them as repetitions",
component: "setting-text-box",
process: "to_int",
bounds: [0, 100],
},
});

this.settings.add("addon.declutter.repetitions_threshold", {
default: DEFAULT_SETTINGS.repetitions_threshold,
ui: {
path: "Add-Ons > Declutterer >> Filter Settings",
title: "Repetition threshold",
description: "Amount of repetitions before the message is marked",
component: "setting-text-box",
process: "to_int",
bounds: [0],
},
});

this.settings.add("addon.declutter.ignore_mods", {
default: DEFAULT_SETTINGS.ignore_mods,
ui: {
path: "Add-Ons > Declutterer >> Mod Settings",
title: "Ignore mods",
description: "Do not limit messages by mods or the broadcaster",
component: "setting-check-box",
},
});

this.settings.add("addon.declutter.force_enable_when_mod", {
default: DEFAULT_SETTINGS.force_enable_when_mod,
ui: {
path: "Add-Ons > Declutterer >> Mod Settings",
title: "Force enabled when Moderator",
description: "Force filtering even in channels you are a moderator in",
component: "setting-check-box",
},
});

this.settings.add("addon.declutter.cache_ttl", {
default: DEFAULT_SETTINGS.cache_ttl,
ui: {
path: "Add-Ons > Declutterer >> Cache Settings",
title: "Cache TTL",
description:
"Amount of seconds for messages to stay in the cache. A long TTL leads to high RAM usage, especially in bigger channels",
component: "setting-text-box",
process: "to_int",
bounds: [1],
},
});

// workaround: logic starts enabled by default, which breaks checkEnabled()
this.logic.disable();

this.set_enabled = null;
this.ChatInput = this.fine.define("chat-input");
this.logic.on(":enabled", this.updateButtons, this);
this.logic.on(":disabled", this.updateButtons, this);
}

onEnable() {
this.chat.context.on(
"changed:addon.declutter.enabled",
this.checkEnabled,
this
);
this.checkEnabled();
this.ChatInput.on("mount", this.updateButton, this);
this.ChatInput.on("update", this.updateButton, this);
this.updateButtons();
}

async onDisable() {
await this.logic.disable();
for (const inst of this.ChatInput.instances) {
this.removeButton(inst);
}
this.ChatInput.off("mount", this.updateButton, this);
this.ChatInput.off("update", this.updateButton, this);
}

checkEnabled() {
const enabled =
this.set_enabled ?? this.chat.context.get("addon.declutter.enabled");
if (enabled && !this.logic.enabled) {
this.logic.enable();
} else if (!enabled && this.logic.enabled) {
this.logic.disable();
}
}

updateButtons() {
if (this.ChatInput) {
if (this.enabling || this.enabled) {
for (const inst of this.ChatInput.instances) {
this.updateButton(inst);
}
}
}
}

toggleEnabled() {
this.set_enabled = !this.logic.enabled;
this.checkEnabled();
}

// Button tooltips piggyback on any PrattleNot internationalisation text
updateButton(inst) {
const node = this.fine.getHostNode(inst);
if (!node) {
return;
}
if (!inst._ffz_declutter_button) {
inst._ffz_declutter_button = (
<div class="tw-relative ffz-il-tooltip__container">
<button
class={`tw-border-radius-medium tw-button-icon--primary ffz-core-button
tw-inline-flex tw-interactive tw-justify-content-center
tw-overflow-hidden tw-relative tw-button-icon`}
onclick={this.toggleEnabled}
>
<span class="tw-button-icon__icon">
{(inst._ffz_declutter_icon = <figure class="ffz-i-zreknarf" />)}
</span>
</button>
<div class="ffz-il-tooltip ffz-il-tooltip--up ffz-il-tooltip--align-right">
{this.i18n
.t("addon.pn.button.title", "Toggle Declutterer")
.replace("PrattleNot", "Declutterer")}
{(inst._ffz_declutter_enabled_tip = <div></div>)}
</div>
</div>
);
}
if (!node.contains(inst._ffz_declutter_button)) {
const container = node.querySelector(
".chat-input__buttons-container > div:last-child"
);
if (container) {
container.insertBefore(
inst._ffz_declutter_button,
container.firstChild
);
}
}
inst._ffz_declutter_icon.className = this.logic.enabled
? "ffz-i-chat"
: "ffz-i-chat-empty";
inst._ffz_declutter_enabled_tip.classList.toggle(
"tw-mg-t-1",
this.logic.enabled
);
inst._ffz_declutter_enabled_tip.textContent = this.logic.enabled
? this.i18n
.t(
"addon.pn.button.enabled",
"Declutterer is currently enabled. Click to disable."
)
.replace("PrattleNot", "Declutterer")
: null;
}

removeButton(inst) {
if (inst._ffz_declutter_button) {
inst._ffz_declutter_button.remove();
inst._ffz_declutter_button = null;
inst._ffz_declutter_enabled_tip = null;
}
}
}

Declutter.register();
168 changes: 168 additions & 0 deletions src/declutter/logic.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"use strict";
import { DEFAULT_SETTINGS } from "./constants";

/**
* This addon is toggled on/off by the index addon (or enabled by default via setting),
* and performs the actual chat filtering.
*/
export default class Logic extends Addon {
cache = new Map(); // message -> [expiry timestamp]
cacheEvictionTimer;
chatContext;
RepetitionCounter;
userId;

constructor(...args) {
super(...args);
this.inject("chat");
this.inject("settings");
this.inject("site");
this.chatContext = this.chat.context;
this.userId = this.site?.getUser()?.id;
this.cacheTtl =
(this.settings.get("addon.declutter.cache_ttl") ??
DEFAULT_SETTINGS.cache_ttl) * 1000;
}

/**
* Calculates the degree of similarity of 2 strings based on Dices Coefficient
* @param {string} first First string for comparison
* @param {string} second Second string for comparison
* @returns {number} Degree of similarity in the range [0,1]
* @see Original source code {@link https://github.com/aceakash/string-similarity}, MIT License
*/
compareTwoStrings = (first, second) => {
first = first.replace(/\s+/g, "");
second = second.replace(/\s+/g, "");
if (!first.length && !second.length) return 1;
if (!first.length || !second.length) return 0;
if (first === second) return 1;
if (first.length === 1 && second.length === 1) {
return first === second ? 1 : 0;
}
if (first.length < 2 || second.length < 2) return 0;
const firstBigrams = new Map();
for (let i = 0; i < first.length - 1; i++) {
const bigram = first.substring(i, i + 2);
const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) + 1 : 1;
firstBigrams.set(bigram, count);
}
let intersectionSize = 0;
for (let i = 0; i < second.length - 1; i++) {
const bigram = second.substring(i, i + 2);
const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) : 0;
if (count > 0) {
firstBigrams.set(bigram, count - 1);
intersectionSize++;
}
}
return (2.0 * intersectionSize) / (first.length + second.length - 2);
};

checkRepetitionAndCache = (message) => {
const simThreshold =
this.settings.get("addon.declutter.similarity_threshold") ??
DEFAULT_SETTINGS.similarity_threshold;
const repThreshold =
this.settings.get("addon.declutter.repetitions_threshold") ??
DEFAULT_SETTINGS.repetitions_threshold;
let n = 0;
for (const [msg, timestamps] of this.cache) {
if (simThreshold === 100) {
if (message.toLowerCase() === msg.toLowerCase()) {
n += timestamps?.length ?? 1;
}
} else if (
this.compareTwoStrings(message.toLowerCase(), msg.toLowerCase()) >
simThreshold / 100
) {
n += timestamps?.length ?? 1;
}
if (n >= repThreshold) {
break;
}
}
const existing = this.cache.get(message) ?? [];
this.cache.set(message, [...existing, Date.now() + this.cacheTtl]);
this.log.debug("(" + n + "): " + message);
return n;
};

onEnable = () => {
this.log.debug("Enabling Declutterer");
this.updateConstants();
this.chat.context.on(
"changed:addon.declutter.cache_ttl",
this.updateConstants,
this
);
this.on("chat:receive-message", this.handleMessage, this);
};

updateConstants = () => {
// Note any new value for cache TTL won't be picked up until re-enable
this.cache_ttl = this.chat.context.get("addon.declutter.cache_ttl");
// Cache eviction will happen 10x per TTL, at least once every 10s, max once per second
this.startCacheEvictionTimer(
Math.min(Math.max(1, Math.floor(this.cache_ttl / 10)), 10)
);
};

onDisable = () => {
this.log.debug("Disabling Declutterer");
this.off("chat:receive-message", this.handleMessage, this);
if (this.cacheEvictionTimer) {
clearInterval(this.cacheEvictionTimer);
}
this.cache = new Map();
};

startCacheEvictionTimer = (intervalSeconds) => {
if (this.cacheEvictionTimer) {
clearInterval(this.cacheEvictionTimer);
}
this.cacheEvictionTimer = setInterval(() => {
this.log.debug("Running cache eviction cycle");
for (const [msg, timestamps] of this.cache) {
const futureTimestamps =
timestamps?.filter((time) => time > Date.now()) ?? [];
if (futureTimestamps.length === 0) {
this.cache.delete(msg);
} else {
this.cache.set(msg, futureTimestamps);
}
}
}, intervalSeconds * 1000);
};

handleMessage = (event) => {
if (!event.message || event.defaultPrevented) return;
if (
this.chatContext &&
this.chatContext.get("context.moderator") &&
!this.settings.get("addon.declutter.force_enable_when_mod")
)
return;
const msg = event.message;
if (msg.ffz_removed || msg.deleted || !msg.ffz_tokens) return;
if (
this.settings.get("addon.declutter.ignore_mods") &&
(msg.badges.moderator || msg.badges.broadcaster)
)
return;
if (msg.user && this.userId && msg.user.id == this.userId) {
// Always show the user's own messages
return;
}
if (!msg.repetitionCount && msg.repetitionCount !== 0) {
msg.repetitionCount = this.checkRepetitionAndCache(msg.message);
}
const repThreshold =
this.settings.get("addon.declutter.repetitions_threshold") ??
DEFAULT_SETTINGS.repetitions_threshold;
if (msg.repetitionCount >= repThreshold) {
// Hide messages matching our filter
event.preventDefault();
}
};
}
Loading

0 comments on commit 4a7931a

Please sign in to comment.