1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-07-03 17:48:30 +00:00
FrankerFaceZ/src/sites/twitch-twilight/modules/chat/index.js

1660 lines
44 KiB
JavaScript
Raw Normal View History

2017-11-13 01:23:39 -05:00
'use strict';
// ============================================================================
// Chat Hooks
// ============================================================================
import {ColorAdjuster} from 'utilities/color';
2017-11-13 01:23:39 -05:00
import {setChildren} from 'utilities/dom';
import {has, make_enum, split_chars, shallow_object_equals, set_equals} from 'utilities/object';
import {FFZEvent} from 'utilities/events';
2017-11-13 01:23:39 -05:00
import Module from 'utilities/module';
import Twilight from 'site';
2017-11-14 04:12:10 -05:00
import Scroller from './scroller';
import ChatLine from './line';
import SettingsMenu from './settings_menu';
import EmoteMenu from './emote_menu';
import TabCompletion from './tab_completion';
const REGEX_EMOTES = {
'B-?\\)': ['B)', 'B-)'],
'R-?\\)': ['R)', 'R-)'],
'[oO](_|\\.)[oO]': ['o_o', 'O_o', 'o_O', 'O_O', 'o.o', 'O.o', 'o.O', 'O.O'],
'\\&gt\\;\\(': ['>('],
'\\&lt\\;3': ['<3'],
'\\:-?(o|O)': [':o', ':O', ':-o', ':-O'],
'\\:-?(p|P)': [':p', ':P', ':-p', ':-P'],
'\\:-?D': [':D', ':-D'],
'\\:-?[\\\\/]': [':/', ':-/', ':\\', ':-\\'],
'\\:-?[z|Z|\\|]': [':z', ':Z', ':|', ':-z', ':-Z', ':-|'],
'\\:-?\\(': [':(', ':-('],
'\\:-?\\)': [':)', ':-)'],
'\\;-?(p|P)': [';p', ';P', ';-p', ';-P'],
'\\;-?\\)': [';)', ';-)'],
'#-?[\\\\/]': ['#/', '#-/', '#//', '#-//'],
':-?(?:7|L)': [':7', ':L', ':-7', ':-L'],
'\\&lt\\;\\]': ['<]'],
'\\:-?(S|s)': [':s', ':S', ':-s', ':-S'],
'\\:\\&gt\\;': [':>']
};
const MESSAGE_TYPES = make_enum(
'Post',
'Action',
'PostWithMention'
);
const MOD_TYPES = make_enum(
'Ban',
'Timeout',
'Delete'
);
const AUTOMOD_TYPES = make_enum(
'MessageRejectedPrompt',
'CheerMessageRejectedPrompt',
'MessageRejected',
'MessageAllowed',
'MessageDenied',
'CheerMessageDenied',
'CheerMessageTimeout',
'MessageModApproved',
'MessageModDenied'
);
const CHAT_TYPES = make_enum(
'Message',
'ExtensionMessage',
'Moderation',
'ModerationAction',
'TargetedModerationAction',
'AutoMod',
'SubscriberOnlyMode',
'FollowerOnlyMode',
'SlowMode',
'EmoteOnlyMode',
'R9KMode',
'Connected',
'Disconnected',
'Reconnect',
'Hosting',
'Unhost',
'Hosted',
'Subscription',
'Resubscription',
'GiftPaidUpgrade',
'AnonGiftPaidUpgrade',
'PrimePaidUpgrade',
'SubGift',
'AnonSubGift',
'Clear',
'RoomMods',
'RoomState',
'Raid',
'Unraid',
'Ritual',
'Notice',
'Info',
'BadgesUpdated',
'Purchase',
'BitsCharity',
'CrateGift',
'RewardGift',
'SubMysteryGift',
'AnonSubMysteryGift',
'FirstCheerMessage',
'BitsBadgeTierMessage',
'InlinePrivateCallout'
);
const NULL_TYPES = [
'Reconnect',
'RoomState',
'BadgesUpdated',
'Clear'
];
2017-11-14 04:12:10 -05:00
const MISBEHAVING_EVENTS = [
'onBadgesUpdatedEvent',
2017-11-13 01:23:39 -05:00
];
export default class ChatHook extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.colors = new ColorAdjuster;
this.inverse_colors = new ColorAdjuster;
2017-11-13 01:23:39 -05:00
this.inject('settings');
this.inject('i18n');
2017-11-13 01:23:39 -05:00
this.inject('site');
this.inject('site.router');
this.inject('site.fine');
this.inject('site.web_munch');
this.inject('site.css_tweaks');
this.inject('chat');
2017-11-14 04:12:10 -05:00
this.inject(Scroller);
this.inject(ChatLine);
this.inject(SettingsMenu);
this.inject(EmoteMenu);
this.inject(TabCompletion);
this.ChatService = this.fine.define(
'chat-service',
n => n.join && n.connectHandlers,
Twilight.CHAT_ROUTES
);
this.ChatBuffer = this.fine.define(
'chat-buffer',
n => n.updateHandlers && n.delayedMessageBuffer && n.handleMessage,
Twilight.CHAT_ROUTES
);
2017-11-13 01:23:39 -05:00
this.ChatController = this.fine.define(
'chat-controller',
n => n.hostingHandler && n.onRoomStateUpdated,
Twilight.CHAT_ROUTES
2017-11-13 01:23:39 -05:00
);
this.ChatContainer = this.fine.define(
'chat-container',
n => n.showViewersList && n.onChatInputFocus,
Twilight.CHAT_ROUTES
2017-11-13 01:23:39 -05:00
);
this.ChatBufferConnector = this.fine.define(
'chat-buffer-connector',
n => n.clearBufferHandle && n.syncBufferedMessages,
Twilight.CHAT_ROUTES
);
2017-11-13 01:23:39 -05:00
this.PinnedCheer = this.fine.define(
'pinned-cheer',
n => n.collapseCheer && n.saveRenderedMessageRef,
Twilight.CHAT_ROUTES
2017-11-13 01:23:39 -05:00
);
this.RoomPicker = this.fine.define(
'chat-picker',
n => n.closeRoomPicker && n.handleRoomSelect,
Twilight.CHAT_ROUTES
);
2017-11-13 01:23:39 -05:00
// Settings
this.settings.add('chat.width', {
default: 340,
ui: {
path: 'Chat > Appearance >> General @{"sort": -1}',
title: 'Width',
description: "How wide chat should be, in pixels. This may be affected by your browser's zoom and font size settings.",
2017-11-13 01:23:39 -05:00
component: 'setting-text-box',
process(val) {
val = parseInt(val, 10);
if ( isNaN(val) || ! isFinite(val) || val <= 0 )
return 340;
return val;
}
}
});
this.settings.add('chat.bits.show-pinned', {
default: true,
ui: {
path: 'Chat > Bits and Cheering >> Appearance',
title: 'Display Top Cheerers',
2017-11-13 01:23:39 -05:00
component: 'setting-check-box'
}
});
this.settings.add('chat.rituals.show', {
default: true,
ui: {
path: 'Chat > Filtering >> Rituals',
title: 'Display ritual messages such as "User is new here! Say Hello!".',
component: 'setting-check-box'
}
});
this.settings.add('chat.subs.show', {
default: 3,
ui: {
path: 'Chat > Appearance >> Subscriptions',
title: 'Display Subs in Chat',
component: 'setting-select-box',
description: '**Note**: Messages sent with re-subs will always be displayed. This only controls the special "X subscribed!" message.',
data: [
{value: 0, title: 'Do Not Display'},
{value: 1, title: 'Re-Subs with Messages Only'},
{value: 2, title: 'Re-Subs Only'},
{value: 3, title: 'Display All'}
]
}
});
this.settings.add('chat.subs.compact', {
default: false,
ui: {
path: 'Chat > Appearance >> Subscriptions',
title: 'Display subscription notices in a more compact (classic style) form.',
component: 'setting-check-box'
}
});
this.settings.add('chat.subs.merge-gifts', {
default: 1000,
ui: {
path: 'Chat > Appearance >> Subscriptions',
title: 'Merge Mass Sub Gifts',
component: 'setting-select-box',
data: [
{value: 1000, title: 'Disabled'},
{value: 50, title: 'More than 50'},
{value: 20, title: 'More than 20'},
{value: 10, title: 'More than 10'},
{value: 5, title: 'More than 5'},
{value: 0, title: 'Always'}
],
description: 'Merge mass gift subscriptions into a single message, depending on the quantity.\n**Note:** Only affects newly gifted subs.'
}
});
this.settings.add('chat.subs.merge-gifts-visibility', {
default: false,
ui: {
path: 'Chat > Appearance >> Subscriptions',
title: 'Expand merged mass sub gift messages by default.',
component: 'setting-check-box'
}
});
2017-11-13 01:23:39 -05:00
this.settings.add('chat.lines.alternate', {
default: false,
ui: {
path: 'Chat > Appearance >> Chat Lines',
title: 'Display lines with alternating background colors.',
component: 'setting-check-box'
}
});
this.settings.add('chat.lines.padding', {
default: false,
ui: {
path: 'Chat > Appearance >> Chat Lines',
title: 'Reduce padding around lines.',
component: 'setting-check-box'
}
});
this.settings.add('chat.lines.borders', {
default: 0,
ui: {
path: 'Chat > Appearance >> Chat Lines',
title: 'Separators',
component: 'setting-select-box',
data: [
{value: 0, title: 'Disabled'},
{value: 1, title: 'Basic Line (1px Solid)'},
{value: 2, title: '3D Line (2px Groove)'},
{value: 3, title: '3D Line (2px Groove Inset)'},
{value: 4, title: 'Wide Line (2px Solid)'}
]
}
});
}
get currentChat() {
for(const inst of this.ChatController.instances)
if ( inst && inst.chatService )
return inst;
}
updateColors() {
const is_dark = this.chat.context.get('theme.is-dark'),
mode = this.chat.context.get('chat.adjustment-mode'),
contrast = this.chat.context.get('chat.adjustment-contrast'),
c = this.colors,
ic = this.inverse_colors;
2017-11-13 01:23:39 -05:00
// TODO: Get the background color from the theme system.
// Updated: Use the lightest/darkest colors from alternating rows for better readibility.
c._base = is_dark ? '#191919' : '#e0e0e0'; //#0e0c13' : '#faf9fa';
2017-11-13 01:23:39 -05:00
c.mode = mode;
c.contrast = contrast;
ic._base = is_dark ? '#dad8de' : '#19171c';
ic.mode = mode;
ic.contrast = contrast;
2017-11-13 01:23:39 -05:00
this.updateChatLines();
}
updateChatCSS() {
const width = this.chat.context.get('chat.width'),
size = this.chat.context.get('chat.font-size'),
emote_alignment = this.chat.context.get('chat.lines.emote-alignment'),
lh = Math.round((20/12) * size);
let font = this.chat.context.get('chat.font-family') || 'inherit';
if ( font.indexOf(' ') !== -1 && font.indexOf(',') === -1 && font.indexOf('"') === -1 && font.indexOf("'") === -1 )
font = `"${font}"`;
this.css_tweaks.setVariable('chat-font-size', `${size/10}rem`);
this.css_tweaks.setVariable('chat-line-height', `${lh/10}rem`);
this.css_tweaks.setVariable('chat-font-family', font);
this.css_tweaks.setVariable('chat-width', `${width/10}rem`);
this.css_tweaks.toggle('chat-font', size !== 12 || font);
this.css_tweaks.toggle('chat-width', width !== 340);
this.css_tweaks.toggle('emote-alignment-padded', emote_alignment === 1);
this.css_tweaks.toggle('emote-alignment-baseline', emote_alignment === 2);
2017-11-13 01:23:39 -05:00
}
updateLineBorders() {
const mode = this.chat.context.get('chat.lines.borders');
this.css_tweaks.toggle('chat-borders', mode > 0);
this.css_tweaks.toggle('chat-borders-3d', mode === 2);
this.css_tweaks.toggle('chat-borders-3d-inset', mode === 3);
this.css_tweaks.toggle('chat-borders-wide', mode === 4);
}
updateMentionCSS() {
const enabled = this.chat.context.get('chat.filtering.highlight-mentions');
this.css_tweaks.toggle('chat-mention-token', this.chat.context.get('chat.filtering.highlight-tokens'));
this.css_tweaks.toggle('chat-mention-bg', enabled);
this.css_tweaks.toggle('chat-mention-bg-alt', enabled && this.chat.context.get('chat.lines.alternate'));
}
2017-11-13 01:23:39 -05:00
async grabTypes() {
const ct = await this.web_munch.findModule('chat-types'),
changes = [];
this.automod_types = ct && ct.a || AUTOMOD_TYPES;
this.chat_types = ct && ct.b || CHAT_TYPES;
this.message_types = ct && ct.c || MESSAGE_TYPES;
this.mod_types = ct && ct.e || MOD_TYPES;
if ( ! ct )
return;
if ( ct.a && ! shallow_object_equals(ct.a, AUTOMOD_TYPES) )
changes.push('AUTOMOD_TYPES');
if ( ct.b && ! shallow_object_equals(ct.b, CHAT_TYPES) )
changes.push('CHAT_TYPES');
if ( ct.c && ! shallow_object_equals(ct.c, MESSAGE_TYPES) )
changes.push('MESSAGE_TYPES');
if ( ct.e && ! shallow_object_equals(ct.e, MOD_TYPES) )
changes.push('MOD_TYPES');
if ( changes.length )
this.log.info('Chat Types have changed from static mappings for categories:', changes.join(' '));
}
onEnable() {
this.on('site.web_munch:loaded', this.grabTypes);
this.grabTypes();
this.chat.context.on('changed:chat.width', this.updateChatCSS, this);
this.chat.context.on('changed:chat.font-size', this.updateChatCSS, this);
this.chat.context.on('changed:chat.font-family', this.updateChatCSS, this);
this.chat.context.on('changed:chat.lines.emote-alignment', this.updateChatCSS, this);
2017-11-13 01:23:39 -05:00
this.chat.context.on('changed:chat.adjustment-mode', this.updateColors, this);
this.chat.context.on('changed:chat.adjustment-contrast', this.updateColors, this);
this.chat.context.on('changed:theme.is-dark', this.updateColors, this);
this.chat.context.on('changed:chat.lines.borders', this.updateLineBorders, this);
this.chat.context.on('changed:chat.filtering.highlight-mentions', this.updateMentionCSS, this);
this.chat.context.on('changed:chat.filtering.highlight-tokens', this.updateMentionCSS, this);
this.chat.context.on('changed:chat.fix-bad-emotes', this.updateChatLines, this);
this.chat.context.on('changed:chat.filtering.display-deleted', this.updateChatLines, this);
this.chat.context.on('changed:chat.filtering.display-mod-action', this.updateChatLines, this);
2017-11-13 01:23:39 -05:00
this.chat.context.on('changed:chat.lines.alternate', val => {
this.css_tweaks.toggle('chat-rows', val);
this.updateMentionCSS();
});
2017-11-13 01:23:39 -05:00
this.chat.context.on('changed:chat.lines.padding', val =>
this.css_tweaks.toggle('chat-padding', val));
this.chat.context.on('changed:chat.bits.show', val =>
this.css_tweaks.toggle('hide-bits', !val));
this.chat.context.on('changed:chat.bits.show-pinned', val =>
this.css_tweaks.toggleHide('pinned-cheer', !val));
this.chat.context.on('changed:chat.filtering.deleted-style', val => {
this.css_tweaks.toggle('chat-deleted-strike', val === 1 || val === 2);
this.css_tweaks.toggle('chat-deleted-fade', val < 2);
});
const val = this.chat.context.get('chat.filtering.deleted-style');
this.css_tweaks.toggle('chat-deleted-strike', val === 1 || val === 2);
this.css_tweaks.toggle('chat-deleted-fade', val < 2);
2017-11-13 01:23:39 -05:00
this.css_tweaks.toggleHide('pinned-cheer', !this.chat.context.get('chat.bits.show-pinned'));
this.css_tweaks.toggle('hide-bits', !this.chat.context.get('chat.bits.show'));
this.css_tweaks.toggle('chat-rows', this.chat.context.get('chat.lines.alternate'));
this.css_tweaks.toggle('chat-padding', this.chat.context.get('chat.lines.padding'));
this.updateChatCSS();
2017-11-13 01:23:39 -05:00
this.updateColors();
this.updateLineBorders();
this.updateMentionCSS();
2017-11-13 01:23:39 -05:00
this.ChatController.on('mount', this.chatMounted, this);
this.ChatController.on('unmount', this.chatUmounted, this);
2017-11-13 01:23:39 -05:00
this.ChatController.on('receive-props', this.chatUpdated, this);
this.ChatService.ready((cls, instances) => {
this.wrapChatService(cls);
for(const inst of instances) {
inst.client.events.removeAll();
inst._ffzInstall();
inst.connectHandlers();
inst.props.setChatConnectionAPI({
sendMessage: inst.sendMessage,
_ffz_inst: inst
});
}
});
this.ChatBuffer.ready((cls, instances) => {
this.wrapChatBuffer(cls);
for(const inst of instances) {
const handler = inst.props.messageHandlerAPI;
if ( handler )
handler.removeMessageHandler(inst.handleMessage);
inst._ffzInstall();
if ( handler )
handler.addMessageHandler(inst.handleMessage);
inst.props.setMessageBufferAPI({
addUpdateHandler: inst.addUpdateHandler,
removeUpdateHandler: inst.removeUpdateHandler,
getMessages: inst.getMessages,
_ffz_inst: inst
});
}
});
this.ChatBufferConnector.on('mount', this.connectorMounted, this);
this.ChatBufferConnector.on('receive-props', this.connectorUpdated, this);
this.ChatBufferConnector.on('unmount', this.connectorUnmounted, this);
this.ChatBufferConnector.ready((cls, instances) => {
for(const inst of instances)
this.connectorMounted(inst);
})
2017-11-13 01:23:39 -05:00
this.ChatController.ready((cls, instances) => {
const t = this,
old_catch = cls.prototype.componentDidCatch,
old_render = cls.prototype.render;
// Try catching errors. With any luck, maybe we can
// recover from the error when we re-build?
cls.prototype.componentDidCatch = function(err, info) {
// Don't log infinitely if stuff gets super screwed up.
const errs = this.state.ffz_errors || 0;
if ( errs < 100 ) {
this.setState({ffz_errors: errs + 1});
t.log.info('Error within Chat', err, info, errs);
}
if ( old_catch )
return old_catch.call(this, err, info);
}
cls.prototype.render = function() {
if ( this.state.ffz_errors > 0 ) {
const React = t.web_munch.getModule('react'),
createElement = React && React.createElement;
if ( ! createElement )
return null;
return createElement('div', {
className: 'tw-border-l tw-c-background-alt-2 tw-c-text-base tw-full-width tw-full-height tw-align-items-center tw-flex tw-flex-column tw-justify-content-center tw-relative'
}, 'There was an error displaying chat.');
} else
return old_render.call(this);
}
for(const inst of instances)
2017-11-13 01:23:39 -05:00
this.chatMounted(inst);
});
this.ChatContainer.on('mount', this.containerMounted, this);
this.ChatContainer.on('unmount', this.removeRoom, this);
this.ChatContainer.on('update', this.containerUpdated, this);
2017-11-13 01:23:39 -05:00
this.ChatContainer.ready((cls, instances) => {
const t = this,
old_catch = cls.prototype.componentDidCatch;
// Try catching errors. With any luck, maybe we can
// recover from the error when we re-build?
cls.prototype.componentDidCatch = function(err, info) {
// Don't log infinitely if stuff gets super screwed up.
const errs = this.state.ffz_errors || 0;
if ( errs < 100 ) {
this.setState({ffz_errors: errs + 1});
t.log.info('Error within Chat Container', err, info, errs);
}
if ( old_catch )
return old_catch.call(this, err, info);
}
2017-11-13 01:23:39 -05:00
for(const inst of instances)
this.containerMounted(inst);
});
this.PinnedCheer.on('mount', this.fixPinnedCheer, this);
this.PinnedCheer.on('update', this.fixPinnedCheer, this);
this.PinnedCheer.ready((cls, instances) => {
for(const inst of instances)
this.fixPinnedCheer(inst);
});
this.RoomPicker.ready((cls, instances) => {
for(const inst of instances)
this.closeRoomPicker(inst);
});
this.RoomPicker.on('mount', this.closeRoomPicker, this);
}
closeRoomPicker(inst) { // eslint-disable-line class-methods-use-this
inst.closeRoomPicker();
}
2017-11-13 01:23:39 -05:00
wrapChatBuffer(cls) {
if ( cls.prototype._ffz_was_here )
return;
const t = this,
old_mount = cls.prototype.componentDidMount;
2017-11-13 01:23:39 -05:00
cls.prototype._ffzInstall = function() {
if ( this._ffz_installed )
return;
this._ffz_installed = true;
const inst = this,
old_handle = inst.handleMessage,
old_set = inst.props.setMessageBufferAPI;
inst.props.setMessageBufferAPI = function(api) {
if ( api )
api._ffz_inst = inst;
return old_set(api);
}
inst.handleMessage = function(msg) {
if ( msg ) {
try {
const types = t.chat_types || {},
mod_types = t.mod_types || {};
if ( msg.type === types.Message ) {
const m = t.chat.standardizeMessage(msg),
cont = inst._ffz_connector,
room_id = cont && cont.props.channelID;
let room = m.roomLogin = m.roomLogin ? m.roomLogin : m.channel ? m.channel.slice(1) : cont && cont.props.channelLogin;
if ( ! room && room_id ) {
const r = t.chat.getRoom(room_id, null, true);
if ( r && r.login )
room = m.roomLogin = r.login;
}
const u = t.site.getUser(),
r = {id: room_id, login: room};
if ( u && cont ) {
u.moderator = cont.props.isCurrentUserModerator;
u.staff = cont.props.isStaff;
}
m.ffz_tokens = m.ffz_tokens || t.chat.tokenizeMessage(m, u, r);
const event = new FFZEvent({
message: m,
channel: room,
channelID: room_id
});
t.emit('chat:receive-message', event);
if ( event.defaultPrevented || m.ffz_removed )
return;
} else if ( msg.type === types.ModerationAction ) {
//t.log.info('Moderation Action', msg);
if ( ! inst.props.isCurrentUserModerator )
return;
const mod_action = msg.moderationActionType;
if ( mod_action === 'ban' || mod_action === 'timeout' || mod_action === 'delete' ) {
const user = msg.targetUserLogin;
if ( inst.moderatedUsers.has(user) )
return;
const do_remove = t.chat.context.get('chat.filtering.remove-deleted') === 3;
if ( do_remove ) {
const len = inst.buffer.length,
target_id = msg.messageID;
inst.buffer = inst.buffer.filter(m =>
m.type !== types.Message || ! m.user || m.user.userLogin !== user ||
(target_id && m.id !== target_id)
);
if ( len !== inst.buffer.length && ! inst.props.isBackground )
inst.notifySubscribers();
inst.ffzModerateBuffer([inst.delayedMessageBuffer], msg);
} else
inst.ffzModerateBuffer([inst.buffer, inst.delayedMessageBuffer], msg);
inst.moderatedUsers.add(user);
setTimeout(inst.unsetModeratedUser(user), 1e3);
inst.delayedMessageBuffer.push({
event: msg,
time: Date.now(),
shouldDelay: false
});
return;
}
} else if ( msg.type === types.Moderation ) {
//t.log.info('Moderation', msg);
if ( inst.props.isCurrentUserModerator )
return;
const user = msg.userLogin;
if ( inst.moderatedUsers.has(user) )
return;
const mod_action = msg.moderationType;
let new_action;
if ( mod_action === mod_types.Ban )
new_action = 'ban';
else if ( mod_action === mod_types.Delete )
new_action = 'delete';
else if ( mod_action === mod_types.Unban )
new_action = 'unban';
else if ( mod_action === mod_types.Timeout )
new_action = 'timeout';
if ( new_action )
msg.moderationActionType = new_action;
const do_remove = t.chat.context.get('chat.filtering.remove-deleted') === 3;
if ( do_remove ) {
const len = inst.buffer.length,
target_id = msg.targetMessageID;
inst.buffer = inst.buffer.filter(m =>
m.type !== types.Message || ! m.user || m.user.userLogin !== user ||
(target_id && m.id !== target_id)
);
if ( len !== inst.buffer.length && ! inst.props.isBackground )
inst.notifySubscribers();
inst.ffzModerateBuffer([inst.delayedMessageBuffer], msg);
} else
inst.ffzModerateBuffer([inst.buffer, inst.delayedMessageBuffer], msg);
inst.moderatedUsers.add(user);
setTimeout(inst.unsetModeratedUser(user), 1e3);
inst.delayedMessageBuffer.push({
event: msg,
time: Date.now(),
shouldDelay: false
});
return;
} else if ( msg.type === types.Clear ) {
if ( t.chat.context.get('chat.filtering.ignore-clear') )
msg = {
type: types.Info,
message: t.i18n.t('chat.ignore-clear', 'An attempt by a moderator to clear chat was ignored.')
}
}
} catch(err) {
t.log.error('Error processing chat event.', err);
t.log.capture(err, {extra: {msg}});
}
}
return old_handle.call(inst, msg);
}
inst.ffzModerateBuffer = function(buffers, event) {
const mod_types = t.mod_types || {},
mod_type = event.moderationActionType,
user_login = event.targetUserLogin || event.userLogin,
mod_login = event.createdByLogin,
target_id = event.targetMessageID || event.messageID;
let deleted_count = 0, last_msg;
const is_delete = mod_type === mod_types.Delete,
updater = m => {
if ( m.event )
m = m.event;
if ( target_id && m.id !== target_id )
return;
const msg = inst.markUserEventDeleted(m, user_login);
if ( ! msg )
return;
last_msg = msg;
deleted_count++;
msg.modLogin = mod_login;
msg.modActionType = mod_type;
msg.duration = event.duration;
if ( is_delete )
return true;
};
for(const buffer of buffers)
if ( buffer.some(updater) )
break;
//t.log.info('Moderate Buffer', mod_type, user_login, mod_login, target_id, deleted_count, last_msg);
if ( last_msg )
last_msg.deletedCount = deleted_count;
}
inst.getMessages = function() {
const buf = inst.buffer,
size = t.chat.context.get('chat.scrollback-length'),
ct = t.chat_types || CHAT_TYPES,
target = buf.length - size;
if ( target > 0 ) {
let removed = 0, last;
for(let i=0; i < target; i++)
if ( buf[i] && ! NULL_TYPES.includes(ct[buf[i].type]) ) {
removed++;
last = i;
}
inst.buffer = buf.slice(removed % 2 === 0 ? target : Math.max(target - 10, last));
} else
// Make a shallow copy of the array because other code expects it to change.
inst.buffer = buf.slice(0);
return inst.buffer;
}
}
cls.prototype.componentDidMount = function() {
try {
this._ffzInstall();
} catch(err) {
t.log.error('Error installing FFZ features onto chat buffer.', err);
}
return old_mount.call(this);
}
cls.prototype.flushRawMessages = function() {
const out = [],
now = Date.now(),
raw_delay = t.chat.context.get('chat.delay'),
delay = raw_delay === -1 ? this.delayDuration : raw_delay,
first = now - delay,
see_deleted = this.shouldSeeBlockedAndDeletedMessages || this.props && this.props.shouldSeeBlockedAndDeletedMessages,
do_remove = t.chat.context.get('chat.filtering.remove-deleted');
let changed = false;
for(const msg of this.delayedMessageBuffer) {
if ( msg.time <= first || ! msg.shouldDelay ) {
if ( do_remove !== 0 && (do_remove > 1 || ! see_deleted) && this.isDeletable(msg.event) && msg.event.deleted )
continue;
this.buffer.push(msg.event);
changed = true;
} else
out.push(msg);
}
this.delayedMessageBuffer = out;
if ( changed && ! this.props.isBackground )
this.notifySubscribers();
2017-11-13 01:23:39 -05:00
}
}
sendMessage(room, message) {
const service = this.ChatService.first;
if ( ! service || ! room )
return null;
if ( room.startsWith('#') )
room = room.slice(1);
if ( room.toLowerCase() !== service.props.channelLogin.toLowerCase() )
return service.client.sendCommand(room, message);
service.sendMessage(message);
}
2017-11-13 01:23:39 -05:00
wrapChatService(cls) {
const t = this,
old_mount = cls.prototype.componentDidMount,
old_handler = cls.prototype.connectHandlers;
2017-11-13 01:23:39 -05:00
cls.prototype._ffz_was_here = true;
cls.prototype.ffzGetEmotes = function() {
const emote_map = this.client && this.client.session && this.client.session.emoteMap;
if ( this._ffz_cached_map === emote_map )
return this._ffz_cached_emotes;
this._ffz_cached_map = emote_map;
const emotes = this._ffz_cached_emotes = {};
if ( emote_map )
for(const emote of Object.values(emote_map))
if ( emote ) {
const token = emote.token;
if ( Array.isArray(REGEX_EMOTES[token]) ) {
for(const tok of REGEX_EMOTES[token] )
emotes[tok] = emote.id;
} else
emotes[token] = emote.id;
}
return emotes;
}
cls.prototype._ffzInstall = function() {
if ( this._ffz_installed )
return;
this._ffz_installed = true;
const inst = this,
old_send = this.sendMessage;
inst.sendMessage = function(raw_msg) {
const msg = raw_msg.replace(/\n/g, '');
if ( msg.startsWith('/ffz') ) {
inst.addMessage({
type: t.chat_types.Notice,
message: 'The /ffz command is not yet re-implemented.'
})
return false;
}
const event = new FFZEvent({
message: msg,
channel: inst.props.channelLogin
});
t.emit('chat:pre-send-message', event);
if ( event.defaultPrevented )
return;
return old_send.call(this, msg);
}
}
cls.prototype.componentDidMount = function() {
try {
this._ffzInstall();
} catch(err) {
t.log.error('Error installing FFZ features onto chat service.', err);
}
return old_mount.call(this);
}
2017-11-13 01:23:39 -05:00
cls.prototype.connectHandlers = function(...args) {
if ( ! this._ffz_init ) {
const i = this;
2017-11-13 01:23:39 -05:00
for(const key of MISBEHAVING_EVENTS) {
2017-11-13 01:23:39 -05:00
const original = this[key];
if ( original )
this[key] = function(e, t) {
i._wrapped = e;
const ret = original.call(i, e, t);
i._wrapped = null;
return ret;
}
}
const old_chat = this.onChatMessageEvent;
this.onChatMessageEvent = function(e) {
if ( e && e.sentByCurrentUser ) {
try {
e.message.user.emotes = findEmotes(
e.message.body,
i.ffzGetEmotes()
);
} catch(err) {
t.log.capture(err, {extra: e});
}
}
return old_chat.call(i, e);
}
const old_action = this.onChatActionEvent;
this.onChatActionEvent = function(e) {
if ( e && e.sentByCurrentUser ) {
try {
e.message.user.emotes = findEmotes(
e.message.body,
i.ffzGetEmotes()
);
} catch(err) {
t.log.capture(err, {extra: e});
}
}
return old_action.call(i, e);
}
const old_sub = this.onSubscriptionEvent;
this.onSubscriptionEvent = function(e) {
try {
if ( t.chat.context.get('chat.subs.show') < 3 )
return;
e.body = '';
const out = i.convertMessage({message: e});
out.ffz_type = 'resub';
out.sub_plan = e.methods;
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
t.log.capture(err, {extra: e});
return old_sub.call(i, e);
}
}
const old_resub = this.onResubscriptionEvent;
this.onResubscriptionEvent = function(e) {
try {
if ( t.chat.context.get('chat.subs.show') < 2 && ! e.body )
return;
const out = i.convertMessage({message: e});
out.ffz_type = 'resub';
out.sub_cumulative = e.cumulativeMonths || 0;
out.sub_streak = e.streakMonths || 0;
out.sub_share_streak = e.shouldShareStreakTenure;
out.sub_months = e.months;
out.sub_plan = e.methods;
//t.log.info('Resub Event', e, out);
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
t.log.capture(err, {extra: e});
return old_resub.call(i, e);
}
}
const mysteries = this.ffz_mysteries = {};
const old_subgift = this.onSubscriptionGiftEvent;
this.onSubscriptionGiftEvent = function(e) {
try {
const key = `${e.channel}:${e.user.userID}`,
mystery = mysteries[key];
if ( mystery ) {
if ( mystery.expires < Date.now() ) {
mysteries[key] = null;
} else {
mystery.recipients.push({
id: e.recipientID,
login: e.recipientLogin,
displayName: e.recipientName
});
if( mystery.recipients.length >= mystery.size )
mysteries[key] = null;
if ( mystery.line )
mystery.line.forceUpdate();
return;
}
}
e.body = '';
const out = i.convertMessage({message: e});
out.ffz_type = 'sub_gift';
out.sub_recipient = {
id: e.recipientID,
login: e.recipientLogin,
displayName: e.recipientName
};
out.sub_plan = e.methods;
out.sub_total = e.senderCount;
//t.log.info('Sub Gift', e, out);
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
t.log.capture(err, {extra: e});
return old_subgift.call(i, e);
}
}
const old_anonsubgift = this.onAnonSubscriptionGiftEvent;
this.onAnonSubscriptionGiftEvent = function(e) {
try {
const key = `${e.channel}:ANON`,
mystery = mysteries[key];
if ( mystery ) {
if ( mystery.expires < Date.now() )
mysteries[key] = null;
else {
mystery.recipients.push({
id: e.recipientID,
login: e.recipientLogin,
displayName: e.recipientName
});
if( mystery.recipients.length >= mystery.size )
mysteries[key] = null;
if ( mystery.line )
mystery.line.forceUpdate();
return;
}
}
e.body = '';
const out = i.convertMessage({message: e});
out.ffz_type = 'sub_gift';
out.sub_anon = true;
out.sub_recipient = {
id: e.recipientID,
login: e.recipientLogin,
displayName: e.recipientName
};
out.sub_plan = e.methods;
out.sub_total = e.senderCount;
//t.log.info('Anon Sub Gift', e, out);
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
t.log.capture(err, {extra: e});
return old_anonsubgift.call(i, e);
}
}
const old_submystery = this.onSubscriptionMysteryGiftEvent;
this.onSubscriptionMysteryGiftEvent = function(e) {
try {
let mystery = null;
if ( e.massGiftCount > t.chat.context.get('chat.subs.merge-gifts') ) {
const key = `${e.channel}:${e.user.userID}`;
mystery = mysteries[key] = {
recipients: [],
size: e.massGiftCount,
expires: Date.now() + 30000
};
}
e.body = '';
const out = i.convertMessage({message: e});
out.ffz_type = 'sub_mystery';
out.mystery = mystery;
out.sub_plan = e.plan;
out.sub_count = e.massGiftCount;
out.sub_total = e.senderCount;
//t.log.info('Sub Mystery', e, out);
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
t.log.capture(err, {extra: e});
return old_submystery.call(i, e);
}
}
const old_anonsubmystery = this.onAnonSubscriptionMysteryGiftEvent;
this.onAnonSubscriptionMysteryGiftEvent = function(e) {
try {
let mystery = null;
if ( e.massGiftCount > t.chat.context.get('chat.subs.merge-gifts') ) {
const key = `${e.channel}:ANON`;
mystery = mysteries[key] = {
recipients: [],
size: e.massGiftCount,
expires: Date.now() + 30000
};
}
e.body = '';
const out = i.convertMessage({message: e});
out.ffz_type = 'sub_mystery';
out.sub_anon = true;
out.mystery = mystery;
out.sub_plan = e.plan;
out.sub_count = e.massGiftCount;
out.sub_total = e.senderCount;
//t.log.info('Anon Sub Mystery', e, out);
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
t.log.capture(err, {extra: e});
return old_anonsubmystery.call(i, e);
}
}
const old_ritual = this.onRitualEvent;
this.onRitualEvent = function(e) {
try {
const out = i.convertMessage(e);
out.ffz_type = 'ritual';
out.ritual = e.type;
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
t.log.capture(err, {extra: e});
return old_ritual.call(i, e);
}
}
const old_host = this.onHostingEvent;
this.onHostingEvent = function (e, _t) {
t.emit('tmi:host', e, _t);
return old_host.call(i, e, _t);
}
const old_unhost = this.onUnhostEvent;
this.onUnhostEvent = function (e, _t) {
t.emit('tmi:unhost', e, _t);
return old_unhost.call(i, e, _t);
}
const old_add = this.addMessage;
this.addMessage = function(e) {
const original = i._wrapped;
if ( original && ! e._ffz_checked )
return i.postMessageToCurrentChannel(original, e);
2017-11-13 01:23:39 -05:00
return old_add.call(i, e);
2017-11-13 01:23:39 -05:00
}
this._ffz_init = true;
}
return old_handler.apply(this, ...args);
}
cls.prototype.postMessageToCurrentChannel = function(original, message) {
message._ffz_checked = true;
if ( original.channel ) {
let chan = message.channel = original.channel.toLowerCase();
if ( chan.startsWith('#') )
chan = chan.slice(1);
if ( chan !== this.props.channelLogin.toLowerCase() )
return;
message.roomLogin = chan;
}
if ( original.message ) {
const user = original.message.user,
flags = original.message.flags;
if ( user )
message.emotes = user.emotes;
if ( flags && this.getFilterFlagOptions )
message.flags = this.getFilterFlagOptions(flags);
if ( typeof original.action === 'string' )
message.message = original.action;
else
message.message = original.message.body;
}
this.addMessage(message);
}
2017-11-13 01:23:39 -05:00
}
updateChatLines() {
this.PinnedCheer.forceUpdate();
this.chat_line.updateLines();
2017-11-13 01:23:39 -05:00
}
// ========================================================================
// Pinned Cheers
// ========================================================================
fixPinnedCheer(inst) {
2018-02-22 18:38:13 -05:00
const el = this.fine.getChildNode(inst),
2017-11-13 01:23:39 -05:00
container = el && el.querySelector && el.querySelector('.pinned-cheer__headline'),
tc = inst.props.topCheer;
if ( ! container || ! tc )
return;
container.dataset.roomId = inst.props.channelID;
container.dataset.room = inst.props.channelLogin && inst.props.channelLogin.toLowerCase();
2017-11-13 01:23:39 -05:00
container.dataset.userId = tc.user.userID;
container.dataset.user = tc.user.userLogin && tc.user.userLogin.toLowerCase();
2017-11-13 01:23:39 -05:00
if ( tc.user.color ) {
const user_el = container.querySelector('.chat-author__display-name');
if ( user_el )
user_el.style.color = this.colors.process(tc.user.color);
const login_el = container.querySelector('.chat-author__intl-login');
if ( login_el )
login_el.style.color = this.colors.process(tc.user.color);
}
const bit_el = container.querySelector('.chat-line__message--emote'),
cont = bit_el ? bit_el.parentElement.parentElement : container.querySelector('.ffz--pinned-top-emote'),
prefix = extractCheerPrefix(tc.messageParts);
if ( cont && prefix ) {
const tokens = this.chat.tokenizeString(`${prefix}${tc.bits}`, tc);
cont.classList.add('ffz--pinned-top-emote');
cont.innerHTML = '';
setChildren(cont, this.chat.renderTokens(tokens));
}
}
// ========================================================================
// Room Handling
// ========================================================================
addRoom(thing, props) {
if ( ! props )
props = thing.props;
if ( ! props.channelID )
return null;
const room = thing._ffz_room = this.chat.getRoom(props.channelID, props.channelLogin && props.channelLogin.toLowerCase(), false, true);
2017-11-13 01:23:39 -05:00
room.ref(thing);
return room;
}
removeRoom(thing) { // eslint-disable-line class-methods-use-this
if ( ! thing._ffz_room )
return;
thing._ffz_room.unref(thing);
thing._ffz_room = null;
}
// ========================================================================
// Chat Controller
// ========================================================================
chatMounted(chat, props) {
if ( chat.chatBuffer )
chat.chatBuffer.ffzController = chat;
2017-11-13 01:23:39 -05:00
if ( ! props )
props = chat.props;
if ( ! this.addRoom(chat, props) )
return;
this.updateRoomBitsConfig(chat, props.bitsConfig);
// TODO: Check if this is the room for the current channel.
this.settings.updateContext({
moderator: props.isCurrentUserModerator,
chatHidden: props.isHidden
});
this.chat.context.updateContext({
moderator: props.isCurrentUserModerator,
channel: props.channelLogin && props.channelLogin.toLowerCase(),
channelID: props.channelID,
ui: {
theme: props.theme
}
});
2017-11-13 01:23:39 -05:00
}
chatUmounted(chat) {
if ( chat.chatBuffer && chat.chatBuffer.ffzController === this )
chat.chatBuffer.ffzController = null;
this.removeRoom(chat);
}
2017-11-13 01:23:39 -05:00
chatUpdated(chat, props) {
if ( chat.chatBuffer )
chat.chatBuffer.ffzController = chat;
2017-11-13 01:23:39 -05:00
if ( props.channelID !== chat.props.channelID ) {
this.removeRoom(chat);
this.chatMounted(chat, props);
return;
}
if ( props.bitsConfig !== chat.props.bitsConfig )
this.updateRoomBitsConfig(chat, props.bitsConfig);
// TODO: Check if this is the room for the current channel.
this.settings.updateContext({
moderator: props.isCurrentUserModerator,
chatHidden: props.isHidden
});
this.chat.context.updateContext({
moderator: props.isCurrentUserModerator,
channel: props.channelLogin && props.channelLogin.toLowerCase(),
2017-11-13 01:23:39 -05:00
channelID: props.channelID,
ui: {
theme: props.theme
}
});
}
updateRoomBitsConfig(chat, config) { // eslint-disable-line class-methods-use-this
const room = chat._ffz_room;
if ( ! room )
return;
// We have to check that the available cheers haven't changed
// to avoid doing too many recalculations.
let new_bits = null;
if ( config && Array.isArray(config.orderedActions) ) {
new_bits = new Set;
for(const action of config.orderedActions)
if ( action && action.prefix )
new_bits.add(action.prefix);
}
if ( (! this._ffz_old_bits && ! new_bits) || set_equals(this._ffz_old_bits, new_bits) )
return;
this._ffz_old_bits = new_bits;
2017-11-13 01:23:39 -05:00
room.updateBitsConfig(formatBitsConfig(config));
this.updateChatLines();
}
// ========================================================================
// Chat Buffer Connector
// ========================================================================
connectorMounted(inst) { // eslint-disable-line class-methods-use-this
const buffer = inst.props.messageBufferAPI;
if ( buffer && buffer._ffz_inst && buffer._ffz_inst._ffz_connector !== inst )
buffer._ffz_inst._ffz_connector = inst;
}
connectorUpdated(inst, props) { // eslint-disable-line class-methods-use-this
const buffer = inst.props.messageBufferAPI,
new_buffer = props.messageBufferAPI;
if ( buffer === new_buffer )
return;
if ( buffer && buffer._ffz_inst && buffer._ffz_inst._ffz_connector === inst )
buffer._ffz_inst._ffz_connector = null;
if ( new_buffer && new_buffer._ffz_inst && new_buffer._ffz_inst._ffz_connector !== inst )
buffer._ffz_inst._ffz_connector = inst;
}
connectorUnmounted(inst) { // eslint-disable-line class-methods-use-this
const buffer = inst.props.messageBufferAPI;
if ( buffer && buffer._ffz_inst && buffer._ffz_inst._ffz_connector === inst )
buffer._ffz_inst._ffz_connector = null;
}
2017-11-13 01:23:39 -05:00
// ========================================================================
// Chat Containers
// ========================================================================
containerMounted(cont, props) {
if ( ! props )
props = cont.props;
if ( ! this.addRoom(cont, props) )
return;
if ( props.data ) {
this.chat.badges.updateTwitchBadges(props.data.badges);
this.updateRoomBadges(cont, props.data.user && props.data.user.broadcastBadges);
this.updateRoomRules(cont, props.chatRules);
2017-11-13 01:23:39 -05:00
}
}
containerUpdated(cont, props) {
if ( props.channelID !== cont.props.channelID ) {
this.removeRoom(cont);
this.containerMounted(cont, props);
return;
}
// Twitch, React, and Apollo are the trifecta of terror so we
// can't compare the badgeSets property in any reasonable way.
// Instead, just check the lengths to see if they've changed
// and hope that badge versions will never change separately.
const data = props.data || {},
odata = cont.props.data || {},
2017-11-13 01:23:39 -05:00
bs = data.badges || [],
obs = odata.badges || [],
2017-11-13 01:23:39 -05:00
cs = data.user && data.user.broadcastBadges || [],
ocs = odata.user && odata.user.broadcastBadges || [];
2017-11-13 01:23:39 -05:00
if ( ! this.chat.badges.hasTwitchBadges() || bs.length !== obs.length )
this.chat.badges.updateTwitchBadges(bs);
2017-11-13 01:23:39 -05:00
if ( ! this.hasRoomBadges(cont) || cs.length !== ocs.length )
this.updateRoomBadges(cont, cs);
this.updateRoomRules(cont, props.chatRules);
2017-11-13 01:23:39 -05:00
}
hasRoomBadges(cont) { // eslint-disable-line class-methods-use-this
const room = cont._ffz_room;
if ( ! room )
return false;
return room.hasBadges();
}
2017-11-13 01:23:39 -05:00
updateRoomBadges(cont, badges) { // eslint-disable-line class-methods-use-this
const room = cont._ffz_room;
if ( ! room )
return;
room.updateBadges(badges);
this.updateChatLines();
}
updateRoomRules(cont, rules) { // eslint-disable-line class-methods-use-this
const room = cont._ffz_room;
if ( ! room )
return;
room.rules = rules;
}
2017-11-13 01:23:39 -05:00
}
// ============================================================================
// Processing Functions
// ============================================================================
export function formatBitsConfig(config) {
if ( ! config )
return;
const out = {},
actions = config.indexedActions;
for(const key in actions)
if ( has(actions, key) ) {
const action = actions[key],
new_act = out[key] = {
id: action.id,
prefix: action.prefix,
tiers: []
};
for(const tier of action.orderedTiers) {
const images = {};
for(const im of tier.images) {
const themed = images[im.theme] = images[im.theme] || [],
ak = im.isAnimated ? 'animated' : 'static',
anim = themed[ak] = themed[ak] || {};
anim[im.dpiScale] = im.url;
}
new_act.tiers.push({
amount: tier.bits,
color: tier.color,
id: tier.id,
images
})
}
}
return out;
}
export function findEmotes(msg, emotes) {
const out = {};
let idx = 0;
for(const part of msg.split(' ')) {
const len = split_chars(part).length;
if ( has(emotes, part) ) {
const em = emotes[part],
matches = out[em] = out[em] || [];
matches.push({
startIndex: idx,
endIndex: idx + len - 1
});
}
idx += len + 1;
}
return out;
}
2017-11-13 01:23:39 -05:00
function extractCheerPrefix(parts) {
for(const part of parts) {
if ( part.type !== 3 || ! part.content.cheerAmount )
continue;
return part.content.alt;
}
return null;
}