1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-28 15:27:43 +00:00
FrankerFaceZ/src/modules/chat/index.js

602 lines
14 KiB
JavaScript
Raw Normal View History

2017-11-13 01:23:39 -05:00
'use strict';
// ============================================================================
// Chat
// ============================================================================
import Module from 'utilities/module';
import {createElement, ManagedStyle} from 'utilities/dom';
import {timeout, has} from 'utilities/object';
2017-11-13 01:23:39 -05:00
import Badges from './badges';
import Emotes from './emotes';
import Room from './room';
import User from './user';
2017-11-13 01:23:39 -05:00
import * as TOKENIZERS from './tokenizers';
import * as RICH_PROVIDERS from './rich_providers';
2017-11-13 01:23:39 -05:00
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(Badges);
this.inject(Emotes);
this._link_info = {};
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 = [];
2017-11-13 01:23:39 -05:00
// ========================================================================
// Settings
// ========================================================================
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.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.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'
}
});
2017-11-13 01:23:39 -05:00
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'
}
});
this.settings.add('tooltip.link-interaction', {
default: true,
ui: {
path: 'Chat > Tooltips >> Links',
title: 'Allow interaction with supported link tooltips.',
component: 'setting-check-box'
}
});
2017-11-13 01:23:39 -05:00
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)'}
2017-11-13 01:23:39 -05:00
]
}
});
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'
}
});
this.context.on('changed:theme.is-dark', () => {
for(const room of this.iterateRooms())
room.buildBitsCSS();
2017-11-13 01:23:39 -05:00
});
this.context.on('changed:chat.bits.animated', () => {
for(const room of this.iterateRooms())
room.buildBitsCSS();
2017-11-13 01:23:39 -05:00
});
}
onEnable() {
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]);
2017-11-13 01:23:39 -05:00
}
getUser(id, login, no_create, no_login, error = false) {
2017-11-13 01:23:39 -05:00
let user;
if ( id && typeof id === 'number' )
id = `${id}`;
2017-11-13 01:23:39 -05:00
if ( this.user_ids[id] )
user = this.user_ids[id];
else if ( this.users[login] && ! no_login )
user = this.users[login];
else if ( no_create )
return null;
else
user = new User(this, null, id, login);
2017-11-13 01:23:39 -05:00
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;
}
2017-11-13 01:23:39 -05:00
// Otherwise, we're just here to set the ID.
user._id = id;
2017-11-13 01:23:39 -05:00
this.user_ids[id] = user;
}
if ( login ) {
const other = this.users[login];
if ( other ) {
if ( other !== this && ! 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 {
// TODO: Merge Logic~~
}
}
} else
this.users[login] = user;
}
return user;
}
getRoom(id, login, no_create, no_login, error = false) {
2017-11-13 01:23:39 -05:00
let room;
if ( id && typeof id === 'number' )
id = `${id}`;
2017-11-13 01:23:39 -05:00
if ( this.room_ids[id] )
room = this.room_ids[id];
else if ( this.rooms[login] && ! no_login )
room = this.rooms[login];
else 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;
}
2017-11-13 01:23:39 -05:00
// Otherwise, we're just here to set the ID.
room._id = id;
2017-11-13 01:23:39 -05:00
this.room_ids[id] = room;
}
if ( login ) {
const other = this.rooms[login];
if ( other ) {
if ( other !== this && ! 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 {
// TODO: Merge Logic~~
}
}
} 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 ) {
visited.add(room);
yield room;
}
}
for(const login in this.rooms)
if ( has(this.rooms, login) ) {
const room = this.rooms[login];
if ( room && ! visited.has(room) )
yield room;
}
}
formatTime(time) { // eslint-disable-line class-methods-use-this
2017-11-13 01:23:39 -05:00
if (!( time instanceof Date ))
time = new Date(time);
let hours = time.getHours();
const minutes = time.getMinutes(); //,
// seconds = time.getSeconds(),
// fmt = this.settings.get('chat.timestamp-format');
2017-11-13 01:23:39 -05:00
if ( hours > 12 )
hours -= 12;
else if ( hours === 0 )
hours = 12;
return `${hours}:${minutes < 10 ? '0' : ''}${minutes}`; //:${seconds < 10 ? '0' : ''}${seconds}`;
}
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'])
tk[i] = typeof tt[i] === 'function' ? tt[i].bind(this) : tt[i];
}
2017-11-13 01:23:39 -05:00
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;
2017-11-13 01:23:39 -05:00
});
}
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) { // eslint-disable-line class-methods-use-this
if ( ! this.context.get('chat.rich.enabled') )
return;
const providers = this.__rich_providers;
for(const token of tokens) {
for(const provider of providers)
if ( provider.test.call(this, token) ) {
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 [];
2017-11-13 01:23:39 -05:00
let tokens = [{type: 'text', text: msg.message}];
if ( ! tokens[0].text )
return tokens;
2017-11-13 01:23:39 -05:00
for(const tokenizer of this.__tokenizers)
tokens = tokenizer.process.call(this, tokens, msg, user);
2017-11-13 01:23:39 -05:00
return tokens;
}
renderTokens(tokens, e) {
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 ( type === 'text' )
res = e('span', {
'data-a-target': 'chat-message-text'
}, token.text);
2017-11-13 01:23:39 -05:00
else if ( tk )
res = tk.render.call(this, token, e);
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
// ====
get_link_info(url, no_promises) {
let info = this._link_info[url];
const expires = info && info[1];
2017-11-13 01:23:39 -05:00
if ( 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) => {
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);
}
timeout(this.socket.call('get_link', url), 15000)
.then(data => handle(true, data))
.catch(err => handle(false, err));
});
}
}