Files
Paradise/tgui/packages/tgui-panel/chat/renderer.js
PoobleandGitHub c73dc100b0 Add ability to blacklist phrases in chat (#30902)
* add ability to blacklist phrases in chat

* support exact

* support exact

* i think this fixes the tgui thing

* prettier linter eeeeeeeeeeee
2025-11-23 02:56:53 +00:00

672 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import DOMPurify from 'dompurify';
import { createLogger } from 'tgui/logging';
import { EventEmitter } from 'tgui-core/events';
import {
COMBINE_MAX_MESSAGES,
COMBINE_MAX_TIME_WINDOW,
IMAGE_RETRY_DELAY,
IMAGE_RETRY_LIMIT,
IMAGE_RETRY_MESSAGE_AGE,
MAX_VISIBLE_MESSAGES,
MESSAGE_PRUNE_INTERVAL,
MESSAGE_TYPE_ADMINPM,
MESSAGE_TYPE_INTERNAL,
MESSAGE_TYPE_MENTORPM,
MESSAGE_TYPE_UNKNOWN,
MESSAGE_TYPES,
} from './constants';
import { canPageAcceptType, createMessage, isSameMessage } from './model';
import { highlightNode, linkifyNode, processBlacklistNode } from './replaceInTextNode';
const logger = createLogger('chatRenderer');
// We consider this as the smallest possible scroll offset
// that is still trackable.
const SCROLL_TRACKING_TOLERANCE = 24;
// List of blacklisted tags
const blacklisted_tags = ['iframe', 'video'];
const findNearestScrollableParent = (startingNode) => {
const body = document.body;
let node = startingNode;
while (node && node !== body) {
// This definitely has a vertical scrollbar, because it reduces
// scrollWidth of the element. Might not work if element uses
// overflow: hidden.
if (node.scrollWidth < node.offsetWidth) {
return node;
}
node = node.parentNode;
}
return window;
};
const createHighlightNode = (text, color) => {
const node = document.createElement('span');
node.className = 'Chat__highlight';
node.setAttribute('style', 'background-color:' + color);
node.textContent = text;
return node;
};
const createMessageNode = () => {
const node = document.createElement('div');
node.className = 'ChatMessage';
return node;
};
const createReconnectedNode = () => {
const node = document.createElement('div');
node.className = 'Chat__reconnected';
return node;
};
const handleImageError = (e) => {
setTimeout(() => {
/** @type {HTMLImageElement} */
const node = e.target;
if (!node) {
return;
}
const attempts = parseInt(node.getAttribute('data-reload-n'), 10) || 0;
if (attempts >= IMAGE_RETRY_LIMIT) {
logger.error(`failed to load an image after ${attempts} attempts`);
return;
}
const src = node.src;
node.src = null;
node.src = src + '#' + attempts;
node.setAttribute('data-reload-n', attempts + 1);
}, IMAGE_RETRY_DELAY);
};
/**
* Assigns a "times-repeated" badge to the message.
*/
const updateMessageBadge = (message) => {
const { node, times } = message;
if (!node || !times) {
// Nothing to update
return;
}
const foundBadge = node.querySelector('.Chat__badge');
const badge = foundBadge || document.createElement('div');
badge.textContent = times;
badge.className = 'Chat__badge';
if (!foundBadge) {
node.appendChild(badge);
}
};
class ChatRenderer {
constructor() {
/** @type {HTMLElement} */
this.loaded = false;
/** @type {HTMLElement} */
this.rootNode = null;
this.queue = [];
this.messages = [];
this.visibleMessages = [];
this.page = null;
this.events = new EventEmitter();
// Scroll handler
/** @type {HTMLElement} */
this.scrollNode = null;
this.scrollTracking = true;
this.handleScroll = (type) => {
const node = this.scrollNode;
const height = node.scrollHeight;
const bottom = node.scrollTop + node.offsetHeight;
const scrollTracking = Math.abs(height - bottom) < SCROLL_TRACKING_TOLERANCE;
if (scrollTracking !== this.scrollTracking) {
this.scrollTracking = scrollTracking;
this.events.emit('scrollTrackingChanged', scrollTracking);
logger.debug('tracking', this.scrollTracking);
}
};
this.ensureScrollTracking = () => {
if (this.scrollTracking) {
this.scrollToBottom();
}
};
// Periodic message pruning
setInterval(() => this.pruneMessages(), MESSAGE_PRUNE_INTERVAL);
}
isReady() {
return this.loaded && this.rootNode && this.page;
}
mount(node) {
// Mount existing root node on top of the new node
if (this.rootNode) {
node.appendChild(this.rootNode);
}
// Initialize the root node
else {
this.rootNode = node;
}
// Find scrollable parent
this.scrollNode = findNearestScrollableParent(this.rootNode);
this.scrollNode.addEventListener('scroll', this.handleScroll);
setTimeout(() => {
this.scrollToBottom();
});
// Flush the queue
this.tryFlushQueue();
}
onStateLoaded() {
this.loaded = true;
this.tryFlushQueue();
}
tryFlushQueue() {
if (this.isReady() && this.queue.length > 0) {
this.processBatch(this.queue);
this.queue = [];
}
}
assignStyle(style = {}) {
for (let key of Object.keys(style)) {
this.rootNode.style.setProperty(key, style[key]);
}
}
setHighlight(highlightSettings, highlightSettingById) {
this.highlightParsers = null;
if (!highlightSettings) {
return;
}
highlightSettings.map((id) => {
const setting = highlightSettingById[id];
const text = setting.highlightText;
const highlightColor = setting.highlightColor;
const highlightWholeMessage = setting.highlightWholeMessage;
const matchWord = setting.matchWord;
const matchCase = setting.matchCase;
const allowedRegex = /^[a-zа-яё0-9_\-$/^[\s\]\\]+$/gi;
const regexEscapeCharacters = /[!#$%^&*)(+=.<>{}[\]:;'"|~`_\-\\/]/g;
const lines = String(text)
.split(/[,|]/)
.map((str) => str.trim())
.filter(
(str) =>
// Must be longer than one character
str &&
str.length > 1 &&
// Must be alphanumeric (with some punctuation)
allowedRegex.test(str) &&
// Reset lastIndex so it does not mess up the next word
((allowedRegex.lastIndex = 0) || true)
);
let highlightWords;
let highlightRegex;
// Nothing to match, reset highlighting
if (lines.length === 0) {
return;
}
let regexExpressions = [];
// Organize each highlight entry into regex expressions and words
for (let line of lines) {
// Regex expression syntax is /[exp]/
if (line.charAt(0) === '/' && line.charAt(line.length - 1) === '/') {
const expr = line.substring(1, line.length - 1);
// Check if this is more than one character
if (/^(\[.*\]|\\.|.)$/.test(expr)) {
continue;
}
regexExpressions.push(expr);
} else {
// Lazy init
if (!highlightWords) {
highlightWords = [];
}
// We're not going to let regex characters fuck up our RegEx operation.
line = line.replace(regexEscapeCharacters, '\\$&');
highlightWords.push(line);
}
}
const regexStr = regexExpressions.join('|');
const flags = 'g' + (matchCase ? '' : 'i');
// We wrap this in a try-catch to ensure that broken regex doesn't break
// the entire chat.
try {
// setting regex overrides matchword
if (regexStr) {
highlightRegex = new RegExp('(' + regexStr + ')', flags);
} else {
const pattern = `${matchWord ? '\\b' : ''}(${highlightWords.join('|')})${matchWord ? '\\b' : ''}`;
highlightRegex = new RegExp(pattern, flags);
}
} catch {
// We just reset it if it's invalid.
highlightRegex = null;
}
// Lazy init
if (!this.highlightParsers) {
this.highlightParsers = [];
}
this.highlightParsers.push({
highlightWords,
highlightRegex,
highlightColor,
highlightWholeMessage,
});
});
}
setBlacklist(blacklistSettings, blacklistSettingById) {
this.blacklistParsers = null;
if (!blacklistSettings) {
return;
}
blacklistSettings.map((id) => {
const setting = blacklistSettingById[id];
const text = setting.blacklistText;
const censor = setting.censor;
const matchWord = setting.matchWord;
const matchCase = setting.matchCase;
const allowedRegex = /^[a-zа-яё0-9_\-$/^[\s\]\\]+$/gi;
const regexEscapeCharacters = /[!#$%^&*)(+=.<>{}[\]:;'"|~`_\-\\/]/g;
const lines = String(text)
.split(/[,|]/)
.map((str) => str.trim())
.filter(
(str) =>
// Must be longer than one character
str &&
str.length > 1 &&
// Must be alphanumeric (with some punctuation)
allowedRegex.test(str) &&
// Reset last
((allowedRegex.lastIndex = 0) || true)
);
let blacklistWords;
let blacklistRegex;
// Nothing to blacklist
if (lines.length === 0) {
return;
}
let regexExpressions = [];
// Organize each blacklist entry into regex expressions and words
for (let line of lines) {
// Regex expression syntax is /[exp]/
if (line.charAt(0) === '/' && line.charAt(line.length - 1) === '/') {
const expr = line.substring(1, line.length - 1);
// Check if this is more than one character
if (/^(\[.*\]|\\.|.)$/.test(expr)) {
continue;
}
regexExpressions.push(expr);
} else {
// Lazy init
if (!blacklistWords) {
blacklistWords = [];
}
// We're not going to let regex characters fuck up our RegEx operation.
line = line.replace(regexEscapeCharacters, '\\$&');
blacklistWords.push(line);
}
}
const regexStr = regexExpressions.join('|');
const flags = 'g' + (matchCase ? '' : 'i');
try {
// setting regex overrides blacklistWords
if (regexStr) {
blacklistRegex = new RegExp('(' + regexStr + ')', flags);
} else {
const pattern = `${matchWord ? '\\b' : ''}(${blacklistWords.join('|')})${matchWord ? '\\b' : ''}`;
blacklistRegex = new RegExp(pattern, flags);
}
} catch {
// We just reset it if it's invalid.
blacklistRegex = null;
}
// Lazy init
if (!this.blacklistParsers) {
this.blacklistParsers = [];
}
this.blacklistParsers.push({
blacklistWords,
blacklistRegex,
censor,
});
});
// Rebuild chat to apply new blacklist settings to existing messages
this.rebuildChat();
}
scrollToBottom() {
// scrollHeight is always bigger than scrollTop and is
// automatically clamped to the valid range.
this.scrollNode.scrollTop = this.scrollNode.scrollHeight;
}
changePage(page) {
if (!this.isReady()) {
this.page = page;
this.tryFlushQueue();
return;
}
this.page = page;
// Fast clear of the root node
this.rootNode.textContent = '';
this.visibleMessages = [];
// Re-add message nodes
const fragment = document.createDocumentFragment();
let node;
for (let message of this.messages) {
if (canPageAcceptType(page, message.type)) {
node = message.node;
fragment.appendChild(node);
this.visibleMessages.push(message);
}
}
if (node) {
this.rootNode.appendChild(fragment);
node.scrollIntoView();
}
}
getCombinableMessage(predicate, now, from, to) {
for (let i = from; i >= to; i--) {
const message = this.visibleMessages[i];
// prettier-ignore
const matches = (
// Is not an internal message
!message.type.startsWith(MESSAGE_TYPE_INTERNAL)
// Text payload must fully match
&& isSameMessage(message, predicate)
// Must land within the specified time window
&& now < message.createdAt + COMBINE_MAX_TIME_WINDOW
);
if (matches) {
return message;
}
}
return null;
}
processBatch(batch, options = {}) {
const { prepend, notifyListeners = true } = options;
const now = Date.now();
// Queue up messages until chat is ready
if (!this.isReady()) {
if (prepend) {
this.queue = [...batch, ...this.queue];
} else {
this.queue = [...this.queue, ...batch];
}
return;
}
// Insert messages
const fragment = document.createDocumentFragment();
const countByType = {};
let node;
const len = this.visibleMessages.length;
const from = len - 1;
const to = Math.max(0, len - COMBINE_MAX_MESSAGES);
for (let payload of batch) {
const message = createMessage(payload);
// Combine messages
const combinable = this.getCombinableMessage(message, now, from, to);
if (combinable) {
combinable.times = (combinable.times || 1) + 1;
updateMessageBadge(combinable);
continue;
}
// Reuse message node
if (message.node) {
node = message.node;
}
// Reconnected
else if (message.type === 'internal/reconnected') {
node = createReconnectedNode();
}
// Create message node
else {
node = createMessageNode();
// Payload is plain text
if (message.text) {
node.textContent = message.text;
}
// Payload is HTML
else if (message.html) {
node.innerHTML = message.html;
node.innerHTML = DOMPurify.sanitize(node.innerHTML, {
// No iframes in my chat kkthxbye
FORBID_TAGS: blacklisted_tags,
ALLOW_UNKNOWN_PROTOCOLS: true,
});
} else {
logger.error('Error: message is missing text payload', message);
}
// Highlight text
if (!message.avoidHighlighting && this.highlightParsers) {
this.highlightParsers.map((parser) => {
const highlighted = highlightNode(node, parser.highlightRegex, parser.highlightWords, (text) =>
createHighlightNode(text, parser.highlightColor)
);
if (highlighted && parser.highlightWholeMessage) {
node.className += ' ChatMessage--highlighted';
}
});
}
}
// Process blacklist patterns
let isBlacklisted = false;
// No blacklisting for Admin PMs and Mentor PMs
if (
!message.avoidBlacklisting &&
message.type !== MESSAGE_TYPE_ADMINPM &&
message.type !== MESSAGE_TYPE_MENTORPM &&
this.blacklistParsers
) {
this.blacklistParsers.forEach((parser) => {
const foundMatch = processBlacklistNode(node, parser.blacklistRegex, parser.blacklistWords, parser.censor);
if (!parser.censor && foundMatch) {
// Mark for removal
isBlacklisted = true;
}
});
}
// Linkify text
const linkifyNodes = node.querySelectorAll('.linkify');
for (let i = 0; i < linkifyNodes.length; ++i) {
linkifyNode(linkifyNodes[i]);
}
// Assign an image error handler
if (now < message.createdAt + IMAGE_RETRY_MESSAGE_AGE) {
const imgNodes = node.querySelectorAll('img');
for (let i = 0; i < imgNodes.length; i++) {
const imgNode = imgNodes[i];
imgNode.addEventListener('error', handleImageError);
}
}
// Store the node in the message
message.node = node;
// Query all possible selectors to find out the message type
if (!message.type) {
const typeDef = MESSAGE_TYPES.find((typeDef) => typeDef.selector && node.querySelector(typeDef.selector));
message.type = typeDef?.type || MESSAGE_TYPE_UNKNOWN;
}
updateMessageBadge(message);
if (!countByType[message.type]) {
countByType[message.type] = 0;
}
countByType[message.type] += 1;
// TODO: Detect duplicates
this.messages.push(message);
if (canPageAcceptType(this.page, message.type) && !isBlacklisted) {
fragment.appendChild(node);
this.visibleMessages.push(message);
}
}
if (node) {
const firstChild = this.rootNode.childNodes[0];
if (prepend && firstChild) {
this.rootNode.insertBefore(fragment, firstChild);
} else {
this.rootNode.appendChild(fragment);
}
if (this.scrollTracking) {
setTimeout(() => this.scrollToBottom());
}
}
// Notify listeners that we have processed the batch
if (notifyListeners) {
this.events.emit('batchProcessed', countByType);
}
}
pruneMessages() {
if (!this.isReady()) {
return;
}
// Delay pruning because user is currently interacting
// with chat history
if (!this.scrollTracking) {
logger.debug('pruning delayed');
return;
}
// Visible messages
{
const messages = this.visibleMessages;
const fromIndex = Math.max(0, messages.length - MAX_VISIBLE_MESSAGES);
if (fromIndex > 0) {
this.visibleMessages = messages.slice(fromIndex);
for (let i = 0; i < fromIndex; i++) {
const message = messages[i];
this.rootNode.removeChild(message.node);
// Mark this message as pruned
message.node = 'pruned';
}
// Remove pruned messages from the message array
// prettier-ignore
this.messages = this.messages.filter(message => (
message.node !== 'pruned'
));
logger.log(`pruned ${fromIndex} visible messages`);
}
}
// All messages
{
const fromIndex = Math.max(0, this.messages.length - MAX_VISIBLE_MESSAGES);
if (fromIndex > 0) {
this.messages = this.messages.slice(fromIndex);
logger.log(`pruned ${fromIndex} stored messages`);
}
}
}
rebuildChat() {
if (!this.isReady()) {
return;
}
// Make a copy of messages
const fromIndex = Math.max(0, this.messages.length - MAX_VISIBLE_MESSAGES);
const messages = this.messages.slice(fromIndex);
// Remove existing nodes
for (let message of messages) {
message.node = undefined;
}
// Fast clear of the root node
this.rootNode.textContent = '';
this.messages = [];
this.visibleMessages = [];
// Repopulate the chat log
this.processBatch(messages, {
notifyListeners: false,
});
}
/**
* @clearChat
* @copyright 2023
* @author Cheffie
* @link https://github.com/CheffieGithub
* @license MIT
*/
clearChat() {
const messages = this.visibleMessages;
this.visibleMessages = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
this.rootNode.removeChild(message.node);
// Mark this message as pruned
message.node = 'pruned';
}
// Remove pruned messages from the message array
this.messages = this.messages.filter((message) => message.node !== 'pruned');
logger.log(`Cleared chat`);
}
saveToDisk() {
// Compile currently loaded stylesheets as CSS text
let cssText = '';
const styleSheets = document.styleSheets;
for (let i = 0; i < styleSheets.length; i++) {
const cssRules = styleSheets[i].cssRules;
for (let i = 0; i < cssRules.length; i++) {
const rule = cssRules[i];
if (rule && typeof rule.cssText === 'string') {
cssText += rule.cssText + '\n';
}
}
}
cssText += 'body, html { background-color: #141414 }\n';
// Compile chat log as HTML text
let messagesHtml = '';
for (let message of this.visibleMessages) {
if (message.node) {
messagesHtml += message.node.outerHTML + '\n';
}
}
// Create a page
// prettier-ignore
const pageHtml = '<!doctype html>\n'
+ '<html>\n'
+ '<head>\n'
+ '<title>SS13 Chat Log</title>\n'
+ '<style>\n' + cssText + '</style>\n'
+ '</head>\n'
+ '<body>\n'
+ '<div class="Chat">\n'
+ messagesHtml
+ '</div>\n'
+ '</body>\n'
+ '</html>\n';
// Create and send a nice blob
const blob = new Blob([pageHtml], { type: 'text/plain' });
const timestamp = new Date().toISOString().substring(0, 19).replace(/[-:]/g, '').replace('T', '-');
Byond.saveBlob(blob, `ss13-paradise-chatlog-${timestamp}.html`, '.html');
}
}
// Make chat renderer global so that we can continue using the same
// instance after hot code replacement.
if (!window.__chatRenderer__) {
window.__chatRenderer__ = new ChatRenderer();
}
/** @type {ChatRenderer} */
export const chatRenderer = window.__chatRenderer__;