1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00
FrankerFaceZ/src/modules/chat/index.js
SirStendec 77d6cf56d2 4.20.69
Nice.

* Changed: Warn users that they have `Show Mod Icons` disabled within [Chat > Actions](~chat.actions).
* Changed: Blocked Badges, Highlight Badges, and Blocked Types within [Chat > Filtering](~chat.filtering) no longer have a default item. This will hopefully minimize user confusion.
* Changed: Blocked Badges also has a new description telling users that it isn't for hiding badges, with a link to the correct place to change badge visibility.
* Changed: Remove the New Link Tokenization experiment, making it enabled for all users.
* Changed: When navigating within the FFZ Control Center in a pop-out window, update the URL so that it can be shared to link to a specific settings page.
* Changed: Disable the websocket connection for users in the API Links experiment to reduce load on the socket cluster.
* Fixed: Bug with the FFZ Control Center failing to load if experiments haven't been populated correctly.
* Fixed: Badge Visibility not being populated when opening the FFZ Control Center on a page without chat.
* API Added: `<markdown />` now supports a link syntax for navigating to a new section of the FFZ Control Center.
* API Fixed: Better tokenization for settings paths. Brackets can now be used safely in embedded JSON.
* API Fixed: `deep_equals` and `shallow_object_equals` returning false when objects were otherwise equal but had keys in a different order.
2021-02-22 20:11:35 -05:00

1699 lines
No EOL
42 KiB
JavaScript
Raw 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.

'use strict';
// ============================================================================
// Chat
// ============================================================================
import dayjs from 'dayjs';
import Module from 'utilities/module';
import {createElement, ManagedStyle} from 'utilities/dom';
import {timeout, has, glob_to_regex, escape_regex, split_chars} from 'utilities/object';
import Badges from './badges';
import Emotes from './emotes';
import Emoji from './emoji';
import Overrides from './overrides';
import Room from './room';
import User from './user';
import * as TOKENIZERS from './tokenizers';
import * as RICH_PROVIDERS from './rich_providers';
import Actions from './actions';
export const SEPARATORS = '[\\s`~<>!-#%-\\x2A,-/:;\\x3F@\\x5B-\\x5D_\\x7B}\\u00A1\\u00A7\\u00AB\\u00B6\\u00B7\\u00BB\\u00BF\\u037E\\u0387\\u055A-\\u055F\\u0589\\u058A\\u05BE\\u05C0\\u05C3\\u05C6\\u05F3\\u05F4\\u0609\\u060A\\u060C\\u060D\\u061B\\u061E\\u061F\\u066A-\\u066D\\u06D4\\u0700-\\u070D\\u07F7-\\u07F9\\u0830-\\u083E\\u085E\\u0964\\u0965\\u0970\\u0AF0\\u0DF4\\u0E4F\\u0E5A\\u0E5B\\u0F04-\\u0F12\\u0F14\\u0F3A-\\u0F3D\\u0F85\\u0FD0-\\u0FD4\\u0FD9\\u0FDA\\u104A-\\u104F\\u10FB\\u1360-\\u1368\\u1400\\u166D\\u166E\\u169B\\u169C\\u16EB-\\u16ED\\u1735\\u1736\\u17D4-\\u17D6\\u17D8-\\u17DA\\u1800-\\u180A\\u1944\\u1945\\u1A1E\\u1A1F\\u1AA0-\\u1AA6\\u1AA8-\\u1AAD\\u1B5A-\\u1B60\\u1BFC-\\u1BFF\\u1C3B-\\u1C3F\\u1C7E\\u1C7F\\u1CC0-\\u1CC7\\u1CD3\\u2010-\\u2027\\u2030-\\u2043\\u2045-\\u2051\\u2053-\\u205E\\u207D\\u207E\\u208D\\u208E\\u2329\\u232A\\u2768-\\u2775\\u27C5\\u27C6\\u27E6-\\u27EF\\u2983-\\u2998\\u29D8-\\u29DB\\u29FC\\u29FD\\u2CF9-\\u2CFC\\u2CFE\\u2CFF\\u2D70\\u2E00-\\u2E2E\\u2E30-\\u2E3B\\u3001-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u3030\\u303D\\u30A0\\u30FB\\uA4FE\\uA4FF\\uA60D-\\uA60F\\uA673\\uA67E\\uA6F2-\\uA6F7\\uA874-\\uA877\\uA8CE\\uA8CF\\uA8F8-\\uA8FA\\uA92E\\uA92F\\uA95F\\uA9C1-\\uA9CD\\uA9DE\\uA9DF\\uAA5C-\\uAA5F\\uAADE\\uAADF\\uAAF0\\uAAF1\\uABEB\\uFD3E\\uFD3F\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE61\\uFE63\\uFE68\\uFE6A\\uFE6B\\uFF01-\\uFF03\\uFF05-\\uFF0A\\uFF0C-\\uFF0F\\uFF1A\\uFF1B\\uFF1F\\uFF20\\uFF3B-\\uFF3D\\uFF3F\\uFF5B\\uFF5D\\uFF5F-\\uFF65]';
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
const EMOTE_CHARS = /[ .,!]/;
export default class Chat extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('settings');
this.inject('i18n');
this.inject('tooltips');
this.inject('socket');
this.inject('experiments');
this.inject(Badges);
this.inject(Emotes);
this.inject(Emoji);
this.inject(Actions);
this.inject(Overrides);
this._link_info = {};
// Bind for JSX stuff
this.clickToReveal = this.clickToReveal.bind(this);
this.handleMentionClick = this.handleMentionClick.bind(this);
this.handleReplyClick = this.handleReplyClick.bind(this);
this.style = new ManagedStyle;
this.context = this.settings.context({});
this.rooms = {};
this.users = {};
this.room_ids = {};
this.user_ids = {};
this.tokenizers = {};
this.__tokenizers = [];
this.rich_providers = {};
this.__rich_providers = [];
// ========================================================================
// Settings
// ========================================================================
this.settings.add('debug.link-resolver.source', {
default: null,
ui: {
path: 'Debugging > Data Sources >> Links',
title: 'Link Resolver',
component: 'setting-select-box',
force_seen: true,
data: [
{value: null, title: 'Automatic'},
{value: 'dev', title: 'localhost'},
{value: 'test', title: 'API Test'},
{value: 'prod', title: 'API Production' },
{value: 'socket', title: 'Socket Cluster (Deprecated)'}
]
},
changed: () => this.clearLinkCache()
});
this.settings.addUI('debug.link-resolver.test', {
path: 'Debugging > Data Sources >> Links',
component: 'link-tester',
getChat: () => this,
force_seen: true
});
this.settings.add('chat.font-size', {
default: 13,
ui: {
path: 'Chat > Appearance >> General',
title: 'Font Size',
description: "How large should text in chat be, in pixels. This may be affected by your browser's zoom and font size settings.",
component: 'setting-text-box',
process(val) {
val = parseInt(val, 10);
if ( isNaN(val) || ! isFinite(val) || val <= 0 )
return 12;
return val;
}
}
});
this.settings.add('chat.font-family', {
default: '',
ui: {
path: 'Chat > Appearance >> General',
title: 'Font Family',
description: 'Set the font used for displaying chat messages.',
component: 'setting-text-box'
}
});
this.settings.add('chat.lines.emote-alignment', {
default: 0,
ui: {
path: 'Chat > Appearance >> Chat Lines',
title: 'Emote Alignment',
description: 'Change how emotes are positioned in chat, potentially making messages taller in order to avoid having emotes overlap.',
component: 'setting-select-box',
data: [
{value: 0, title: 'Standard'},
{value: 1, title: 'Padded'},
{value: 2, title: 'Baseline (BTTV-Like)'}
]
}
});
this.settings.add('chat.rich.enabled', {
default: true,
ui: {
path: 'Chat > Appearance >> Rich Content',
title: 'Display rich content in chat.',
description: 'This displays rich content blocks for things like linked clips and videos.',
component: 'setting-check-box'
}
});
this.settings.add('chat.rich.hide-tokens', {
default: true,
ui: {
path: 'Chat > Appearance >> Rich Content',
title: 'Hide matching links for rich content.',
component: 'setting-check-box'
}
});
this.settings.add('chat.rich.all-links', {
default: false,
ui: {
path: 'Chat > Appearance >> Rich Content',
title: 'Display rich content embeds for all links.',
description: '*Streamers: Please be aware that this is a potential vector for NSFW imagery via thumbnails, so be mindful when capturing chat with this enabled.*',
component: 'setting-check-box',
extra: {
component: 'chat-rich-example',
getChat: () => this
}
}
});
this.settings.add('chat.rich.minimum-level', {
default: 0,
ui: {
path: 'Chat > Appearance >> Rich Content',
title: 'Required User Level',
description: 'Only display rich content embeds on messages posted by users with this level or higher.',
component: 'setting-select-box',
data: [
{value: 4, title: 'Broadcaster'},
{value: 3, title: 'Moderator'},
{value: 2, title: 'VIP'},
{value: 1, title: 'Subscriber'},
{value: 0, title: 'Everyone'}
]
}
});
this.settings.add('chat.scrollback-length', {
default: 150,
ui: {
path: 'Chat > Behavior >> General',
title: 'Scrollback Length',
description: 'Keep up to this many lines in chat. Setting this too high will create lag.',
component: 'setting-text-box',
process(val) {
val = parseInt(val, 10);
if ( isNaN(val) || ! isFinite(val) || val < 1 )
val = 150;
return val;
}
}
});
this.settings.add('chat.filtering.click-to-reveal', {
default: false,
ui: {
path: 'Chat > Filtering >> Behavior',
title: 'Click to reveal deleted terms.',
component: 'setting-check-box'
}
});
this.settings.add('chat.filtering.deleted-style', {
default: 1,
ui: {
path: 'Chat > Behavior >> Deleted Messages',
title: 'Detailed Message Style',
description: 'This style will be applied to deleted messages showed in Detailed rendering mode to differentiate them from normal chat messages.',
component: 'setting-select-box',
data: [
{value: 0, title: 'Faded'},
{value: 1, title: 'Faded, Line Through'},
{value: 2, title: 'Line Through'},
{value: 3, title: 'No Change'}
]
}
});
this.settings.add('chat.filtering.display-deleted', {
default: false,
ui: {
path: 'Chat > Behavior >> Deleted Messages',
sort: -1,
title: 'Rendering Mode',
description: 'This, when set, overrides the `Deleted Messages` mode selected in Twitch chat settings, which is normally only accessible for moderators. Brief hides messages entirely and shows a notice in chat that a number of messages were hidden. Detailed shows the contents of the message. Legacy shows `<message deleted>` with click to reveal.',
component: 'setting-select-box',
data: [
{value: false, title: 'Do Not Override'},
{value: 'BRIEF', title: 'Brief'},
{value: 'DETAILED', title: 'Detailed'},
{value: 'LEGACY', title: 'Legacy'}
]
}
});
this.settings.add('chat.filtering.display-mod-action', {
default: 1,
ui: {
path: 'Chat > Behavior >> Deleted Messages',
title: 'Display Reason',
component: 'setting-select-box',
data: [
{value: 0, title: 'Never'},
{value: 1, title: 'In Detailed Mode'},
{value: 2, title: 'Always'}
]
}
});
this.settings.add('chat.automod.delete-messages', {
default: true,
ui: {
path: 'Chat > Filtering >> AutoMod Filters @{"description": "Extra configuration for Twitch\'s native `Chat Filters`."}',
title: 'Mark messages as deleted if they contain filtered phrases.',
component: 'setting-check-box'
}
});
this.settings.add('chat.automod.remove-messages', {
default: true,
ui: {
path: 'Chat > Filtering >> AutoMod Filters',
title: 'Remove messages entirely if they contain filtered phrases.',
component: 'setting-check-box'
}
});
this.settings.add('chat.automod.run-as-mod', {
default: false,
ui: {
path: 'Chat > Filtering >> AutoMod Filters',
title: 'Use Chat Filters as a moderator.',
description: 'By default, Twitch\'s Chat Filters feature does not function for moderators. This overrides that behavior.',
component: 'setting-check-box'
}
});
this.settings.add('chat.filtering.process-own', {
default: false,
ui: {
path: 'Chat > Filtering >> Behavior',
title: 'Filter your own messages.',
component: 'setting-check-box'
}
});
this.settings.add('chat.filtering.ignore-clear', {
default: false,
ui: {
path: 'Chat > Behavior >> Deleted Messages',
title: 'Do not Clear Chat when commanded to.',
component: 'setting-check-box'
}
});
this.settings.add('chat.filtering.remove-deleted', {
default: 1,
ui: {
path: 'Chat > Behavior >> Deleted Messages',
title: 'Remove deleted messages from chat.',
description: 'Deleted messages will be removed from chat entirely. This setting is not recommended for moderators.',
component: 'setting-select-box',
data: [
{value: 0, title: 'Do Not Remove'},
{value: 1, title: 'Remove Unseen (Default)'},
{value: 2, title: 'Remove Unseen as Moderator'},
{value: 3, title: 'Remove All'}
]
}
});
this.settings.add('chat.delay', {
default: -1,
ui: {
path: 'Chat > Behavior >> General',
title: 'Artificial Chat Delay',
description: 'Delay the appearance of chat messages to allow for moderation before you see them.',
component: 'setting-select-box',
data: [
{value: -1, title: 'Default Delay (Room Specific; Non-Mod Only)'},
{value: 0, title: 'No Delay'},
{value: 300, title: 'Minor (Bot Moderation; 0.3s)'},
{value: 1200, title: 'Normal (Human Moderation; 1.2s)'},
{value: 5000, title: 'Large (Spoiler Removal / Slow Mods; 5s)'},
{value: 10000, title: 'Extra Large (10s)'},
{value: 15000, title: 'Extremely Large (15s)'},
{value: 20000, title: 'Mods Asleep; Delay Chat (20s)'},
{value: 30000, title: 'Half a Minute (30s)'},
{value: 60000, title: 'Why??? (1m)'},
{value: 788400000000, title: 'The CBenni Option (Literally 25 Years)'}
]
}
});
this.settings.add('chat.filtering.highlight-basic-users', {
default: [],
type: 'array_merge',
always_inherit: true,
ui: {
path: 'Chat > Filtering >> Highlight Users',
component: 'basic-terms',
colored: true,
words: false
}
});
this.settings.add('chat.filtering.highlight-basic-users--color-regex', {
requires: ['chat.filtering.highlight-basic-users'],
equals: 'requirements',
process(ctx) {
const val = ctx.get('chat.filtering.highlight-basic-users');
if ( ! val || ! val.length )
return null;
const colors = new Map;
for(const item of val) {
const c = item.c || null,
t = item.t;
let v = item.v;
if ( t === 'glob' )
v = glob_to_regex(v);
else if ( t !== 'raw' )
v = escape_regex(v);
if ( ! v || ! v.length )
continue;
try {
new RegExp(v);
} catch(err) {
continue;
}
if ( colors.has(c) )
colors.get(c).push(v);
else {
colors.set(c, [v]);
}
}
for(const [key, list] of colors) {
colors.set(key, new RegExp(`^(?:${list.join('|')})$`, 'gi'));
}
return colors;
}
});
this.settings.add('chat.filtering.highlight-basic-users-blocked', {
default: [],
type: 'array_merge',
always_inherit: true,
ui: {
path: 'Chat > Filtering >> Blocked Users',
component: 'basic-terms',
removable: true,
words: false
}
});
this.settings.add('chat.filtering.highlight-basic-users-blocked--regex', {
requires: ['chat.filtering.highlight-basic-users-blocked'],
equals: 'requirements',
process(ctx) {
const val = ctx.get('chat.filtering.highlight-basic-users-blocked');
if ( ! val || ! val.length )
return null;
const out = [[], []];
for(const item of val) {
const t = item.t;
let v = item.v;
if ( t === 'glob' )
v = glob_to_regex(v);
else if ( t !== 'raw' )
v = escape_regex(v);
if ( ! v || ! v.length )
continue;
out[item.remove ? 1 : 0].push(v);
}
return out.map(data => {
if ( ! data.length )
return null;
return new RegExp(`^(?:${data.join('|')})$`, 'gi');
});
}
});
this.settings.add('chat.filtering.highlight-basic-badges', {
default: [],
type: 'array_merge',
always_inherit: true,
ui: {
path: 'Chat > Filtering >> Highlight Badges',
component: 'badge-highlighting',
colored: true,
data: () => this.badges.getSettingsBadges()
}
});
this.settings.add('chat.filtering.highlight-basic-badges--colors', {
requires: ['chat.filtering.highlight-basic-badges'],
equals: 'requirements',
process(ctx) {
const val = ctx.get('chat.filtering.highlight-basic-badges');
if ( ! val || ! val.length )
return null;
const colors = new Map;
for(const item of val) {
const c = item.c || null,
v = item.v;
if ( ! colors.has(v) )
colors.set(v, c);
}
return colors;
}
});
this.settings.add('chat.filtering.highlight-basic-badges-blocked', {
default: [],
type: 'array_merge',
always_inherit: true,
ui: {
path: 'Chat > Filtering >> Blocked Badges @{"description": "**Note:** This section is for filtering messages out of chat from users with specific badges. If you wish to hide a badge, go to [Chat > Badges >> Visibility](~chat.badges.tabs.visibility)."}',
component: 'badge-highlighting',
removable: true,
data: () => this.badges.getSettingsBadges()
}
});
this.settings.add('chat.filtering.highlight-basic-badges-blocked--list', {
requires: ['chat.filtering.highlight-basic-badges-blocked'],
equals: 'requirements',
process(ctx) {
const val = ctx.get('chat.filtering.highlight-basic-badges-blocked');
if ( ! val || ! val.length )
return null;
const out = [[], []];
for(const item of val)
if ( item.v )
out[item.remove ? 1 : 0].push(item.v);
if ( ! out[0].length && ! out[1].length )
return null;
return out;
}
});
this.settings.add('chat.filtering.highlight-basic-terms', {
default: [],
type: 'array_merge',
always_inherit: true,
ui: {
path: 'Chat > Filtering >> Highlight Terms',
component: 'basic-terms',
colored: true
}
});
this.settings.add('chat.filtering.highlight-basic-terms--color-regex', {
requires: ['chat.filtering.highlight-basic-terms'],
equals: 'requirements',
process(ctx) {
const val = ctx.get('chat.filtering.highlight-basic-terms');
if ( ! val || ! val.length )
return null;
const colors = new Map;
for(const item of val) {
const c = item.c || null,
t = item.t;
let v = item.v, word = true;
if ( t === 'glob' )
v = glob_to_regex(v);
else if ( t === 'raw' )
word = false;
else if ( t !== 'regex' )
v = escape_regex(v);
if ( ! v || ! v.length )
continue;
try {
new RegExp(v);
} catch(err) {
continue;
}
if ( colors.has(c) )
colors.get(c)[word ? 0 : 1].push(v);
else {
const vals = [[],[]];
colors.set(c, vals);
vals[word ? 0 : 1].push(v);
}
}
for(const [key, list] of colors) {
if ( list[0].length )
list[1].push(`(^|.*?${SEPARATORS})(?:${list[0].join('|')})(?=$|${SEPARATORS})`);
colors.set(key, new RegExp(list[1].join('|'), 'gi'));
}
return colors;
}
});
this.settings.add('chat.filtering.highlight-basic-blocked', {
default: [],
type: 'array_merge',
always_inherit: true,
ui: {
path: 'Chat > Filtering >> Blocked Terms',
component: 'basic-terms',
removable: true
}
});
this.settings.add('chat.filtering.highlight-basic-blocked--regex', {
requires: ['chat.filtering.highlight-basic-blocked'],
equals: 'requirements',
process(ctx) {
const val = ctx.get('chat.filtering.highlight-basic-blocked');
if ( ! val || ! val.length )
return null;
const out = [
[[], []],
[[], []]
];
for(const item of val) {
const t = item.t;
let v = item.v, word = true;
if ( t === 'glob' )
v = glob_to_regex(v);
else if ( t === 'raw' )
word = false;
else if ( t !== 'regex' )
v = escape_regex(v);
if ( ! v || ! v.length )
continue;
out[item.remove ? 1 : 0][word ? 0 : 1].push(v);
}
return out.map(data => {
if ( data[0].length )
data[1].push(`(^|.*?${SEPARATORS})(?:${data[0].join('|')})(?=$|${SEPARATORS})`);
return data[1].length ? new RegExp(data[1].join('|'), 'gi') : null;
});
}
});
this.settings.add('chat.filtering.clickable-mentions', {
default: false,
ui: {
component: 'setting-check-box',
path: 'Chat > Viewer Cards >> Behavior',
title: 'Enable opening viewer cards by clicking mentions in chat.'
}
});
this.settings.add('chat.filtering.color-mentions', {
default: false,
ui: {
component: 'setting-check-box',
path: 'Chat > Filtering >> Appearance',
title: 'Display mentions in chat with username colors.',
description: '**Note:** Not compatible with color overrides as mentions do not include user IDs.'
}
});
this.settings.add('chat.filtering.bold-mentions', {
default: true,
ui: {
component: 'setting-check-box',
path: 'Chat > Filtering >> Appearance',
title: 'Display mentions in chat with a bold font.'
}
});
this.settings.add('chat.filtering.mention-color', {
default: '',
ui: {
path: 'Chat > Filtering >> Appearance',
title: 'Custom Highlight Color',
component: 'setting-color-box',
description: 'If this is set, highlighted messages with no default color set will use this color rather than red.'
}
});
this.settings.add('chat.filtering.highlight-mentions', {
default: false,
ui: {
path: 'Chat > Filtering >> Appearance',
title: 'Highlight messages that mention you.',
component: 'setting-check-box'
}
});
this.settings.add('chat.filtering.highlight-tokens', {
default: false,
ui: {
path: 'Chat > Filtering >> Appearance',
title: 'Highlight matched words in chat.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.images', {
default: true,
ui: {
path: 'Chat > Tooltips >> General @{"sort": -1}',
title: 'Display images in tooltips.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.badge-images', {
default: true,
requires: ['tooltip.images'],
process(ctx, val) {
return ctx.get('tooltip.images') ? val : false
},
ui: {
path: 'Chat > Tooltips >> Badges',
title: 'Display large images of badges.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.emote-sources', {
default: true,
ui: {
path: 'Chat > Tooltips >> Emotes',
title: 'Display known sources.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.emote-images', {
default: true,
requires: ['tooltip.images'],
process(ctx, val) {
return ctx.get('tooltip.images') ? val : false
},
ui: {
path: 'Chat > Tooltips >> Emotes',
title: 'Display large images of emotes.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.rich-links', {
default: true,
ui: {
sort: -1,
path: 'Chat > Tooltips >> Links',
title: 'Display rich tooltips for links.',
component: 'setting-check-box',
extra: {
component: 'chat-tooltip-example'
}
}
});
this.settings.add('tooltip.link-interaction', {
default: true,
ui: {
path: 'Chat > Tooltips >> Links',
title: 'Allow interaction with supported link tooltips.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.link-images', {
default: true,
requires: ['tooltip.images'],
process(ctx, val) {
return ctx.get('tooltip.images') ? val : false
},
ui: {
path: 'Chat > Tooltips >> Links',
title: 'Display images for links.',
component: 'setting-check-box'
}
});
this.settings.add('tooltip.link-nsfw-images', {
default: false,
ui: {
path: 'Chat > Tooltips >> Links',
title: 'Display potentially NSFW images.',
description: 'When enabled, FrankerFaceZ will include images that are tagged as unsafe or that are not rated.',
component: 'setting-check-box'
}
});
this.settings.add('chat.adjustment-mode', {
default: 1,
ui: {
path: 'Chat > Appearance >> Colors',
title: 'Adjustment',
description: 'Alter user colors to ensure that they remain readable.',
component: 'setting-select-box',
data: [
{value: -1, title: 'No Color'},
{value: 0, title: 'Unchanged'},
{value: 1, title: 'HSL Luma'},
{value: 2, title: 'Luv Luma'},
{value: 3, title: 'HSL Loop (BTTV-Like)'},
{value: 4, title: 'RGB Loop (Deprecated)'}
]
}
});
this.settings.add('chat.adjustment-contrast', {
default: 4.5,
ui: {
path: 'Chat > Appearance >> Colors',
title: 'Minimum Contrast',
description: 'Set the minimum contrast ratio used by Luma adjustments when determining readability.',
component: 'setting-text-box',
process(val) {
return parseFloat(val)
}
}
});
this.settings.add('chat.bits.stack', {
default: 0,
ui: {
path: 'Chat > Bits and Cheering >> Appearance',
title: 'Cheer Stacking',
description: 'Collect all the cheers in a message into a single cheer at the start of the message.',
component: 'setting-select-box',
data: [
{value: 0, title: 'Disabled'},
{value: 1, title: 'Grouped by Type'},
{value: 2, title: 'All in One'}
]
}
});
this.settings.add('chat.bits.animated', {
default: true,
ui: {
path: 'Chat > Bits and Cheering >> Appearance',
title: 'Display animated cheers.',
component: 'setting-check-box'
}
});
const ts = new Date(0).toLocaleTimeString().toUpperCase(),
default_24 = ts.lastIndexOf('PM') === -1 && ts.lastIndexOf('AM') === -1;
this.settings.add('chat.timestamp-format', {
default: default_24 ? 'H:mm' : 'h:mm',
ui: {
path: 'Chat > Appearance >> Chat Lines',
title: 'Timestamp Format',
component: 'setting-combo-box',
description: 'Timestamps are formatted using the [Day.js](https://github.com/iamkun/dayjs#readme) library. More details about formatting strings [can be found here](https://github.com/iamkun/dayjs/blob/HEAD/docs/en/API-reference.md#list-of-all-available-formats)',
data: [
{value: 'h:mm', title: '12 Hour'},
{value: 'h:mm:ss', title: '12 Hour with Seconds'},
{value: 'H:mm', title: '24 Hour'},
{value: 'H:mm:ss', title: '24 Hour with Seconds'},
{value: 'hh:mm', title: 'Padded'},
{value: 'hh:mm:ss', title: 'Padded with Seconds'},
{value: 'HH:mm', title: 'Padded 24 Hour'},
{value: 'HH:mm:ss', title: 'Padded 24 Hour with Seconds'},
]
}
});
this.context.on('changed:theme.is-dark', () => {
for(const room of this.iterateRooms())
room.buildBitsCSS();
});
this.context.on('changed:chat.bits.animated', () => {
for(const room of this.iterateRooms())
room.buildBitsCSS();
});
this.context.on('changed:chat.filtering.color-mentions', async val => {
if ( val )
await this.createColorCache();
else
this.color_cache = null;
this.emit(':update-lines');
});
}
async createColorCache() {
const LRUCache = await require(/* webpackChunkName: 'utils' */ 'mnemonist/lru-cache');
this.color_cache = new LRUCache(150);
}
generateLog() {
const out = ['chat settings', '-------------------------------------------------------------------------------'];
for(const [key, value] of this.context.__cache.entries())
out.push(`${key}: ${JSON.stringify(value)}`);
return out.join('\n');
}
onEnable() {
if ( this.context.get('chat.filtering.color-mentions') )
this.createColorCache().then(() => this.emit(':update-lines'));
for(const key in TOKENIZERS)
if ( has(TOKENIZERS, key) )
this.addTokenizer(TOKENIZERS[key]);
for(const key in RICH_PROVIDERS)
if ( has(RICH_PROVIDERS, key) )
this.addRichProvider(RICH_PROVIDERS[key]);
}
getUser(id, login, no_create, no_login, error = false) {
let user;
if ( id && typeof id === 'number' )
id = `${id}`;
if ( id && this.user_ids[id] )
user = this.user_ids[id];
else if ( login && this.users[login] && ! no_login )
user = this.users[login];
if ( user && user.destroyed )
user = null;
if ( ! user ) {
if ( no_create )
return null;
else
user = new User(this, null, id, login);
}
if ( id && id !== user.id ) {
// If the ID isn't what we expected, something is very wrong here.
// Blame name changes.
if ( user.id ) {
this.log.warn(`Data mismatch for user #${id} -- Stored ID: ${user.id} -- Login: ${login} -- Stored Login: ${user.login}`);
if ( error )
throw new Error('id mismatch');
// Remove the old reference if we're going with this.
if ( this.user_ids[user.id] === user )
this.user_ids[user.id] = null;
}
// Otherwise, we're just here to set the ID.
user._id = id;
this.user_ids[id] = user;
}
if ( login ) {
const other = this.users[login];
if ( other ) {
if ( other !== user && ! no_login ) {
// If the other has an ID, something weird happened. Screw it
// and just take over.
if ( other.id )
this.users[login] = user;
else {
user.merge(other);
other.destroy(true);
}
}
} else
this.users[login] = user;
}
return user;
}
getRoom(id, login, no_create, no_login, error = false) {
let room;
if ( id && typeof id === 'number' )
id = `${id}`;
if ( id && this.room_ids[id] )
room = this.room_ids[id];
else if ( login && this.rooms[login] && ! no_login )
room = this.rooms[login];
if ( room && room.destroyed )
room = null;
if ( ! room ) {
if ( no_create )
return null;
else
room = new Room(this, id, login);
}
if ( id && id !== room.id ) {
// If the ID isn't what we expected, something is very wrong here.
// Blame name changes. Or React not being atomic.
if ( room.id ) {
this.log.warn(`Data mismatch for room #${id} -- Stored ID: ${room.id} -- Login: ${login} -- Stored Login: ${room.login}`);
if ( error )
throw new Error('id mismatch');
// Remove the old reference if we're going with this.
if ( this.room_ids[room.id] === room )
this.room_ids[room.id] = null;
}
// Otherwise, we're just here to set the ID.
room._id = id;
this.room_ids[id] = room;
}
if ( login ) {
const other = this.rooms[login];
if ( other ) {
if ( other !== room && ! no_login ) {
// If the other has an ID, something weird happened. Screw it
// and just take over.
if ( other.id )
this.rooms[login] = room;
else {
room.merge(other);
other.destroy(true);
}
}
} else
this.rooms[login] = room;
}
return room;
}
*iterateRooms() {
const visited = new Set;
for(const id in this.room_ids)
if ( has(this.room_ids, id) ) {
const room = this.room_ids[id];
if ( room && ! room.destroyed ) {
visited.add(room);
yield room;
}
}
for(const login in this.rooms)
if ( has(this.rooms, login) ) {
const room = this.rooms[login];
if ( room && ! room.destroyed && ! visited.has(room) )
yield room;
}
}
handleReplyClick(event) {
const target = event.target,
fine = this.resolve('site.fine');
if ( ! target || ! fine )
return;
const chat = fine.searchParent(target, n => n.props && n.props.reply && n.setOPCardTray);
if ( chat )
chat.setOPCardTray(chat.props.reply);
}
handleMentionClick(event) {
if ( ! this.context.get('chat.filtering.clickable-mentions') )
return;
const target = event.target,
ds = target && target.dataset;
if ( ! ds || ! ds.login )
return;
const fine = this.resolve('site.fine');
if ( ! fine )
return;
const chat = fine.searchParent(target, n => n.props && n.props.onUsernameClick);
if ( ! chat )
return;
chat.props.onUsernameClick(
ds.login,
undefined, undefined,
event.currentTarget.getBoundingClientRect().bottom
);
}
clickToReveal(event) {
const target = event.target;
if ( target ) {
if ( target._ffz_visible )
target.textContent = '×××';
else if ( ! this.context.get('chat.filtering.click-to-reveal') )
return;
else if ( target.dataset )
target.textContent = target.dataset.text;
target._ffz_visible = ! target._ffz_visible;
}
}
standardizeWhisper(msg) { // eslint-disable-line class-methods-use-this
if ( ! msg )
return msg;
if ( msg._ffz_message )
return msg._ffz_message;
const emotes = {},
is_action = msg.content.startsWith('/me '),
offset = is_action ? 4 : 0,
out = msg._ffz_message = {
user: msg.from,
message: msg.content.slice(offset),
is_action,
ffz_emotes: emotes,
timestamp: msg.sentAt && msg.sentAt.getTime(),
deleted: false
};
out.user.color = out.user.chatColor;
if ( Array.isArray(msg.emotes) && msg.emotes.length )
for(const emote of msg.emotes) {
const id = emote.emoteID,
em = emotes[id] = emotes[id] || [];
em.push({
startIndex: emote.from - offset,
endIndex: emote.to - offset
});
}
return out;
}
getUserLevel(msg) { // eslint-disable-line class-methods-use-this
if ( ! msg || ! msg.user )
return 0;
if ( msg.user.login === msg.roomLogin || (msg.badges && msg.badges.broadcaster) )
return 4;
if ( ! msg.badges )
return 0;
if ( msg.badges.moderator )
return 3;
if ( msg.badges.vip )
return 2;
if ( msg.badges.subscriber )
return 1;
return 0;
}
tokenizeReply(reply) {
if ( ! reply )
return null;
return [
{
type: 'reply',
text: reply.parentDisplayName,
color: this.color_cache ? this.color_cache.get(reply.parentUserLogin) : null,
recipient: reply.parentUserLogin
},
{
type: 'text',
text: ' '
}
];
}
standardizeMessage(msg) { // eslint-disable-line class-methods-use-this
if ( ! msg )
return msg;
// Standardize User
if ( msg.sender && ! msg.user )
msg.user = msg.sender;
if ( msg.from && ! msg.user )
msg.user = msg.from;
let user = msg.user;
if ( ! user )
user = msg.user = {};
const ext = msg.extension || {};
user.color = user.color || user.chatColor || ext.chatColor || null;
user.type = user.type || user.userType || null;
user.id = user.id || user.userID || null;
user.login = user.login || user.userLogin || null;
user.displayName = user.displayName || user.userDisplayName || user.login || ext.displayName;
user.isIntl = user.login && user.displayName && user.displayName.trim().toLowerCase() !== user.login;
if ( this.color_cache && user.color )
this.color_cache.set(user.login, user.color);
// Standardize Message Content
if ( ! msg.message && msg.messageParts )
this.detokenizeMessage(msg);
if ( msg.content && ! msg.message ) {
if ( msg.content.fragments )
this.detokenizeContent(msg);
else
msg.message = msg.content.text;
}
// Standardize Emotes
if ( ! msg.ffz_emotes )
this.standardizeEmotes(msg);
// Standardize Badges
if ( ! msg.badges && user.displayBadges ) {
const b = msg.badges = {};
for(const item of user.displayBadges)
b[item.setID] = item.version;
}
if ( ! msg.badges && ext.displayBadges ) {
const b = msg.badges = {};
for(const item of ext.displayBadges)
b[item.setID] = item.version;
}
// Standardize Timestamp
if ( ! msg.timestamp && msg.sentAt )
msg.timestamp = new Date(msg.sentAt).getTime();
// Standardize Deletion
if ( msg.deletedAt !== undefined )
msg.deleted = !!msg.deletedAt;
return msg;
}
standardizeEmotes(msg) { // eslint-disable-line class-methods-use-this
if ( msg.emotes && msg.message ) {
const emotes = {},
chars = split_chars(msg.message);
let offset = 0;
if ( msg.message && msg.messageBody && msg.message !== msg.messageBody )
offset = chars.length - split_chars(msg.messageBody).length;
for(const key in msg.emotes)
if ( has(msg.emotes, key) ) {
const raw_emote = msg.emotes[key];
if ( Array.isArray(raw_emote) )
return msg.ffz_emotes = msg.emotes;
const em = emotes[raw_emote.id] = emotes[raw_emote.id] || [];
let idx = raw_emote.startIndex + 1 + offset;
while(idx < chars.length) {
if ( EMOTE_CHARS.test(chars[idx]) )
break;
idx++;
}
em.push({
startIndex: raw_emote.startIndex + offset,
endIndex: idx - 1
});
}
msg.ffz_emotes = emotes;
return;
}
if ( msg.messageParts )
this.detokenizeMessage(msg, true);
else if ( msg.content && msg.content.fragments )
this.detokenizeContent(msg, true);
}
detokenizeContent(msg, emotes_only = false) { // eslint-disable-line class-methods-use-this
const out = [],
parts = msg.content.fragments,
l = parts.length,
emotes = {};
let idx = 0, ret, first = true;
for(let i=0; i < l; i++) {
const part = parts[i],
content = part.content,
ct = content && content.__typename;
ret = part.text;
if ( ct === 'Emote' ) {
const id = content.emoteID,
em = emotes[id] = emotes[id] || [];
em.push({startIndex: idx, endIndex: idx + ret.length - 1});
}
if ( ret && ret.length ) {
if ( first && ret.startsWith('/me ') ) {
msg.is_action = true;
ret = ret.slice(4);
}
idx += split_chars(ret).length;
out.push(ret);
}
first = false;
}
if ( ! emotes_only )
msg.message = out.join('');
msg.ffz_emotes = emotes;
return msg;
}
detokenizeMessage(msg, emotes_only = false) { // eslint-disable-line class-methods-use-this
const out = [],
parts = msg.messageParts,
l = parts.length,
emotes = {};
let idx = 0, ret, last_type = null, bits = 0;
for(let i=0; i < l; i++) {
const part = parts[i],
content = part.content;
if ( ! content )
continue;
if ( typeof content === 'string' )
ret = content;
else if ( content.recipient )
ret = `@${content.recipient}`;
else if ( content.url )
ret = content.url;
else if ( content.cheerAmount ) {
bits += content.cheerAmount;
ret = `${content.alt}${content.cheerAmount}`;
} else if ( content.images ) {
const url = (content.images.themed ? content.images.dark : content.images.sources);
let id = content.emoteID;
if ( ! id ) {
const match = url && (
/\/emoticons\/v1\/(\d+)\/[\d.]+$/.exec(url['1x']) ||
/\/emoticons\/v2\/(\d+)\//.exec(url['1x'])
);
id = match && match[1];
}
ret = content.alt;
if ( id ) {
const em = emotes[id] = emotes[id] || [],
offset = last_type > 0 ? 1 : 0;
em.push({startIndex: idx + offset, endIndex: idx + ret.length - 1});
}
if ( last_type > 0 )
ret = ` ${ret}`;
} else
continue;
if ( ret ) {
idx += split_chars(ret).length;
last_type = part.type;
out.push(ret)
}
}
if ( ! emotes_only )
msg.message = out.join('');
msg.bits = bits;
msg.ffz_emotes = emotes;
return msg;
}
formatTime(time) {
if (!( time instanceof Date ))
time = new Date(time);
const fmt = this.context.get('chat.timestamp-format'),
d = dayjs(time);
try {
return d.locale(this.i18n.locale).format(fmt);
} catch(err) {
// If the locale isn't loaded, this can fail.
return d.format(fmt);
}
}
addTokenizer(tokenizer) {
const type = tokenizer.type;
this.tokenizers[type] = tokenizer;
if ( tokenizer.priority == null )
tokenizer.priority = 0;
if ( tokenizer.tooltip ) {
const tt = tokenizer.tooltip;
const tk = this.tooltips.types[type] = tt.bind(this);
for(const i of ['interactive', 'delayShow', 'delayHide', 'onShow', 'onHide'])
tk[i] = typeof tt[i] === 'function' ? tt[i].bind(this) : tt[i];
}
this.__tokenizers.push(tokenizer);
this.__tokenizers.sort((a, b) => {
if ( a.priority > b.priority ) return -1;
if ( a.priority < b.priority ) return 1;
return a.type < b.type;
});
}
addRichProvider(provider) {
const type = provider.type;
this.rich_providers[type] = provider;
if ( provider.priority == null )
provider.priority = 0;
this.__rich_providers.push(provider);
this.__rich_providers.sort((a,b) => {
if ( a.priority > b.priority ) return -1;
if ( a.priority < b.priority ) return 1;
return a.type < b.type;
});
}
tokenizeString(message, msg) {
let tokens = [{type: 'text', text: message}];
for(const tokenizer of this.__tokenizers)
tokens = tokenizer.process.call(this, tokens, msg);
return tokens;
}
pluckRichContent(tokens, msg) { // eslint-disable-line class-methods-use-this
if ( ! this.context.get('chat.rich.enabled') || this.context.get('chat.rich.minimum-level') > this.getUserLevel(msg) )
return;
const providers = this.__rich_providers;
for(const token of tokens) {
for(const provider of providers)
if ( provider.test.call(this, token, msg) ) {
token.hidden = this.context.get('chat.rich.hide-tokens') && provider.hide_token;
return provider.process.call(this, token);
}
}
}
tokenizeMessage(msg, user) {
if ( msg.content && ! msg.message )
msg.message = msg.content.text;
if ( msg.sender && ! msg.user )
msg.user = msg.sender;
if ( ! msg.message )
return [];
let tokens = [{type: 'text', text: msg.message}];
for(const tokenizer of this.__tokenizers)
tokens = tokenizer.process.call(this, tokens, msg, user);
return tokens;
}
renderTokens(tokens, e, reply) {
if ( ! e )
e = createElement;
const out = [],
tokenizers = this.tokenizers,
l = tokens.length;
for(let i=0; i < l; i++) {
const token = tokens[i],
type = token.type,
tk = tokenizers[type];
if ( token.hidden )
continue;
let res;
// If we have a reply, skip the initial mention.
if ( reply && i === 0 && type === 'mention' && token.recipient && token.recipient === reply.parentUserLogin )
continue;
if ( type === 'text' )
res = e('span', {
className: 'text-fragment',
'data-a-target': 'chat-message-text'
}, token.text);
else if ( tk )
res = tk.render.call(this, token, e, reply);
else
res = e('em', {
className: 'ffz-unknown-token ffz-tooltip',
'data-tooltip-type': 'json',
'data-data': JSON.stringify(token, null, 2)
}, `[unknown token: ${type}]`)
if ( res )
out.push(res);
}
return out;
}
// ====
// Twitch Crap
// ====
clearLinkCache(url) {
if ( url ) {
const info = this._link_info[url];
if ( ! info[0] ) {
for(const pair of info[2])
pair[1]();
}
this._link_info[url] = null;
this.emit(':update-link-resolver', url);
return;
}
const old = this._link_info;
this._link_info = {};
for(const info of Object.values(old)) {
if ( ! info[0] ) {
for(const pair of info[2])
pair[1]();
}
}
this.emit(':update-link-resolver');
}
get_link_info(url, no_promises, refresh = false) {
let info = this._link_info[url];
const expires = info && info[1];
if ( (info && info[0] && refresh) || (expires && Date.now() > expires) )
info = this._link_info[url] = null;
if ( info && info[0] )
return no_promises ? info[2] : Promise.resolve(info[2]);
if ( no_promises )
return null;
else if ( info )
return new Promise((resolve, reject) => info[2].push([resolve, reject]))
return new Promise((resolve, reject) => {
info = this._link_info[url] = [false, null, [[resolve, reject]]];
const handle = (success, data) => {
data = this.fixLinkInfo(data);
const callbacks = ! info[0] && info[2];
info[0] = true;
info[1] = Date.now() + 120000;
info[2] = success ? data : null;
if ( callbacks )
for(const cbs of callbacks)
cbs[success ? 0 : 1](data);
}
let provider = this.settings.get('debug.link-resolver.source');
if ( provider == null )
provider = this.experiments.getAssignment('api_links') ? 'test' : 'socket';
if ( provider === 'socket' ) {
timeout(this.socket.call('get_link', url), 15000)
.then(data => handle(true, data))
.catch(err => handle(false, err));
} else {
const host = provider === 'dev' ? 'https://localhost:8002/' :
provider === 'test' ? 'https://api-test.frankerfacez.com/v2/link' :
'https://api.frankerfacez.com/v2/link';
timeout(fetch(`${host}?url=${encodeURIComponent(url)}`).then(r => r.json()), 15000)
.then(data => handle(true, data))
.catch(err => handle(false, err));
}
});
}
fixLinkInfo(data) {
if ( data.error && data.message )
data.error = data.message;
if ( data.error )
data = {
v: 5,
title: this.i18n.t('card.error', 'An error occurred.'),
description: data.error,
short: {
type: 'header',
image: {type: 'image', url: ERROR_IMAGE},
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
subtitle: data.error
}
}
if ( data.v < 5 && ! data.short && ! data.full && (data.title || data.desc_1 || data.desc_2) ) {
const image = data.preview || data.image;
data = {
v: 5,
short: {
type: 'header',
image: image ? {
type: 'image',
url: image,
sfw: data.image_safe ?? false,
} : null,
title: data.title,
subtitle: data.desc_1,
extra: data.desc_2
}
}
}
return data;
}
}