1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-08-08 07:10:54 +00:00

4.0.0 Beta 1

This commit is contained in:
SirStendec 2017-11-13 01:23:39 -05:00
parent c2688646af
commit 262757a20d
187 changed files with 22878 additions and 38882 deletions

View file

@ -0,0 +1,27 @@
'use strict';
// ============================================================================
// Badge Handling
// ============================================================================
import Module from 'utilities/module';
export const CSS_BADGES = {
staff: { 1: { color: '#200f33', use_svg: true } },
admin: { 1: { color: '#faaf19', use_svg: true } },
global_mod: { 1: { color: '#0c6f20', use_svg: true } },
broadcaster: { 1: { color: '#e71818', use_svg: true } },
moderator: { 1: { color: '#34ae0a', use_svg: true } },
twitchbot: { 1: { color: '#34ae0a' } },
partner: { 1: { color: 'transparent', has_trans: true, trans_color: '#6441a5' } },
turbo: { 1: { color: '#6441a5', use_svg: true } },
premium: { 1: { color: '#009cdc' } },
subscriber: { 0: { color: '#6441a4' }, 1: { color: '#6441a4' }},
}
export default class Badges extends Module {
}

325
src/modules/chat/emotes.js Normal file
View file

@ -0,0 +1,325 @@
'use strict';
// ============================================================================
// Emote Handling and Default Provider
// ============================================================================
import Module from 'utilities/module';
import {ManagedStyle} from 'utilities/dom';
import {has, timeout} from 'utilities/object';
import {API_SERVER} from 'utilities/constants';
const MODIFIERS = {
59847: {
modifier_offset: '0 15px 15px 0',
modifier: true
},
70852: {
modifier: true,
modifier_offset: '0 5px 20px 0',
extra_width: 5,
shrink_to_fit: true
},
70854: {
modifier: true,
modifier_offset: '30px 0 0'
},
147049: {
modifier: true,
modifier_offset: '4px 1px 0 3px'
},
147011: {
modifier: true,
modifier_offset: '0'
},
70864: {
modifier: true,
modifier_offset: '0'
},
147038: {
modifier: true,
modifier_offset: '0'
}
};
export default class Emotes extends Module {
constructor(...args) {
super(...args);
this.inject('socket');
this.twitch_inventory_sets = [];
this.__twitch_emote_to_set = new Map;
this.__twitch_set_to_channel = new Map;
this.default_sets = new Set;
this.global_sets = new Set;
this.emote_sets = {};
}
onEnable() {
this.style = new ManagedStyle('emotes');
if ( Object.keys(this.emote_sets).length ) {
this.log.info('Generating CSS for existing emote sets.');
for(const set_id in this.emote_sets)
if ( has(this.emote_sets, set_id) ) {
const emote_set = this.emote_sets[set_id];
if ( emote_set && emote_set.pending_css ) {
this.style.set(`es--${set_id}`, emote_set.pending_css + (emote_set.css || ''));
emote_set.pending_css = null;
}
}
}
this.load_global_sets();
this.load_emoji_data();
this.refresh_twitch_inventory();
}
// ========================================================================
// Access
// ========================================================================
getSetIDs(user_id, user_login, room_id, room_login) {
const room = this.parent.getRoom(room_id, room_login, true),
user = this.parent.getUser(user_id, user_login, true);
return (user ? user.emote_sets : []).concat(
room ? room.emote_sets : [],
Array.from(this.default_sets)
);
}
getSets(user_id, user_login, room_id, room_login) {
return this.getSetIDs(user_id, user_login, room_id, room_login)
.map(set_id => this.emote_sets[set_id]);
}
// ========================================================================
// FFZ Emote Sets
// ========================================================================
async load_global_sets(tries = 0) {
let response, data;
try {
response = await fetch(`${API_SERVER}/v1/set/global`)
} catch(err) {
tries++;
if ( tries < 10 )
return setTimeout(() => this.load_global_sets(tries), 500 * tries);
this.log.error('Error loading global emote sets.', err);
return false;
}
if ( ! response.ok )
return false;
try {
data = await response.json();
} catch(err) {
this.log.error('Error parsing global emote data.', err);
return false;
}
const sets = data.sets || {};
for(const set_id of data.default_sets)
this.default_sets.add(set_id);
for(const set_id in sets)
if ( has(sets, set_id) ) {
this.global_sets.add(set_id);
this.load_set_data(set_id, sets[set_id]);
}
if ( data.users )
this.load_set_users(data.users);
}
load_set_users(data) {
for(const set_id in data)
if ( has(data, set_id) ) {
const emote_set = this.emote_sets[set_id],
users = data[set_id];
for(const login of users) {
const user = this.parent.getUser(undefined, login),
sets = user.emote_sets;
if ( sets.indexOf(set_id) === -1 )
sets.push(set_id);
}
this.log.info(`Added "${emote_set ? emote_set.title : set_id}" emote set to ${users.length} users.`);
}
}
load_set_data(set_id, data) {
const old_set = this.emote_sets[set_id];
if ( ! data ) {
if ( old_set )
this.emote_sets[set_id] = null;
return;
}
this.emote_sets[set_id] = data;
data.users = old_set ? old_set.users : 0;
let count = 0;
const ems = data.emotes || data.emoticons,
new_ems = data.emotes = {},
css = [];
data.emoticons = undefined;
for(const emote of ems) {
emote.set_id = set_id;
emote.srcSet = `${emote.urls[1]} 1x`;
if ( emote.urls[2] )
emote.srcSet += `, ${emote.urls[2]} 2x`;
if ( emote.urls[4] )
emote.srcSet += `, ${emote.urls[4]} 4x`;
emote.token = {
type: 'emote',
id: emote.id,
set: set_id,
provider: 'ffz',
src: emote.urls[1],
srcSet: emote.srcSet,
text: emote.hidden ? '???' : emote.name,
length: emote.name.length
};
if ( has(MODIFIERS, emote.id) )
Object.assign(emote, MODIFIERS[emote.id]);
const emote_css = this.generateEmoteCSS(emote);
if ( emote_css )
css.push(emote_css);
count++;
new_ems[emote.id] = emote;
}
data.count = count;
if ( this.style && (css.length || data.css) )
this.style.set(`es--${set_id}`, css.join('') + (data.css || ''));
else if ( css.length )
data.pending_css = css.join('');
this.log.info(`Updated emotes for set #${set_id}: ${data.title}`);
this.emit(':loaded', set_id, data);
}
// ========================================================================
// Emote CSS
// ========================================================================
generateEmoteCSS(emote) { // eslint-disable-line class-methods-use-this
if ( ! emote.margins && ( ! emote.modifier || ( ! emote.modifier_offset && ! emote.extra_width && ! emote.shrink_to_fit ) ) && ! emote.css )
return '';
let output = '';
if ( emote.modifier && (emote.modifier_offset || emote.margins || emote.extra_width || emote.shrink_to_fit) ) {
let margins = emote.modifier_offset || emote.margins || '0';
margins = margins.split(/\s+/).map(x => parseInt(x, 10));
if ( margins.length === 3 )
margins.push(margins[1]);
const l = margins.length,
m_top = margins[0 % l],
m_right = margins[1 % l],
m_bottom = margins[2 % l],
m_left = margins[3 % l];
output = `.modified-emote span .ffz-emote[data-id="${emote.id}"] {
padding: ${m_top}px ${m_right}px ${m_bottom}px ${m_left}px;
${emote.shrink_to_fit ? `max-width: calc(100% - ${40 - m_left - m_right - (emote.extra_width||0)}px);` : ''}
margin: 0 !important;
}`;
}
return `${output}.ffz-emote[data-id="${emote.id}"] {
${(emote.margins && ! emote.modifier) ? `margin: ${emote.margins} !important;` : ''}
${emote.css||''}
}`;
}
// ========================================================================
// Emoji
// ========================================================================
load_emoji_data() {
this.log.debug('Unimplemented: load_emoji_data');
}
// ========================================================================
// Twitch Data Lookup
// ========================================================================
refresh_twitch_inventory() {
this.log.debug('Unimplemented: refresh_twitch_inventory');
}
twitch_emote_to_set(emote_id, callback) {
const tes = this.__twitch_emote_to_set;
if ( isNaN(emote_id) || ! isFinite(emote_id) )
return null;
if ( tes.has(emote_id) )
return tes.get(emote_id);
tes.set(emote_id, null);
timeout(this.socket.call('get_emote', emote_id), 1000).then(data => {
const set_id = data['s_id'];
tes.set(emote_id, set_id);
this.__twitch_set_to_channel.set(set_id, data);
if ( callback )
callback(data['s_id']);
}).catch(() => tes.delete(emote_id));
}
twitch_set_to_channel(set_id, callback) {
const tes = this.__twitch_set_to_channel;
if ( isNaN(set_id) || ! isFinite(set_id) )
return null;
if ( tes.has(set_id) )
return tes.get(set_id);
tes.set(set_id, null);
timeout(this.socket.call('get_emote_set', set_id), 1000).then(data => {
tes.set(set_id, data);
if ( callback )
callback(data);
}).catch(() => tes.delete(set_id));
}
}

513
src/modules/chat/index.js Normal file
View file

@ -0,0 +1,513 @@
'use strict';
// ============================================================================
// Chat
// ============================================================================
import {IS_WEBKIT} from 'utilities/constants';
const WEBKIT = IS_WEBKIT ? '-webkit-' : '';
import Module from 'utilities/module';
import {createElement, ManagedStyle} from 'utilities/dom';
import {timeout, has} from 'utilities/object';
import Badges from './badges';
import Emotes from './emotes';
import Room from './room';
import * as TOKENIZERS from './tokenizers';
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 = [];
// ========================================================================
// Settings
// ========================================================================
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-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)'}
]
}
});
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 key in this.rooms)
if ( this.rooms[key] )
this.rooms[key].updateBitsCSS();
});
this.context.on('changed:chat.bits.animated', () => {
for(const key in this.rooms)
if ( this.rooms[key] )
this.rooms[key].updateBitsCSS();
});
}
onEnable() {
for(const key in TOKENIZERS)
if ( has(TOKENIZERS, key) )
this.addTokenizer(TOKENIZERS[key]);
}
getBadge(badge, version, room) {
let b;
if ( this.room_ids[room] ) {
const versions = this.room_ids[room].badges.get(badge);
b = versions && versions.get(version);
}
if ( ! b ) {
const versions = this.badges.get(badge);
b = versions && versions.get(version);
}
return b;
}
updateBadges(badges) {
this.badges = badges;
this.updateBadgeCSS();
}
updateBadgeCSS() {
if ( ! this.badges )
this.style.delete('badges');
const out = [];
for(const [key, versions] of this.badges)
for(const [version, data] of versions) {
out.push(`.ffz-badge.badge--${key}.version--${version} {
background-color: transparent;
filter: none;
${WEBKIT}mask-image: none;
background-image: url("${data.image1x}");
background-image: ${WEBKIT}image-set(
url("${data.image1x}") 1x,
url("${data.image2x}") 2x,
url("${data.image4x}") 4x
);
}`)
}
this.style.set('badges', out.join('\n'));
}
getUser(id, login, no_create, no_login) {
let user;
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 = {id, login, badges: [], emote_sets: []};
if ( id && id !== user.id ) {
// If the ID isn't what we expected, something is very wrong here.
// Blame name changes.
if ( user.id )
throw new Error('id mismatch');
// 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 !== 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) {
let room;
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 )
throw new Error('id mismatch');
// 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 !== 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;
}
formatTime(time) {
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');
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 )
this.tooltips.types[type] = tokenizer.tooltip.bind(this);
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.sort;
});
}
tokenizeString(message, msg) {
let tokens = [{type: 'text', text: message}];
for(const tokenizer of this.__tokenizers)
tokens = tokenizer.process.call(this, tokens, msg);
return tokens;
}
tokenizeMessage(msg) {
let tokens = [{type: 'text', text: msg.message}];
for(const tokenizer of this.__tokenizers)
tokens = tokenizer.process.call(this, tokens, msg);
return tokens;
}
renderBadges(msg, e) { // eslint-disable-line class-methods-use-this
const out = [],
badges = msg.badges || {};
for(const key in badges)
if ( has(badges, key) ) {
const version = badges[key];
out.push(e('span', {
className: `ffz-tooltip ffz-badge badge--${key} version--${version}`,
'data-tooltip-type': 'badge',
'data-badge': key,
'data-version': version
}))
}
return out;
}
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 = token.text;
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],
expires = info && info[1];
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));
});
}
}

216
src/modules/chat/room.js Normal file
View file

@ -0,0 +1,216 @@
'use strict';
// ============================================================================
// Room
// ============================================================================
import {API_SERVER, IS_WEBKIT} from 'utilities/constants';
import {EventEmitter} from 'utilities/events';
import {createElement as e, ManagedStyle} from 'utilities/dom';
import {has} from 'utilities/object';
const WEBKIT = IS_WEBKIT ? '-webkit-' : '';
export default class Room extends EventEmitter {
constructor(manager, id, login) {
super();
this._destroy_timer = null;
this.manager = manager;
this.id = id;
this.login = login;
if ( login )
this.manager.rooms[login] = this;
if ( id )
this.manager.room_ids[id] = this;
this.refs = new Set;
this.style = new ManagedStyle(`room--${login}`);
this.emote_sets = [];
this.users = [];
this.user_ids = [];
this.load_data();
}
destroy() {
clearTimeout(this._destroy_timer);
this._destroy_timer = null;
this.destroyed = true;
this.style.destroy();
if ( this.manager.room_ids[this.id] === this )
this.manager.room_ids[this.id] = null;
if ( this.manager.rooms[this.login] === this )
this.manager.rooms[this.login] = null;
}
// ========================================================================
// FFZ Data
// ========================================================================
async load_data(tries = 0) {
if ( this.destroyed )
return;
let response, data;
try {
response = await fetch(`${API_SERVER}/v1/room/${this.login}`);
} catch(err) {
tries++;
if ( tries < 10 )
return setTimeout(() => this.load_data(tries), 500 * tries);
this.manager.log.error(`Error loading room data for ${this.id}:${this.login}`, err);
return false;
}
if ( ! response.ok )
return false;
try {
data = await response.json();
} catch(err) {
this.manager.log.error(`Error parsing room data for ${this.id}:${this.login}`, err);
return false;
}
const d = this.data = data.room;
if ( d.set && this.emote_sets.indexOf(d) === -1 )
this.emote_sets.push(d.set);
if ( data.sets )
for(const set_id in data.sets)
if ( has(data.sets, set_id) )
this.manager.emotes.load_set_data(set_id, data.sets[set_id]);
// TODO: User data.
// TODO: Generate CSS.
return true;
}
// ========================================================================
// Life Cycle
// ========================================================================
ref(referrer) {
clearTimeout(this._destroy_timer);
this._destroy_timer = null;
this.refs.add(referrer);
}
unref(referrer) {
this.refs.delete(referrer);
if ( ! this.users.size && ! this._destroy_timer )
this._destroy_timer = setTimeout(() => this.destroy(), 5000);
}
// ========================================================================
// Badge Data
// ========================================================================
updateBadges(badges) {
this.badges = badges;
this.updateBadgeCSS();
}
updateBadgeCSS() {
if ( ! this.badges )
return this.style.delete('badges');
const out = [],
id = this.id;
for(const [key, versions] of this.badges) {
for(const [version, data] of versions) {
const rule = `.ffz-badge.badge--${key}.version--${version}`;
out.push(`[data-room-id="${id}"] ${rule} {
background-color: transparent;
filter: none;
${WEBKIT}mask-image: none;
background-image: url("${data.image1x}");
background-image: ${WEBKIT}image-set(
url("${data.image1x}") 1x,
url("${data.image2x}") 2x,
url("${data.image4x}") 4x
);
}`);
}
}
this.style.set('badges', out.join('\n'));
}
// ========================================================================
// Bits Data
// ========================================================================
updateBitsConfig(config) {
this.bitsConfig = config;
this.updateBitsCSS();
}
updateBitsCSS() {
if ( ! this.bitsConfig )
return this.style.delete('bits');
const animated = this.manager.context.get('chat.bits.animated') ? 'animated' : 'static',
is_dark = this.manager.context.get('theme.is-dark'),
theme = is_dark ? 'DARK' : 'LIGHT',
antitheme = is_dark ? 'LIGHT' : 'DARK',
out = [];
for(const key in this.bitsConfig)
if ( has(this.bitsConfig, key) ) {
const action = this.bitsConfig[key],
prefix = action.prefix.toLowerCase(),
tiers = action.tiers,
l = tiers.length;
for(let i=0; i < l; i++) {
const images = tiers[i].images[theme][animated],
anti_images = tiers[i].images[antitheme][animated];
out.push(`.ffz-cheer[data-prefix="${prefix}"][data-tier="${i}"] {
color: ${tiers[i].color};
background-image: url("${images[1]}");
background-image: ${WEBKIT}image-set(
url("${images[1]}") 1x,
url("${images[2]}") 2x,
url("${images[4]}") 4x
);
}
.ffz__tooltip .ffz-cheer[data-prefix="${prefix}"][data-tier="${i}"] {
background-image: url("${anti_images[1]}");
background-image: ${WEBKIT}image-set(
url("${anti_images[1]}") 1x,
url("${anti_images[2]}") 2x,
url("${anti_images[4]}") 4x
);
}
.ffz-cheer-preview[data-prefix="${prefix}"][data-tier="${i}"] {
background-image: url("${anti_images[4]}");
}`)
}
}
this.style.set('bits', out.join('\n'));
}
}

View file

@ -0,0 +1,702 @@
'use strict';
// ============================================================================
// Default Tokenizers
// ============================================================================
import {sanitize, createElement as e} from 'utilities/dom';
import {has} from 'utilities/object';
const EMOTE_CLASS = 'chat-line__message--emote',
LINK_REGEX = /([^\w@#%\-+=:~])?((?:(https?:\/\/)?(?:[\w@#%\-+=:~]+\.)+[a-z]{2,6}(?:\/[\w.\/@#%&()\-+=:?~]*)?))([^\w.\/@#%&()\-+=:?~]|\s|$)/g,
MENTION_REGEX = /([^\w@#%\-+=:~])?(@([^\u0000-\u007F]+|\w+)+)([^\w.\/@#%&()\-+=:?~]|\s|$)/g,
SPLIT_REGEX = /[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g,
TWITCH_BASE = '//static-cdn.jtvnw.net/emoticons/v1/';
function split_chars(str) {
return str.match(SPLIT_REGEX);
}
// ============================================================================
// Links
// ============================================================================
const TOOLTIP_VERSION = 4;
export const Links = {
type: 'link',
priority: 50,
render(token, e) {
return e('a', {
className: 'ffz-tooltip',
'data-tooltip-type': 'link',
'data-url': token.url,
'data-is-mail': token.is_mail,
rel: 'noopener',
target: '_blank',
href: token.url
}, token.text);
},
tooltip(target, tip) {
if ( ! this.context.get('tooltip.rich-links') )
return '';
if ( target.dataset.isMail === 'true' )
return [this.i18n.t('tooltip.email-link', 'E-Mail %{address}', {address: target.textContent})];
return this.get_link_info(target.dataset.url).then(data => {
if ( ! data || (data.v || 1) > TOOLTIP_VERSION )
return '';
let content = data.content || data.html || '';
// TODO: Replace timestamps.
if ( data.urls && data.urls.length > 1 )
content += (content.length ? '<hr>' : '') +
sanitize(this.i18n.t(
'tooltip.link-destination',
'Destination: %{url}',
{url: data.urls[data.urls.length-1]}
));
if ( data.unsafe ) {
const reasons = Array.from(new Set(data.urls.map(x => x[2]).filter(x => x))).join(', ');
content = this.i18n.t(
'tooltip.link-unsafe',
"Caution: This URL is on Google's Safe Browsing List for: %{reasons}",
{reasons: sanitize(reasons.toLowerCase())}
) + (content.length ? `<hr>${content}` : '');
}
const show_image = this.context.get('tooltip.link-images') && (data.image_safe || this.context.get('tooltip.link-nsfw-images'));
if ( show_image ) {
if ( data.image && ! data.image_iframe )
content = `<img class="preview-image" src="${sanitize(data.image)}">${content}`
setTimeout(() => {
if ( tip.element )
for(const el of tip.element.querySelectorAll('video,img'))
el.addEventListener('load', tip.update)
});
} else if ( content.length )
content = content.replace(/<!--MS-->.*<!--ME-->/g, '');
if ( data.tooltip_class )
tip.element.classList.add(data.tooltip_class);
return content;
}).catch(error =>
sanitize(this.i18n.t('tooltip.error', 'An error occurred. (%{error})', {error}))
);
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length )
return tokens;
const out = [];
for(const token of tokens) {
if ( token.type !== 'text' ) {
out.push(token);
continue;
}
LINK_REGEX.lastIndex = 0;
const text = token.text;
let idx = 0, match;
while((match = LINK_REGEX.exec(text))) {
const nix = match.index + (match[1] ? match[1].length : 0);
if ( idx !== nix )
out.push({type: 'text', text: text.slice(idx, nix)});
const is_mail = ! match[3] && match[2].indexOf('/') === -1 && match[2].indexOf('@') !== -1;
out.push({
type: 'link',
url: (match[3] ? '' : is_mail ? 'mailto:' : 'https://') + match[2],
is_mail,
text: match[2]
});
idx = nix + match[2].length;
}
if ( idx < text.length )
out.push({type: 'text', text: text.slice(idx)});
}
return out;
}
}
// ============================================================================
// Rich Content
// ============================================================================
/*export const RichContent = {
type: 'rich-content',
render(token, e) {
return e('div', {
className: 'ffz--rich-content elevation-1 mg-y-05',
}, e('a', {
className: 'clips-chat-card flex flex-nowrap pd-05',
target: '_blank',
href: token.url
}, [
e('div', {
className: 'clips-chat-card__thumb align-items-center flex justify-content-center'
})
]));
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length )
return tokens;
for(const token of tokens) {
if ( token.type !== 'link' )
continue;
}
}
}*/
// ============================================================================
// Mentions
// ============================================================================
export const Mentions = {
type: 'mention',
priority: 40,
render(token, e) {
return e('strong', {
className: 'chat-line__message-mention'
}, `@${token.recipient}`);
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length )
return tokens;
const out = [];
for(const token of tokens) {
if ( token.type !== 'text' ) {
out.push(token);
continue;
}
MENTION_REGEX.lastIndex = 0;
const text = token.text;
let idx = 0, match;
while((match = MENTION_REGEX.exec(text))) {
const nix = match.index + (match[1] ? match[1].length : 0);
if ( idx !== nix )
out.push({type: 'text', text: text.slice(idx, nix)});
out.push({
type: 'mention',
recipient: match[3],
length: match[3].length + 1
});
idx = nix + match[2].length;
}
if ( idx < text.length )
out.push({type: 'text', text: text.slice(idx)});
}
return out;
}
}
// ============================================================================
// Cheers
// ============================================================================
export const CheerEmotes = {
type: 'cheer',
render(token, e) {
return e('span', {
className: `ffz-cheer ffz-tooltip`,
'data-tooltip-type': 'cheer',
'data-prefix': token.prefix,
'data-amount': this.i18n.formatNumber(token.amount),
'data-tier': token.tier,
'data-individuals': token.individuals ? JSON.stringify(token.individuals) : 'null',
alt: token.text
});
},
tooltip(target) {
const ds = target.dataset,
amount = parseInt(ds.amount.replace(/,/g, ''), 10),
prefix = ds.prefix,
tier = ds.tier,
individuals = ds.individuals && JSON.parse(ds.individuals),
length = individuals && individuals.length;
const out = [
this.context.get('tooltip.emote-images') && e('div', {
className: 'preview-image ffz-cheer-preview',
'data-prefix': prefix,
'data-tier': tier
}),
this.i18n.t('tooltip.bits', '%{count|number} Bits', amount),
];
if ( length > 1 ) {
out.push(e('br'));
individuals.sort(i => -i[0]);
for(let i=0; i < length && i < 12; i++) {
const [amount, tier, prefix] = individuals[i];
out.push(this.tokenizers.cheer.render.call(this, {
amount,
prefix,
tier
}, e));
}
if ( length > 12 ) {
out.push(e('br'));
out.push(this.i18n.t('tooltip.bits.more', '(and %{count} more)', length-12));
}
}
return out;
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length || ! msg.bits )
return tokens;
const SiteChat = this.resolve('site.chat'),
chat = SiteChat && SiteChat.currentChat,
bitsConfig = chat && chat.props.bitsConfig;
if ( ! bitsConfig )
return tokens;
const actions = bitsConfig.indexedActions,
matcher = new RegExp('\\b(' + Object.keys(actions).join('|') + ')(\\d+)\\b', 'ig');
const out = [],
collected = {},
collect = this.context.get('chat.bits.stack');
for(const token of tokens) {
if ( token.type !== 'text' ) {
out.push(token);
continue;
}
matcher.lastIndex = 0;
const text = token.text;
let idx = 0, match;
while((match = matcher.exec(text))) {
const prefix = match[1].toLowerCase(),
cheer = actions[prefix];
if ( ! cheer )
continue;
if ( idx !== match.index )
out.push({type: 'text', text: text.slice(idx, match.index)});
const amount = parseInt(match[2], 10),
tiers = cheer.orderedTiers;
let tier, token;
for(let i=0, l = tiers.length; i < l; i++)
if ( amount >= tiers[i].bits ) {
tier = i;
break;
}
out.push(token = {
type: 'cheer',
prefix,
tier,
amount: parseInt(match[2], 10),
text: match[0]
});
if ( collect ) {
const pref = collect === 2 ? 'cheer' : prefix,
group = collected[pref] = collected[pref] || {total: 0, individuals: []};
group.total += amount;
group.individuals.push([amount, tier, prefix]);
token.hidden = true;
}
idx = match.index + match[0].length;
}
if ( idx < text.length )
out.push({type: 'text', text: text.slice(idx)});
}
if ( collect ) {
for(const prefix in collected)
if ( has(collected, prefix) ) {
const cheers = collected[prefix],
cheer = actions[prefix],
tiers = cheer.orderedTiers;
let tier = 0;
for(let l = tiers.length; tier < l; tier++)
if ( cheers.total >= tiers[tier].bits )
break;
out.unshift({
type: 'cheer',
prefix,
tier,
amount: cheers.total,
individuals: cheers.individuals,
length: 0
});
}
}
return out;
}
}
// ============================================================================
// Addon Emotes
// ============================================================================
export const AddonEmotes = {
type: 'emote',
render(token, e) {
const mods = token.modifiers || [], ml = mods.length,
emote = e('img', {
className: `${EMOTE_CLASS} ffz-tooltip${token.provider === 'ffz' ? ' ffz-emote' : ''}`,
src: token.src,
srcSet: token.srcSet,
alt: token.text,
'data-tooltip-type': 'emote',
'data-provider': token.provider,
'data-id': token.id,
'data-set': token.set,
'data-modifiers': ml ? mods.map(x => x.id).join(' ') : null,
'data-modifier-info': ml ? JSON.stringify(mods.map(x => [x.set, x.id])) : null
});
if ( ! ml )
return emote;
return e('span', {
className: `${EMOTE_CLASS} modified-emote`,
'data-provider': token.provider,
'data-id': token.id,
'data-set': token.set
}, [
emote,
mods.map(t => e('span', null, this.tokenizers.emote.render(t, e)))
]);
},
tooltip(target, tip) {
const provider = target.dataset.provider,
modifiers = target.dataset.modifierInfo;
let preview, source, owner, mods;
if ( modifiers && modifiers !== 'null' ) {
mods = JSON.parse(modifiers).map(([set_id, emote_id]) => {
const emote_set = this.emotes.emote_sets[set_id],
emote = emote_set && emote_set.emotes[emote_id];
if ( emote )
return e('span', null, [
this.tokenizers.emote.render(emote.token, e),
` - ${emote.hidden ? '???' : emote.name}`
]);
})
}
if ( provider === 'twitch' ) {
const emote_id = parseInt(target.dataset.id, 10),
set_id = this.emotes.twitch_emote_to_set(emote_id, tip.rerender),
emote_set = set_id != null && this.emotes.twitch_set_to_channel(set_id, tip.rerender);
preview = `//static-cdn.jtvnw.net/emoticons/v1/${emote_id}/4.0?_=preview`;
if ( emote_set ) {
source = emote_set.c_name;
if ( source === '--global--' || emote_id === 80393 )
source = this.i18n.t('emote.global', 'Twitch Global');
else if ( source === '--twitch-turbo--' || source === 'turbo' || source === '--turbo-faces--' )
source = this.i18n.t('emote.turbo', 'Twitch Turbo');
else if ( source === '--prime--' || source === '--prime-faces--' )
source = this.i18n.t('emote.prime', 'Twitch Prime');
else
source = this.i18n.t('tooltip.channel', 'Channel: %{source}', {source});
}
} else if ( provider === 'ffz' ) {
const emote_set = this.emotes.emote_sets[target.dataset.set],
emote = emote_set && emote_set.emotes[target.dataset.id];
if ( emote_set )
source = emote_set.source_line || (`${emote_set.source || 'FFZ'} ${emote_set.title || 'Global'}`);
if ( emote ) {
if ( emote.owner )
owner = this.i18n.t(
'emote.owner', 'By: %{owner}',
{owner: emote.owner.display_name});
if ( emote.urls[4] )
preview = emote.urls[4];
else if ( emote.urls[2] )
preview = emote.urls[2];
}
}
return [
preview && this.context.get('tooltip.emote-images') && e('img', {
className: 'preview-image',
src: preview,
onLoad: tip.update
}),
this.i18n.t('tooltip.emote', 'Emote: %{code}', {code: target.alt}),
source && this.context.get('tooltip.emote-sources') && e('div', {
className: 'pd-t-05',
}, source),
owner && this.context.get('tooltip.emote-sources') && e('div', {
className: 'pd-t-05'
}, owner),
mods && e('div', {
className: 'pd-t-1'
}, mods)
];
},
process(tokens, msg) {
if ( ! tokens || ! tokens.length )
return tokens;
const applicable_sets = this.emotes.getSets(
msg.user.userID,
msg.user.userLogin,
msg.roomID,
msg.roomLogin
),
emotes = {},
out = [];
if ( ! applicable_sets || ! applicable_sets.length )
return tokens;
for(const emote_set of applicable_sets)
if ( emote_set && emote_set.emotes )
for(const emote_id in emote_set.emotes ) { // eslint-disable-line guard-for-in
const emote = emote_set.emotes[emote_id];
if ( ! has(emotes, emote.name) )
emotes[emote.name] = emote;
}
let last_token, emote;
for(const token of tokens) {
if ( ! token )
continue;
if ( token.type !== 'text' ) {
if ( token.type === 'emote' && ! token.modifiers )
token.modifiers = [];
out.push(token);
last_token = token;
continue;
}
let text = [];
for(const segment of token.text.split(/ +/)) {
if ( has(emotes, segment) ) {
emote = emotes[segment];
// Is this emote a modifier?
if ( emote.modifier && last_token && last_token.modifiers && (!text.length || (text.length === 1 && text[0] === '')) ) {
if ( last_token.modifiers.indexOf(emote.token) === -1 )
last_token.modifiers.push(emote.token);
continue;
}
if ( text.length ) {
// We have pending text. Join it together, with an extra space.
const t = {type: 'text', text: text.join(' ') + ' '};
out.push(t);
if ( t.text.trim().length )
last_token = t;
text = [];
}
const t = Object.assign({modifiers: []}, emote.token);
out.push(t);
last_token = t;
text.push('');
} else
text.push(segment);
}
if ( text.length > 1 || (text.length === 1 && text[0] !== '') )
out.push({type: 'text', text: text.join(' ')});
}
return out;
}
}
// ============================================================================
// Twitch Emotes
// ============================================================================
export const TwitchEmotes = {
type: 'twitch-emote',
priority: 10,
process(tokens, msg) {
if ( ! msg.emotes )
return tokens;
const data = msg.emotes,
emotes = [];
for(const emote_id in data)
if ( has(data, emote_id) ) {
for(const match of data[emote_id])
emotes.push([emote_id, match.startIndex, match.endIndex + 1]);
}
const out = [],
e_length = emotes.length;
if ( ! e_length )
return tokens;
emotes.sort((a,b) => a[1] - b[1]);
let idx = 0,
eix = 0;
for(const token of tokens) {
const length = token.length || (token.text && split_chars(token.text).length) || 0,
t_start = idx,
t_end = idx + length;
if ( token.type !== 'text' ) {
out.push(token);
idx = t_end;
continue;
}
const text = split_chars(token.text);
while( eix < e_length ) {
const [e_id, e_start, e_end] = emotes[eix];
// Does this emote go outside the bounds of this token?
if ( e_start > t_end || e_end > t_end ) {
// Output the remainder of this token.
if ( t_start === idx )
out.push(token);
else
out.push({
type: 'text',
text: text.slice(idx - t_start).join('')
});
// If this emote goes across token boundaries,
// skip it.
if ( e_start < t_end && e_end > t_end )
eix++;
idx = t_end;
break;
}
// If there's text at the beginning of the token that
// isn't part of this emote, output it.
if ( e_start > idx )
out.push({
type: 'text',
text: text.slice(idx - t_start, e_start - t_start).join('')
});
out.push({
type: 'emote',
id: e_id,
provider: 'twitch',
src: `${TWITCH_BASE}${e_id}/1.0`,
srcSet: `${TWITCH_BASE}${e_id}/1.0 1x, ${TWITCH_BASE}${e_id}/2.0 2x`,
text: text.slice(e_start - t_start, e_end - t_start).join(''),
modifiers: []
});
idx = e_end;
eix++;
}
// We've finished processing emotes. If there is any
// remaining text in the token, push it out.
if ( idx < t_end ) {
if ( t_start === idx )
out.push(token);
else
out.push({
type: 'text',
text: text.slice(idx - t_start).join('')
});
idx = t_end;
}
}
return out;
}
}

View file

@ -0,0 +1,3 @@
'use strict';
export default require.context('./components', false, /\.vue$/);

View file

@ -0,0 +1,50 @@
<template lang="html">
<div class="ffz--changelog border-t pd-t-1">
<div class="align-center">
<h2>{{ t('home.changelog', 'Changelog') }}</h2>
</div>
<div ref="changes" />
</div>
</template>
<script>
import {SERVER} from 'utilities/constants';
export default {
props: ['item', 'context'],
methods: {
fetch(url, container) {
const done = data => {
if ( ! data )
data = 'There was an error loading this page from the server.';
container.innerHTML = data;
const btn = container.querySelector('#ffz-old-news-button');
if ( btn )
btn.addEventListener('click', () => {
btn.parentElement.removeChild(btn);
const old_news = container.querySelector('#ffz-old-news');
if ( old_news )
this.fetch(`${SERVER}/script/old_changes.html`, old_news);
});
}
fetch(url)
.then(resp => resp.ok ? resp.text() : null)
.then(done)
.catch(err => done(null));
}
},
mounted() {
this.fetch(`${SERVER}/script/changelog.html`, this.$refs.changes);
}
}
</script>

View file

@ -0,0 +1,35 @@
<template lang="html">
<div class="ffz--home border-t pd-t-1">
<h2>Feedback</h2>
<div class="mg-y-1 c-background-accent c-text-overlay pd-1">
<h3 class="ffz-i-attention">
Please keep in mind that FrankerFaceZ v4 is under heavy development.
</h3>
</div>
<p>
Okay, still here? Great! You can provide feedback and bug reports by
<a href="https://github.com/FrankerFaceZ/FrankerFaceZ/issues" target="_blank" rel="noopener">
opening an issue at our GitHub repository</a>.
You can also <a href="https://twitter.com/FrankerFaceZ" target="_blank" rel="noopener">
tweet at us</a>.
</p>
<p>
When creating a GitHub issue, please check that someone else hasn't
already created one for what you'd like to discuss or report.
</p>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
}
</script>

View file

@ -0,0 +1,67 @@
<template lang="html">
<div class="ffz--widget ffz--filter-editor">
<div ref="list" class="ffz--rule-list">
<section v-for="(rule, idx) in rules">
<div
class="ffz--rule elevation-1 c-background border mg-b-05 pd-y-05 pd-r-1 flex flex--nowrap align-items-start"
tabindex="0"
>
<div class="flex flex-shrink-0 align-items-center handle pd-x-05 pd-y-1">
<span class="ffz-i-ellipsis-vert" />
</div>
<div class="flex-shrink-0 pd-y-05">
Channel
</div>
<div class="mg-x-1 flex flex-grow-1">
<div class="flex-shrink-0 mg-r-1">
<select class="tw-select">
<option>is one of</option>
<option>is not one of</option>
</select>
</div>
<div class="flex-grow-1">
<input
type="text"
class="tw-input"
value="SirStendec"
/>
</div>
</div>
<div class="flex flex-shrink-0 align-items-center">
<button class="tw-button tw-button--text" @click="del(idx)">
<span class="tw-button__text ffz-i-trash">
{{ t('setting.filters.delete', 'Delete') }}
</span>
</button>
</div>
</div>
</section>
</div>
<button
class="tw-button tw-button--hollow mg-y-1 full-width"
@click="newRule"
>
<span class="tw-button__text ffz-i-plus">
{{ t('', 'Add New Rule') }}
</span>
</button>
</div>
</template>
<script>
export default {
props: ['filters', 'rules', 'context'],
methods: {
newRule() {
}
}
}
</script>

View file

@ -0,0 +1,100 @@
<template lang="html">
<div class="ffz--home flex flex--nowrap">
<div class="flex-grow-1">
<div class="align-center">
<h1 class="ffz-i-zreknarf ffz-i-pd-1">FrankerFaceZ</h1>
<span class="c-text-alt">
{{ t('home.tag-line', 'The Twitch Enhancement Suite') }}
</span>
</div>
<section class="pd-t-1 border-t mg-t-1">
<h2>Welcome to the v4.0 Beta</h2>
<p>
This is the initial, beta release of FrankerFaceZ v4.0 with support
for the Twitch website rewrite.
As you'll notice, this release is <strong>not</strong> complete.
There are missing features. There are bugs. If you are a moderator,
you will want to just keep opening a Legacy Chat Popout for now.
</p>
<p>
FrankerFaceZ v4.0 is still under heavy development and there will
be significant changes and improvements in the coming weeks. For
now, here are some of the bigger issues:
</p>
<ul class="mg-b-2">
<li>Settings from the old version are not being imported.</li>
<li>Settings cannot be searched.</li>
<li>FFZ badges do not display.</li>
<li>Oh god everything is missing.</li>
<li>FFZ:AP is broken.</li>
<li>Uptime breaks occasionally.</li>
</ul>
<p>And the biggest features still under development:</p>
<ul class="mg-b-2">
<li>Dark Theme (Pls No Purple)</li>
<li>Chat Pause on Hover</li>
<li>Badge Customization</li>
<li>Emoji Rendering</li>
<li>Emotes Menu</li>
<li>Chat Filtering (Highlighted Words, etc.)</li>
<li>Room Status Indicators</li>
<li>Custom Mod Cards</li>
<li>Custom Mod Actions</li>
<li>Chat Room Tabs</li>
<li>Recent Highlights</li>
<li>More Channel Metadata</li>
<li>Disable Hosting</li>
<li>Portrait Mode</li>
<li>Hiding stuff in the directory</li>
<li>Directory Host Stacking</li>
<li>Basically anything to do with the directory</li>
<li>Importing and exporting settings</li>
<li>User Aliases</li>
<li>Rich Content in Chat (aka Clip Embeds)</li>
</ul>
<p>
For a possibly more up-to-date list of what I'm working on,
please consult <a href="https://trello.com/b/LGcYPFwi/frankerfacez-v4" target="_blank">this Trello board</a>.
</p>
</section>
</div>
<div class="mg-l-1 flex-shrink-0 tweet-column">
<a class="twitter-timeline" data-width="300" data-theme="dark" href="https://twitter.com/FrankerFaceZ?ref_src=twsrc%5Etfw">
Tweets by FrankerFaceZ
</a>
</div>
</div>
</template>
<script>
import {createElement as e} from 'utilities/dom';
export default {
props: ['item', 'context'],
mounted() {
let el;
document.head.appendChild(el = e('script', {
id: 'ffz--twitter-widget-script',
async: true,
charset: 'utf-8',
src: 'https://platform.twitter.com/widgets.js',
onLoad: () => el.parentElement.removeChild(el)
}));
}
}
</script>

View file

@ -0,0 +1,180 @@
<template lang="html">
<div class="ffz-main-menu elevation-3 c-background-alt border flex flex--nowrap flex-column" :class="{ maximized }">
<header class="c-background pd-1 pd-l-2 full-width align-items-center flex flex-nowrap" @dblclick="resize">
<h3 class="ffz-i-zreknarf ffz-i-pd-1">FrankerFaceZ</h3>
<div class="flex-grow-1 pd-x-2">
<!--div class="tw-search-input">
<label for="ffz-main-menu.search" class="hide-accessible">{{ t('main-menu.search', 'Search Settings') }}</label>
<div class="relative">
<div class="tw-input__icon-group">
<div class="tw-input__icon">
<figure class="ffz-i-search" />
</div>
</div>
<input type="search" class="tw-input tw-input--icon-left" :placeholder="t('main-menu.search', 'Search Settings')" autocapitalize="off" autocorrect="off" autocomplete="off" id="ffz-main-menu.search">
</div>
</div-->
</div>
<button class="tw-button-icon mg-x-05" @click="resize">
<span class="tw-button-icon__icon">
<figure :class="{'ffz-i-window-maximize': !maximized, 'ffz-i-window-restore': maximized}" />
</span>
</button>
<button class="tw-button-icon mg-x-05" @click="close">
<span class="tw-button-icon__icon">
<figure class="ffz-i-window-close" />
</span>
</button>
</header>
<section class="border-t full-height full-width flex flex--nowrap">
<nav class="ffz-vertical-nav c-background-alt-2 border-r full-height flex flex-column flex-shrink-0 flex-nowrap">
<header class="border-b pd-1">
<profile-selector
:context="context"
@navigate="navigate"
/>
</header>
<div class="full-width full-height overflow-hidden flex flex-nowrap relative">
<div class="ffz-vertical-nav__items full-width flex-grow-1 scrollable-area" data-simplebar>
<div class="simplebar-scroll-content">
<div class="simplebar-content">
<menu-tree
:currentItem="currentItem"
:modal="nav"
@change-item="changeItem"
@navigate="navigate"
/>
</div>
</div>
</div>
</div>
<footer class="c-text-alt border-t pd-1">
<div>
{{ t('main-menu.version', 'Version %{version}', {version: version.toString()}) }}
</div>
<div class="c-text-alt-2">
{{version.build}}
</div>
</footer>
</nav>
<main class="flex-grow-1 scrollable-area" data-simplebar>
<div class="simplebar-scroll-content">
<div class="simplebar-content">
<menu-page
ref="page"
:context="context"
:item="currentItem"
@change-item="changeItem"
@navigate="navigate"
v-if="currentItem"
/>
</div>
</div>
</main>
</section>
</div>
</template>
<script>
import displace from 'displacejs';
export default {
data() {
return this.$vnode.data;
},
created() {
this.context.context._add_user();
},
destroyed() {
this.context.context._remove_user();
},
methods: {
changeProfile() {
const new_id = this.$refs.profiles.value,
new_profile = this.context.profiles[new_id];
if ( new_profile )
this.context.currentProfile = new_profile;
},
changeItem(item) {
if ( this.$refs.page && this.$refs.page.onBeforeChange ) {
if ( this.$refs.page.onBeforeChange(this.currentItem, item) === false )
return;
}
this.currentItem = item;
let current = item;
while(current = current.parent)
current.expanded = true;
},
updateDrag() {
if ( this.maximized )
this.destroyDrag();
else
this.createDrag();
},
destroyDrag() {
if ( this.displace ) {
this.displace.destroy();
this.displace = null;
}
},
createDrag() {
this.$nextTick(() => {
if ( ! this.maximized )
this.displace = displace(this.$el, {
handle: this.$el.querySelector('header'),
highlightInputs: true,
constrain: true
});
})
},
handleResize() {
if ( this.displace )
this.displace.reinit();
},
navigate(key) {
let item = this.nav_keys[key];
while(item && item.page)
item = item.parent;
if ( ! item )
return;
this.changeItem(item);
}
},
watch: {
maximized() {
this.updateDrag();
}
},
mounted() {
this.updateDrag();
this._on_resize = this.handleResize.bind(this);
window.addEventListener('resize', this._on_resize);
},
beforeDestroy() {
this.destroyDrag();
if ( this._on_resize ) {
window.removeEventListener('resize', this._on_resize);
this._on_resize = null;
}
}
}
</script>

View file

@ -0,0 +1,34 @@
<template lang="html">
<div v-bind:class="classes" v-if="item.contents">
<header v-if="! item.no_header">
{{ t(item.i18n_key, item.title, item) }}
</header>
<section
v-if="item.description"
v-html="t(item.desc_i18n_key, item.description, item)"
class="pd-b-1"
/>
<component
v-for="i in item.contents"
v-bind:is="i.component"
:context="context"
:item="i"
:key="i.full_key"
/>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
computed: {
classes() {
return [
'ffz--menu-container',
this.item.full_box ? 'border' : 'border-t'
]
}
}
}
</script>

View file

@ -0,0 +1,89 @@
<template lang="html">
<div class="ffz--menu-page">
<header class="mg-b-1">
<template v-for="i in breadcrumbs">
<a v-if="i !== item" href="#" @click="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title, i) }}</a>
<strong v-if="i === item">{{ t(i.i18n_key, i.title, i) }}</strong>
<template v-if="i !== item">&raquo; </template>
</template>
</header>
<section v-if="! context.currentProfile.live && item.profile_warning !== false" class="border-t pd-t-1 pd-b-2">
<div class="c-background-accent c-text-overlay pd-1">
<h3 class="ffz-i-attention">
{{ t('setting.profiles.inactive', "This profile isn't active.") }}
</h3>
{{ t(
'setting.profiles.inactive.description',
"This profile's rules don't match the current context and it therefore isn't currently active, so you " +
"won't see changes you make here reflected on Twitch."
) }}
</div>
</section>
<section
v-if="item.description"
class="border-t pd-y-1"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</section>
<template v-if="! item.contents">
<ul class="border-t pd-y-1">
<li class="pd-x-1" v-for="i in item.items">
<a href="#" @click="$emit('change-item', i, false)">
{{ t(i.i18n_key, i.title, i) }}
</a>
</li>
</ul>
</template>
<component
v-for="i in item.contents"
v-bind:is="i.component"
ref="children"
:context="context"
:item="i"
:key="i.full_key"
@change-item="changeItem"
@navigate="navigate"
/>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
computed: {
breadcrumbs() {
const out = [];
let current = this.item;
while(current) {
out.unshift(current);
current = current.parent;
}
return out;
}
},
methods: {
changeItem(item) {
this.$emit('change-item', item);
},
navigate(...args) {
this.$emit('navigate', ...args);
},
onBeforeChange(current, new_item) {
for(const child of this.$refs.children)
if ( child && child.onBeforeChange ) {
const res = child.onBeforeChange(current, new_item);
if ( res !== undefined )
return res;
}
}
}
}
</script>

View file

@ -0,0 +1,165 @@
<template lang="html">
<ul
v-if="modal"
class="ffz--menu-tree"
:role="[root ? 'group' : 'tree']"
:tabindex="tabIndex"
@keyup.up="prevItem"
@keyup.down="nextItem"
@keyup.left="prevLevel"
@keyup.right="nextLevel"
@keyup.*="expandAll"
>
<li
v-for="item in modal"
:key="item.full_key"
:class="[currentItem === item ? 'active' : '']"
role="presentation"
>
<div
class="flex__item flex flex--nowrap align-items-center pd-y-05 pd-r-05"
role="treeitem"
:aria-expanded="item.expanded"
:aria-selected="currentItem === item"
@click="clickItem(item)"
>
<span
role="presentation"
class="arrow"
:class="[
item.items ? '' : 'ffz--invisible',
item.expanded ? 'ffz-i-down-dir' : 'ffz-i-right-dir'
]"
/>
<span class="flex-grow-1">
{{ t(item.i18n_key, item.title, item) }}
</span>
<span v-if="item.pill" class="pill">
{{ item.pill_i18n_key ? t(item.pill_i18n_key, item.pill, item) : item.pill }}
</span>
</div>
<menu-tree
:root="item"
:currentItem="currentItem"
:modal="item.items"
v-if="item.items && item.expanded"
@change-item="i => $emit('change-item', i)"
/>
</li>
</ul>
</template>
<script>
function findLastVisible(node) {
if ( node.expanded && node.items )
return findLastVisible(node.items[node.items.length - 1]);
return node;
}
function findNextVisible(node, modal) {
const items = node.parent ? node.parent.items : modal,
idx = items.indexOf(node);
if ( items[idx + 1] )
return items[idx+1];
if ( node.parent )
return findNextVisible(node.parent, modal);
return null;
}
function recursiveExpand(node) {
node.expanded = true;
if ( node.items )
for(const item of node.items)
recursiveExpand(item);
}
export default {
props: ['root', 'modal', 'currentItem'],
computed: {
tabIndex() {
return this.root ? undefined : 0;
}
},
methods: {
clickItem(item) {
if ( ! item.expanded )
item.expanded = true;
else if ( this.currentItem === item )
item.expanded = false;
this.$emit('change-item', item);
},
expandAll() {
for(const item of this.modal)
recursiveExpand(item);
},
prevItem() {
if ( this.root ) return;
const i = this.currentItem,
items = i.parent ? i.parent.items : this.modal,
idx = items.indexOf(i);
if ( idx > 0 )
this.$emit('change-item', findLastVisible(items[idx-1]));
else if ( i.parent )
this.$emit('change-item', i.parent);
},
nextItem(e) {
if ( this.root ) return;
const i = this.currentItem;
let target;
if ( i.expanded && i.items )
target = i.items[0];
else
target = findNextVisible(i, this.modal);
if ( target )
this.$emit('change-item', target);
},
prevLevel() {
if ( this.root ) return;
const i = this.currentItem;
if ( i.expanded && i.items )
i.expanded = false;
else if ( i.parent )
this.$emit('change-item', i.parent);
},
nextLevel() {
if ( this.root ) return;
const i = this.currentItem;
if ( i.expanded && i.items )
this.$emit('change-item', i.items[0]);
else
i.expanded = true;
if ( event.ctrlKey )
recursiveExpand(this.currentItem);
}
}
}
</script>

View file

@ -0,0 +1,205 @@
<template lang="html">
<div class="ffz--profile-editor">
<div class="flex align-items-center border-t pd-1">
<div class="flex-grow-1"></div>
<button
class="tw-button tw-button--text"
@click="save"
>
<span class="tw-button__text ffz-i-floppy">
{{ t('settings.profiles.save', 'Save') }}
</span>
</button>
<button
class="mg-l-1 tw-button tw-button--text"
:disabled="item.profile && context.profiles.length < 2"
@click="del"
>
<span class="tw-button__text ffz-i-trash">
{{ t('setting.profiles.delete', 'Delete') }}
</span>
</button>
<!--button class="mg-l-1 tw-button tw-button--text">
<span class="tw-button__text ffz-i-download">
{{ t('setting.profiles.export', 'Export') }}
</span>
</button-->
</div>
<div class="ffz--menu-container border-t">
<header>
{{ t('settings.data_management.profiles.edit.general', 'General') }}
</header>
<div class="ffz--widget flex flex--nowrap">
<label for="ffz:editor:name">
{{ t('settings.data_management.profiles.edit.name', 'Name') }}
</label>
<input
class="tw-input"
ref="name"
id="ffz:editor:name"
v-model="name"
/>
</div>
<div class="ffz--widget flex flex--nowrap">
<label for="ffz:editor:description">
{{ t('settings.data_management.profiles.edit.desc', 'Description') }}
</label>
<textarea
class="tw-input"
ref="desc"
id="ffz:editor:description"
v-model="desc"
/>
</div>
</div>
<div class="ffz--menu-container border-t">
<header>
{{ t('settings.data_management.profiles.edit.rules', 'Rules') }}
</header>
<section class="pd-b-1">
{{ t(
'settings.data_management.profiles.edit.rules.description',
'Rules allows you to define a series of conditions under which this profile will be active.'
) }}
</section>
<filter-editor
:filters="filters"
:rules="rules"
:context="test_context"
@change="unsaved = true"
/>
</div>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
data() {
return {
old_name: null,
old_desc: null,
name: null,
desc: null,
unsaved: false,
filters: null,
rules: null,
test_context: null
}
},
created() {
this.context.context.on('context_changed', this.updateContext, this);
this.updateContext();
this.revert();
},
beforeDestroy() {
this.context.context.off('context_changed', this.updateContext, this);
},
watch: {
name() {
if ( this.name !== this.old_name )
this.unsaved = true;
},
desc() {
if ( this.desc !== this.old_desc )
this.unsaved = true;
}
},
methods: {
revert() {
const profile = this.item.profile;
this.old_name = this.name = profile ?
profile.i18n_key ?
this.t(profile.i18n_key, profile.title, profile) :
profile.title :
'Unnamed Profile',
this.old_desc = this.desc = profile ?
profile.desc_i18n_key ?
this.t(profile.desc_i18n_key, profile.description, profile) :
profile.description :
'';
this.rules = profile ? profile.context : {};
this.unsaved = ! profile;
},
del() {
if ( this.item.profile || this.unsaved ) {
if ( ! confirm(this.t(
'settings.profiles.warn-delete',
'Are you sure you wish to delete this profile? It cannot be undone.'
)) )
return
if ( this.item.profile )
this.context.deleteProfile(this.item.profile);
}
this.unsaved = false;
this.$emit('navigate', 'data_management.profiles');
},
save() {
if ( ! this.item.profile ) {
this.item.profile = this.context.createProfile({
name: this.name,
description: this.desc
});
} else if ( this.unsaved ) {
const changes = {
name: this.name,
description: this.desc
};
// Disable i18n if required.
if ( this.name !== this.old_name )
changes.i18n_key = undefined;
if ( this.desc !== this.old_desc )
changes.desc_i18n_key = undefined;
this.item.profile.update(changes);
}
this.unsaved = false;
this.$emit('navigate', 'data_management.profiles');
},
updateContext() {
this.test_context = this.context.context.context;
},
onBeforeChange() {
if ( this.unsaved )
return confirm(
this.t(
'settings.warn-unsaved',
'You have unsaved changes. Are you sure you want to leave the editor?'
));
}
}
}
</script>

View file

@ -0,0 +1,129 @@
<template lang="html">
<div class="ffz--widget ffz--profile-manager border-t pd-y-1">
<div class="c-background-accent c-text-overlay pd-1 mg-b-1">
<h3 class="ffz-i-attention">
This feature is not yet finished.
</h3>
Creating and editing profiles is disabled until the rule editor is finished.
</div>
<div class="flex align-items-center pd-b-05">
<div class="flex-grow-1">
{{ t('setting.profiles.drag', 'Drag profiles to change their priority.') }}
</div>
<button class="mg-l-1 tw-button tw-button--text" disabled @notclick="edit()">
<span class="tw-button__text ffz-i-plus">
{{ t('setting.profiles.new', 'New Profile') }}
</span>
</button>
<!--button class="mg-l-1 tw-button tw-button--text">
<span class="tw-button__text ffz-i-upload">
{{ t('setting.profiles.import', 'Import…') }}
</span>
</button-->
</div>
<div ref="list" class="ffz--profile-list">
<section
v-for="p in context.profiles"
:key="p.id"
:data-profile="p.id"
>
<div
class="ffz--profile elevation-1 c-background border pd-y-05 pd-r-1 mg-y-05 flex flex--nowrap"
:class="{live: p.live}"
tabindex="0"
>
<div class="flex flex-shrink-0 align-items-center handle pd-x-05 pd-t-1 pd-b-05">
<span class="ffz-i-ellipsis-vert" />
</div>
<div class="flex-grow-1">
<h4>{{ t(p.i18n_key, p.title, p) }}</h4>
<div v-if="p.description" class="description">
{{ t(p.desc_i18n_key, p.description, p) }}
</div>
</div>
<div class="flex flex-shrink-0 align-items-center">
<button class="tw-button tw-button--text" disabled @notclick="edit(p)">
<span class="tw-button__text ffz-i-cog">
{{ t('setting.profiles.configure', 'Configure') }}
</span>
</button>
</div>
<div class="flex flex-shrink-0 align-items-center border-l mg-l-1 pd-l-1">
<div v-if="p.live" class="ffz--profile__icon ffz-i-ok tw-tooltip-wrapper">
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.profiles.active', 'This profile is active.') }}
</div>
</div>
<div v-if="! p.live" class="ffz--profile__icon ffz-i-cancel tw-tooltip-wrapper">
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.profiles.inactive', 'This profile is not active.') }}
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script>
import Sortable from 'sortablejs';
export default {
props: ['item', 'context'],
methods: {
edit(profile) {
const item = {
full_key: 'data_management.profiles.edit_profile',
key: 'edit_profile',
profile_warning: false,
title: `Edit Profile`,
i18n_key: 'setting.data_management.profiles.edit_profile',
parent: this.item.parent,
contents: [{
page: true,
profile,
component: 'profile-editor'
}]
};
item.contents[0].parent = item;
this.$emit('change-item', item);
}
},
mounted() {
this._sortable = Sortable.create(this.$refs.list, {
draggable: 'section',
filter: 'button',
onUpdate: (event) => {
const id = event.item.dataset.profile,
profile = this.context.profile_keys[id];
if ( profile )
profile.move(event.newIndex);
}
});
},
beforeDestroy() {
if ( this._sortable )
this._sortable.destroy();
this._sortable = null;
}
}
</script>

View file

@ -0,0 +1,218 @@
<template lang="html">
<div class="ffz--widget ffz--profile-selector">
<div
tabindex="0"
class="tw-select"
:class="{active: opened}"
ref="button"
@keyup.up.stop.prevent="focusShow"
@keyup.left.stop.prevent="focusShow"
@keyup.down.stop.prevent="focusShow"
@keyup.right.stop.prevent="focusShow"
@keyup.enter="focusShow"
@keyup.space="focusShow"
@click="togglePopup"
>
{{ t(context.currentProfile.i18n_key, context.currentProfile.title, context.currentProfile) }}
</div>
<div v-if="opened" v-on-clickaway="hide" class="tw-balloon block tw-balloon--lg tw-balloon--down tw-balloon--left">
<div
class="ffz--profile-list elevation-2 c-background-alt"
@keyup.escape="focusHide"
@focusin="focus"
@focusout="blur"
>
<div class="scrollable-area border-b" data-simplebar>
<div class="simplebar-scroll-content">
<div class="simplebar-content" ref="popup">
<div
v-for="(p, idx) in context.profiles"
tabindex="0"
class="ffz--profile-row relative border-b pd-y-05 pd-r-3 pd-l-1"
:class="{
live: p.live,
current: p === context.currentProfile
}"
@keydown.up.stop.prevent=""
@keydown.down.stop.prevent=""
@keydown.page-up.stop.prevent=""
@keydown.page-down.stop.prevent=""
@keyup.up.stop="prevItem"
@keyup.down.stop="nextItem"
@keyup.home="firstItem"
@keyup.end="lastItem"
@keyup.page-up.stop="prevPage"
@keyup.page-down.stop="nextPage"
@keyup.enter="changeProfile(p)"
@click="changeProfile(p)"
>
<div
v-if="p.live"
class="tw-tooltip-wrapper ffz--profile-row__icon ffz-i-ok absolute"
>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.profiles.active', 'This profile is active.') }}
</div>
</div>
<h4>{{ t(p.i18n_key, p.title, p) }}</h4>
<div v-if="p.description" class="description">
{{ t(p.desc_i18n_key, p.description, p) }}
</div>
</div>
</div>
</div>
</div>
<div class="pd-y-05 pd-x-05 align-right">
<button class="tw-button tw-button--text" @click="openConfigure">
<span class="tw-button__text ffz-i-cog">
{{ t('setting.profiles.configure', 'Configure') }}
</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { mixin as clickaway} from 'vue-clickaway';
const indexOf = Array.prototype.indexOf;
export default {
mixins: [clickaway],
props: ['context'],
data() {
return {
opened: false
}
},
methods: {
openConfigure() {
this.hide();
this.$emit('navigate', 'data_management.profiles');
},
focus() {
this._focused = true;
},
blur() {
this._focused = false;
if ( ! this._blur_timer )
this._blur_timer = setTimeout(() => {
this._blur_timer = null;
if ( ! this._focused && document.hasFocus() )
this.hide();
}, 10);
},
hide() {
this.opened = false;
},
show() {
if ( ! this.opened )
this.opened = true;
},
togglePopup() {
if ( this.opened )
this.hide();
else
this.show();
},
focusHide() {
this.hide();
this.$refs.button.focus();
},
focusShow() {
this.show();
this.$nextTick(() => this.$refs.popup.querySelector('.current').focus());
},
prevItem(e) {
const el = e.target.previousSibling;
if ( el ) {
this.scroll(el);
el.focus();
}
},
nextItem(e) {
const el = e.target.nextSibling;
if ( el ) {
this.scroll(el);
el.focus();
}
},
firstItem() {
const el = this.$refs.popup.firstElementChild;
if ( el ) {
this.scroll(el);
el.focus();
}
},
prevPage(e) {
this.select(indexOf.call(this.$refs.popup.children, e.target) - 5);
},
nextPage(e) {
this.select(indexOf.call(this.$refs.popup.children, e.target) + 5);
},
select(idx) {
const kids = this.$refs.popup.children,
el = kids[idx <= 0 ? 0 : Math.min(idx, kids.length - 1)];
if ( el ) {
this.scroll(el);
el.focus();
}
},
lastItem() {
const el = this.$refs.popup.lastElementChild;
if ( el ) {
this.scroll(el);
el.focus();
}
},
scroll(el) {
const scroller = this.$refs.popup.parentElement,
top = el.offsetTop,
bottom = el.offsetHeight + top,
// We need to use the margin-bottom because of the scrollbar library.
// In fact, the scrollbar library is why any of this function exists.
scroll_top = scroller.scrollTop,
scroll_bottom = scroller.offsetHeight + parseInt(scroller.style.marginBottom || 0, 10) + scroll_top;
if ( top < scroll_top )
scroller.scrollBy(0, top - scroll_top);
else if ( bottom > scroll_bottom )
scroller.scrollBy(0, bottom - scroll_bottom);
},
changeProfile(profile) {
this.context.currentProfile = profile;
this.focusHide();
}
}
}
</script>

View file

@ -0,0 +1,58 @@
<template lang="html">
<div class="ffz--widget ffz--checkbox" :class="{inherits: isInherited, default: isDefault}">
<div class="flex align-items-center">
<input
type="checkbox"
class="tw-checkbox__input"
ref="control"
:id="item.full_key"
:checked="value"
@change="onChange"
>
<label class="tw-checkbox__label" :for="item.full_key">
{{ t(item.i18n_key, item.title, item) }}
</label>
<button
v-if="source && source !== profile"
class="mg-l-05 tw-button tw-button--text"
@click="context.currentProfile = source"
>
<span class="tw-button__text ffz-i-right-dir">
{{ sourceDisplay }}
</span>
</button>
<button v-if="has_value" class="mg-l-05 tw-button tw-button--text tw-tooltip-wrapper" @click="clear">
<span class="tw-button__text ffz-i-cancel"></span>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
</div>
</button>
</div>
<section
v-if="item.description"
class="c-text-alt-2"
style="padding-left:2.2rem"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</div>
</template>
<script>
import SettingMixin from '../setting-mixin';
export default {
mixins: [SettingMixin],
props: ['item', 'context'],
methods: {
onChange() {
this.set(this.$refs.control.checked);
}
}
}
</script>

View file

@ -0,0 +1,44 @@
<template lang="html">
<div class="ffz--widget ffz--hotkey-input">
<label
:for="item.full_key"
v-html="t(item.i18n_key, item.title, item)"
/>
<div class="relative">
<div class="tw-input__icon-group tw-input__icon-group--right">
<div class="tw-input__icon">
<figure class="ffz-i-keyboard" />
</div>
</div>
<div
type="text"
class="mg-05 tw-input tw-input--icon-right"
ref="display"
:id="item.full_key"
tabindex="0"
@keyup="onKey"
>
&nbsp;
</div>
</div>
<section
v-if="item.description"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</div>
</template>
<script>
export default {
props: ['item', 'context'],
methods: {
onKey(e) {
const name = `${e.ctrlKey ? 'Ctrl-' : ''}${e.shiftKey ? 'Shift-' : ''}${e.altKey ? 'Alt-' : ''}${e.code}`;
this.$refs.display.innerText = name;
}
}
}
</script>

View file

@ -0,0 +1,27 @@
<template lang="html">
<div class="atw-input">
<header>
{{ t(item.i18n_key, item.title, item) }}
</header>
<section
v-if="item.description"
class="c-text-alt-2"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
<div v-for="(i, idx) in data" class="mg-l-1">
<input type="radio" :name="item.full_key" :id="item.full_key + idx" :value="i.value" class="tw-radio__input">
<label :for="item.full_key + idx" class="pd-y-05 tw-radio__label">{{ t(i.i18n_key, i.title, i) }}</label>
</div>
</div>
</template>
<script>
import SettingMixin from '../setting-mixin';
export default {
mixins: [SettingMixin],
props: ['item', 'context']
}
</script>

View file

@ -0,0 +1,64 @@
<template lang="html">
<div class="ffz--widget ffz--select-box" :class="{inherits: isInherited, default: isDefault}">
<div class="flex align-items-center">
<label :for="item.full_key">
{{ t(item.i18n_key, item.title, item) }}
</label>
<select
class="mg-05 tw-select display-inline width-auto"
ref="control"
:id="item.full_key"
@change="onChange"
>
<option v-for="i in data" :selected="i.value === value">
{{ i.i18n_key ? t(i.i18n_key, i.title, i) : i.title }}
</option>
</select>
<button
v-if="source && source !== profile"
class="mg-l-05 tw-button tw-button--text"
@click="context.currentProfile = source"
>
<span class="tw-button__text ffz-i-right-dir">
{{ sourceDisplay }}
</span>
</button>
<button v-if="has_value" class="mg-l-05 tw-button tw-button--text tw-tooltip-wrapper" @click="clear">
<span class="tw-button__text ffz-i-cancel"></span>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
</div>
</button>
</div>
<section
v-if="item.description"
class="c-text-alt-2"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</div>
</template>
<script>
import SettingMixin from '../setting-mixin';
export default {
mixins: [SettingMixin],
props: ['item', 'context'],
methods: {
onChange() {
const idx = this.$refs.control.selectedIndex,
raw_value = this.data[idx];
if ( raw_value )
this.set(raw_value.value);
}
}
}
</script>

View file

@ -0,0 +1,59 @@
<template lang="html">
<div class="ffz--widget ffz--text-box" :class="{inherits: isInherited, default: isDefault}">
<div class="flex align-items-center">
<label :for="item.full_key">
{{ t(item.i18n_key, item.title, item) }}
</label>
<input
class="mg-05 tw-input display-inline width-auto"
ref="control"
:id="item.full_key"
@change="onChange"
:value="value"
/>
<button
v-if="source && source !== profile"
class="mg-l-05 tw-button tw-button--text"
@click="context.currentProfile = source"
>
<span class="tw-button__text ffz-i-right-dir">
{{ sourceDisplay }}
</span>
</button>
<button v-if="has_value" class="mg-l-05 tw-button tw-button--text tw-tooltip-wrapper" @click="clear">
<span class="tw-button__text ffz-i-cancel"></span>
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
</div>
</button>
</div>
<section
v-if="item.description"
class="c-text-alt-2"
v-html="t(item.desc_i18n_key || item.i18n_key + '.description', item.description, item)"
/>
</div>
</template>
<script>
import SettingMixin from '../setting-mixin';
export default {
mixins: [SettingMixin],
props: ['item', 'context'],
methods: {
onChange() {
const value = this.$refs.control.value;
if ( value != null )
this.set(value);
}
}
}
</script>

View file

@ -0,0 +1,551 @@
'use strict';
// ============================================================================
// Menu Module
// ============================================================================
import Module from 'utilities/module';
import {createElement as e} from 'utilities/dom';
import {has, deep_copy} from 'utilities/object';
function format_term(term) {
return term.replace(/<[^>]*>/g, '').toLocaleLowerCase();
}
// TODO: Rewrite literally everything about the menu to use vue-router and further
// separate the concept of navigation from visible pages.
export default class MainMenu extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('i18n');
this.inject('site');
this.inject('vue');
//this.should_enable = true;
this._settings_tree = null;
this._settings_count = 0;
this._menu = null;
this._visible = true;
this._maximized = false;
this.settings.addUI('profiles', {
path: 'Data Management @{"sort": 1000, "profile_warning": false} > Profiles @{"profile_warning": false}',
component: 'profile-manager'
});
this.settings.addUI('home', {
path: 'Home @{"sort": -1000, "profile_warning": false}',
component: 'home-page'
});
this.settings.addUI('feedback', {
path: 'Home > Feedback',
component: 'feedback-page'
});
this.settings.addUI('changelog', {
path: 'Home > Changelog',
component: 'changelog'
});
}
async onLoad() {
this.vue.component(
(await import(/* webpackChunkName: "main-menu" */ './components.js')).default
);
}
get maximized() {
return this._maximized;
}
set maximized(val) {
val = Boolean(val);
if ( val === this._maximized )
return;
if ( this.enabled )
this.toggleSize();
}
get visible() {
return this._visible;
}
set visible(val) {
val = Boolean(val);
if ( val === this._visible )
return;
if ( this.enabled )
this.toggleVisible();
}
async onEnable(event) {
await this.site.awaitElement('.twilight-root');
this.on('site.menu_button:clicked', this.toggleVisible);
if ( this._visible ) {
this._visible = false;
this.toggleVisible(event);
}
}
onDisable() {
if ( this._visible ) {
this.toggleVisible();
this._visible = true;
}
this.off('site.menu_button:clicked', this.toggleVisible);
}
toggleVisible(event) {
if ( event && event.button !== 0 )
return;
const maximized = this._maximized,
visible = this._visible = !this._visible,
main = document.querySelector(maximized ? '.twilight-main' : '.twilight-root > .full-height');
if ( ! visible ) {
if ( maximized )
main.classList.remove('ffz-has-menu');
if ( this._menu ) {
main.removeChild(this._menu);
this._vue.$destroy();
this._menu = this._vue = null;
}
return;
}
if ( ! this._menu )
this.createMenu();
if ( maximized )
main.classList.add('ffz-has-menu');
main.appendChild(this._menu);
}
toggleSize(event) {
if ( ! this._visible || event && event.button !== 0 )
return;
const maximized = this._maximized = !this._maximized,
main = document.querySelector(maximized ? '.twilight-main' : '.twilight-root > .full-height'),
old_main = this._menu.parentElement;
if ( maximized )
main.classList.add('ffz-has-menu');
else
old_main.classList.remove('ffz-has-menu');
old_main.removeChild(this._menu);
main.appendChild(this._menu);
this._vue.$children[0].maximized = maximized;
}
rebuildSettingsTree() {
this._settings_tree = {};
this._settings_count = 0;
for(const [key, def] of this.settings.definitions)
this._addDefinitionToTree(key, def);
for(const [key, def] of this.settings.ui_structures)
this._addDefinitionToTree(key, def);
}
_addDefinitionToTree(key, def) {
if ( ! def.ui || ! this._settings_tree )
return;
if ( ! def.ui.path_tokens ) {
if ( def.ui.path )
def.ui.path_tokens = parse_path(def.ui.path);
else
return;
}
if ( ! def.ui || ! def.ui.path_tokens || ! this._settings_tree )
return;
const tree = this._settings_tree,
tokens = def.ui.path_tokens,
len = tokens.length;
let prefix = null,
token;
// Create and/or update all the necessary structure elements for
// this node in the settings tree.
for(let i=0; i < len; i++) {
const raw_token = tokens[i],
key = prefix ? `${prefix}.${raw_token.key}` : raw_token.key;
token = tree[key];
if ( ! token )
token = tree[key] = {
full_key: key,
sort: 0,
parent: prefix,
expanded: prefix === null,
i18n_key: `setting.${key}`,
desc_i18n_key: `setting.${key}.description`
};
Object.assign(token, raw_token);
prefix = key;
}
// Add this setting to the tree.
token.settings = token.settings || [];
token.settings.push([key, def]);
this._settings_count++;
}
getSettingsTree() {
const started = performance.now();
if ( ! this._settings_tree )
this.rebuildSettingsTree();
const tree = this._settings_tree,
root = {},
copies = {},
needs_sort = new Set,
needs_component = new Set,
have_locale = this.i18n.locale !== 'en';
for(const key in tree) {
if ( ! has(tree, key) )
continue;
const token = copies[key] = copies[key] || Object.assign({}, tree[key]),
p_key = token.parent,
parent = p_key ?
(copies[p_key] = copies[p_key] || Object.assign({}, tree[p_key])) :
root;
token.parent = p_key ? parent : null;
token.page = token.page || parent.page;
if ( token.page && ! token.component )
needs_component.add(token);
if ( token.settings ) {
const list = token.contents = token.contents || [];
for(const [setting_key, def] of token.settings)
if ( def.ui ) { //} && def.ui.title ) {
const i18n_key = `${token.i18n_key}.${def.ui.key}`
const tok = Object.assign({
i18n_key,
desc_i18n_key: `${i18n_key}.description`,
sort: 0,
title: setting_key
}, def.ui, {
full_key: `setting:${setting_key}`,
setting: setting_key,
path_tokens: undefined,
parent: token
});
if ( def.default && ! tok.default ) {
const def_type = typeof def.default;
if ( def_type === 'object' ) {
// TODO: Better way to deep copy this object.
tok.default = JSON.parse(JSON.stringify(def.default));
} else
tok.default = def.default;
}
const terms = [
setting_key,
this.i18n.t(tok.i18n_key, tok.title, tok, true)
];
if ( have_locale && this.i18n.has(tok.i18n_key) )
terms.push(this.i18n.t(tok.i18n_key, tok.title, tok));
if ( tok.description ) {
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, tok, true));
if ( have_locale && this.i18n.has(tok.desc_i18n_key) )
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, tok));
}
tok.search_terms = terms.map(format_term).join('\n');
list.push(tok);
}
token.settings = undefined;
if ( list.length > 1 )
needs_sort.add(list);
}
if ( ! token.search_terms ) {
const formatted = this.i18n.t(token.i18n_key, token.title, token, true);
let terms = [token.key];
if ( formatted && formatted.localeCompare(token.key, undefined, {sensitivity: 'base'}) )
terms.push(formatted);
if ( have_locale && this.i18n.has(token.i18n_key) )
terms.push(this.i18n.t(token.i18n_key, token.title, token));
if ( token.description ) {
terms.push(this.i18n.t(token.desc_i18n_key, token.description, token, true));
if ( have_locale && this.i18n.has(token.desc_i18n_key) )
terms.push(this.i18n.t(token.desc_i18n_key, token.description, token));
}
terms = terms.map(format_term);
for(const lk of ['tabs', 'contents', 'items'])
if ( token[lk] )
for(const tok of token[lk] )
if ( tok.search_terms )
terms.push(tok.search_terms);
terms = token.search_terms = terms.join('\n');
let p = parent;
while(p && p.search_terms) {
p.search_terms += '\n' + terms;
p = p.parent;
}
}
const lk = token.tab ? 'tabs' : token.page ? 'contents' : 'items',
list = parent[lk] = parent[lk] || [];
list.push(token);
if ( list.length > 1 )
needs_sort.add(list);
}
for(const token of needs_component) {
token.component = token.tabs ? 'tab-container' :
token.contents ? 'menu-container' :
'setting-check-box';
}
for(const list of needs_sort)
list.sort((a, b) => {
if ( a.sort < b.sort ) return -1;
if ( a.sort > b.sort ) return 1;
return a.key.localeCompare(b.key);
});
this.log.info(`Built Tree in ${(performance.now() - started).toFixed(5)}ms with ${Object.keys(tree).length} structure nodes and ${this._settings_count} settings nodes.`);
const items = root.items || [];
items.keys = copies;
return items;
}
getProfiles(context) {
const profiles = [],
keys = {};
context = context || this.settings.main_context;
for(const profile of this.settings.__profiles)
profiles.push(keys[profile.id] = this.getProfileProxy(profile, context));
return [profiles, keys];
}
getProfileProxy(profile, context) {
return {
id: profile.id,
order: context.manager.__profiles.indexOf(profile),
live: context.__profiles.includes(profile),
title: profile.name,
i18n_key: profile.i18n_key,
description: profile.description,
desc_i18n_key: profile.desc_i18n_key || profile.i18n_key && `${profile.i18n_key}.description`,
move: idx => context.manager.moveProfile(profile.id, idx),
save: () => profile.save(),
update: data => {
profile.data = data
profile.save()
},
context: deep_copy(profile.context),
get: key => profile.get(key),
set: (key, val) => profile.set(key, val),
delete: key => profile.delete(key),
has: key => profile.has(key),
on: (...args) => profile.on(...args),
off: (...args) => profile.off(...args)
}
}
getContext() {
const t = this,
Vue = this.vue.Vue,
settings = this.settings,
context = settings.main_context,
[profiles, profile_keys] = this.getProfiles(),
_c = {
profiles,
profile_keys,
currentProfile: profile_keys[0],
createProfile: data => {
const profile = settings.createProfile(data);
return t.getProfileProxy(profile, context);
},
deleteProfile: profile => settings.deleteProfile(profile),
context: {
_users: 0,
profiles: context.__profiles.map(profile => profile.id),
get: key => context.get(key),
uses: key => context.uses(key),
on: (...args) => context.on(...args),
off: (...args) => context.off(...args),
order: id => context.order.indexOf(id),
context: deep_copy(context.context),
_update_profiles(changed) {
const new_list = [],
profiles = context.manager.__profiles;
for(let i=0; i < profiles.length; i++) {
const profile = profile_keys[profiles[i].id];
profile.order = i;
new_list.push(profile);
}
Vue.set(_c, 'profiles', new_list);
if ( changed && changed.id === _c.currentProfile.id )
_c.currentProfile = profile_keys[changed.id];
},
_profile_created(profile) {
Vue.set(profile_keys, profile.id, t.getProfileProxy(profile, context));
this._update_profiles()
},
_profile_changed(profile) {
Vue.set(profile_keys, profile.id, t.getProfileProxy(profile, context));
this._update_profiles(profile);
},
_profile_deleted(profile) {
Vue.delete(profile_keys, profile.id);
this._update_profiles();
if ( _c.currentProfile.id === profile.id )
_c.currentProfile = profile_keys[0]
},
_context_changed() {
this.context = deep_copy(context.context);
const ids = this.profiles = context.__profiles.map(profile => profile.id);
for(const id in profiles) {
const profile = profiles[id];
profile.live = this.profiles.includes(profile.id);
}
},
_add_user() {
this._users++;
if ( this._users === 1 ) {
settings.on(':profile-created', this._profile_created, this);
settings.on(':profile-changed', this._profile_changed, this);
settings.on(':profile-deleted', this._profile_deleted, this);
settings.on(':profiles-reordered', this._update_profiles, this);
context.on('context_changed', this._context_changed, this);
context.on('profiles_changed', this._context_changed, this);
this.profiles = context.__profiles.map(profile => profile.id);
}
},
_remove_user() {
this._users--;
if ( this._users === 0 ) {
settings.off(':profile-created', this._profile_created, this);
settings.off(':profile-changed', this._profile_changed, this);
settings.off(':profile-deleted', this._profile_deleted, this);
settings.off(':profiles-reordered', this._update_profiles, this);
context.off('context_changed', this._context_changed, this);
context.off('profiles_changed', this._context_changed, this);
}
}
}
};
return _c;
}
getData() {
const settings = this.getSettingsTree(),
context = this.getContext();
return {
context,
nav: settings,
currentItem: settings.keys['home'], // settings[0],
nav_keys: settings.keys,
maximized: this._maximized,
resize: e => this.toggleSize(e),
close: e => this.toggleVisible(e),
version: window.FrankerFaceZ.version_info
}
}
createMenu() {
if ( this._menu )
return;
this._vue = new this.vue.Vue({
el: e('div'),
render: h => h('main-menu', this.getData())
});
this._menu = this._vue.$el;
}
}
MainMenu.requires = ['site.menu_button'];

View file

@ -0,0 +1,178 @@
'use strict';
export default {
data() {
return {
value: undefined,
has_value: false,
profile: null,
source: null,
source_value: undefined,
}
},
created() {
const ctx = this.context.context,
setting = this.item.setting;
ctx._add_user();
this._update_profile();
this._uses_changed(ctx.uses(setting));
ctx.on(`uses_changed:${setting}`, this._uses_changed, this);
},
destroyed() {
const ctx = this.context.context,
setting = this.item.setting;
if ( this.profile )
this.profile.off('changed', this._setting_changed, this);
if ( this.source )
this.source.off('changed', this._source_setting_changed, this);
ctx.off(`uses_changed:${setting}`, this._uses_changed, this);
this.value = undefined;
this.has_value = false;
this.profile = null;
this.source_value = undefined;
this.source = null;
ctx._remove_user();
},
watch: {
'context.currentProfile'() {
this._update_profile();
}
},
computed: {
data() {
const data = this.item.data;
if ( typeof data === 'function' )
return data(this.profile, this.value);
return data;
},
default_value() {
if ( typeof this.item.default === 'function' )
return this.item.default(this.context.context);
return this.item.default;
},
isInherited() {
return ! this.has_value && this.source && this.sourceOrder > this.profileOrder;
},
isDefault() {
return ! this.has_value && ! this.source
},
isOverridden() {
return this.source && this.sourceOrder < this.profileOrder;
},
sourceOrder() {
return this.source ? this.source.order : Infinity
},
profileOrder() {
return this.profile ? this.profile.order : Infinity
},
sourceTitle() {
if ( this.source )
return this.source.i18n_key ?
this.t(this.source.i18n_key, this.source.title, this.source) :
this.source.title;
},
sourceDisplay() {
const opts = {
title: this.sourceTitle
};
if ( this.isInherited )
return this.t('setting.inherited-from', 'Inherited From: %{title}', opts);
else if ( this.isOverridden )
return this.t('setting.overridden-by', 'Overridden By: %{title}', opts);
}
},
methods: {
_update_profile() {
if ( this.profile )
this.profile.off('changed', this._setting_changed, this);
const profile = this.profile = this.context.currentProfile,
setting = this.item.setting;
profile.on('changed', this._setting_changed, this);
this.has_value = profile.has(setting);
this.value = this.has_value ?
profile.get(setting) :
this.isInherited ?
this.source_value :
this.default_value;
},
_setting_changed(key, value, deleted) {
if ( key !== this.item.setting )
return;
this.has_value = deleted !== true;
this.value = this.has_value ?
value :
this.isInherited ?
this.source_value :
this.default_value;
},
_source_setting_changed(key, value, deleted) {
if ( key !== this.item.setting )
return;
this.source_value = value;
if ( this.isInherited )
this.value = deleted ? this.default_value : value;
},
_uses_changed(uses) {
if ( this.source )
this.source.off('changed', this._source_setting_changed, this);
const source = this.source = this.context.profile_keys[uses],
setting = this.item.setting;
if ( source ) {
source.on('changed', this._source_setting_changed, this);
this.source_value = source.get(setting);
} else
this.source_value = undefined;
if ( ! this.has_value )
this.value = this.isInherited ? this.source_value : this.default_value;
},
set(value) {
if ( this.item.process )
value = this.item.process(value);
this.profile.set(this.item.setting, value);
},
clear() {
this.profile.delete(this.item.setting);
}
}
}

342
src/modules/metadata.js Normal file
View file

@ -0,0 +1,342 @@
'use strict';
// ============================================================================
// Channel Metadata
// ============================================================================
import {createElement as e} from 'utilities/dom';
import {has, get, maybe_call} from 'utilities/object';
import {duration_to_string} from 'utilities/time';
import Tooltip from 'utilities/tooltip';
import Module from 'utilities/module';
export default class Metadata extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('i18n');
this.should_enable = true;
this.definitions = {};
this.settings.add('metadata.player-stats', {
default: false,
ui: {
path: 'Channel > Metadata >> Player',
title: 'Playback Statistics',
description: 'Show the current stream delay, with playback rate and dropped frames in the tooltip.',
component: 'setting-check-box'
},
changed: () => this.updateMetadata('player-stats')
});
this.settings.add('metadata.uptime', {
default: 1,
ui: {
path: 'Channel > Metadata >> Player',
title: 'Stream Uptime',
component: 'setting-select-box',
data: [
{value: 0, title: 'Disabled'},
{value: 1, title: 'Enabled'},
{value: 2, title: 'Enabled (with Seconds)'}
]
},
changed: () => this.updateMetadata('uptime')
});
this.definitions.uptime = {
refresh() { return this.settings.get('metadata.uptime') > 0 },
setup() {
const socket = this.resolve('socket'),
query = this.resolve('site.apollo').getQuery('ChannelPage_ChannelInfoBar_User'),
result = query.lastResult,
created_at = get('data.user.stream.createdAt', result);
if ( created_at === undefined && ! query._ffz_refetched ) {
query._ffz_refetched = true;
query.refetch();
return {};
}
if ( ! created_at )
return {};
const created = new Date(created_at),
now = Date.now() - socket._time_drift;
return {
created,
uptime: created ? Math.floor((now - created.getTime()) / 1000) : -1
}
},
order: 2,
icon: 'ffz-i-clock',
label(data) {
const setting = this.settings.get('metadata.uptime');
if ( ! setting || ! data.created )
return null;
return duration_to_string(data.uptime, false, false, false, setting !== 2);
},
tooltip(data) {
if ( ! data.created )
return null;
return `${this.i18n.t(
'metadata.uptime.tooltip',
'Stream Uptime'
)}<div class="pd-t-05">${this.i18n.t(
'metadata.uptime.since',
'(since %{since})',
{since: data.created.toLocaleString()}
)}</div>`;
}
}
this.definitions['player-stats'] = {
refresh() {
return this.settings.get('metadata.player-stats')
},
setup() {
const Player = this.resolve('site.player'),
socket = this.resolve('socket'),
player = Player.current,
stats = player && player.getVideoInfo();
if ( ! stats )
return {stats};
let delay = stats.hls_latency_broadcaster / 1000,
drift = 0;
if ( socket && socket.connected )
drift = socket._time_drift;
return {
stats,
drift,
delay,
old: delay > 180
}
},
order: 3,
icon: 'ffz-i-gauge',
label(data) {
if ( ! this.settings.get('metadata.player-stats') || ! data.delay )
return null;
const delayed = data.drift > 5000 ? '(!) ' : '';
if ( data.old )
return `${delayed}${data.delay.toFixed(2)}s old`;
else
return `${delayed}${data.delay.toFixed(2)}s`;
},
color(data) {
const setting = this.settings.get('some.thing');
if ( setting == null || ! data.delay || data.old )
return;
if ( data.delay > (setting * 2) )
return '#ec1313';
else if ( data.delay > setting )
return '#fc7835';
},
tooltip(data) {
const delayed = data.drift > 5000 ?
`${this.i18n.t(
'metadata.player-stats.delay-warning',
'Your local clock seems to be off by roughly %{count} seconds, which could make this inaccurate.',
Math.round(data.drift / 10) / 100
)}<hr>` :
'';
if ( ! data.stats || ! data.delay )
return delayed + this.i18n.t('metadata.player-stats.latency-tip', 'Stream Latency');
const stats = data.stats,
video_info = this.i18n.t(
'metadata.player-stats.video-info',
'Video: %{vid_width}x%{vid_height}p%{current_fps}\nPlayback Rate: %{current_bitrate|number} Kbps\nDropped Frames:%{dropped_frames|number}',
stats
);
if ( data.old )
return `${delayed}${this.i18n.t(
'metadata.player-stats.video-tip',
'Video Information'
)}<div class="pd-t-05">${this.i18n.t(
'metadata.player-stats.broadcast-ago',
'Broadcast %{count}s Ago',
data.delay
)}</div><div class="pd-t-05">${video_info}</div>`;
return `${delayed}${this.i18n.t(
'metadata.player-stats.latency-tip',
'Stream Latency'
)}<div class="pd-t-05">${video_info}</div>`;
}
}
}
get keys() {
return Object.keys(this.definitions);
}
async getData(key) {
const def = this.definitions[key];
if ( ! def )
return {label: null};
return {
icon: maybe_call(def.icon),
label: maybe_call(def.label),
refresh: maybe_call(def.refresh)
}
}
updateMetadata(keys) {
const bar = this.resolve('site.channel_bar');
if ( bar ) {
for(const inst of bar.ChannelBar.instances)
bar.updateMetadata(inst, keys);
for(const inst of bar.HostBar.instances)
bar.updateMetadata(inst, keys);
}
}
async render(key, data, container, timers, refresh_fn) {
if ( timers[key] )
clearTimeout(timers[key]);
let el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
const def = this.definitions[key],
destroy = () => {
if ( el ) {
if ( el.tooltip )
el.tooltip.destroy();
if ( el.popper )
el.popper.destroy();
el.tooltip = el.popper = null;
el.parentElement.removeChild(el);
}
};
if ( ! def )
return destroy();
try {
// Process the data if a setup method is defined.
if ( def.setup )
data = await def.setup.call(this, data);
// Let's get refresh logic out of the way now.
const refresh = maybe_call(def.refresh, this, data);
if ( refresh )
timers[key] = setTimeout(
() => refresh_fn(key),
typeof refresh === 'number' ? refresh : 1000
);
// Grab the element again in case it changed, somehow.
el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
let stat, old_color;
const label = maybe_call(def.label, this, data);
if ( ! label )
return destroy();
const tooltip = maybe_call(def.tooltip, this, data),
order = maybe_call(def.order, this, data),
color = maybe_call(def.color, this, data);
if ( ! el ) {
let icon = maybe_call(def.icon, this, data);
if ( typeof icon === 'string' )
icon = e('span', 'tw-stat__icon', e('figure', icon));
el = e('div', {
className: 'ffz-stat tw-stat',
'data-key': key,
tip_content: tooltip
}, [
icon,
stat = e('span', 'tw-stat__value')
]);
el._ffz_order = order;
if ( order != null )
el.style.order = order;
container.appendChild(el);
if ( def.tooltip )
el.tooltip = new Tooltip(container, el, {
live: false,
html: true,
content: () => el.tip_content,
onShow: (t, tip) => el.tip = tip,
onHide: () => el.tip = null
});
} else {
stat = el.querySelector('.tw-stat__value');
old_color = el.dataset.color || '';
if ( el._ffz_order !== order )
el.style.order = el._ffz_order = order;
if ( el.tip_content !== tooltip ) {
el.tip_content = tooltip;
if ( el.tip )
el.tip.element.innerHTML = tooltip;
}
}
if ( old_color !== color )
el.dataset.color = el.style.color = color;
stat.innerHTML = label;
if ( def.disabled !== undefined )
el.disabled = maybe_call(def.disabled, this, data);
} catch(err) {
this.log.error(`Error rendering metadata for ${key}`, err);
return destroy();
}
}
}

93
src/modules/tooltips.js Normal file
View file

@ -0,0 +1,93 @@
'use strict';
// ============================================================================
// Tooltip Handling
// ============================================================================
import {createElement as e} from 'utilities/dom';
import Tooltip from 'utilities/tooltip';
import Module from 'utilities/module';
export default class TooltipProvider extends Module {
constructor(...args) {
super(...args);
this.types = {};
this.should_enable = true;
this.inject('i18n');
this.inject('chat');
this.types.json = target => {
const title = target.dataset.title;
return [
title && e('strong', null, title),
e('code', {
className: `block${title ? ' pd-t-05 border-t mg-t-05' : ''}`,
style: {
fontFamily: 'monospace',
textAlign: 'left'
}
}, target.dataset.data)
]
}
this.types.badge = (target, tip) => {
const container = target.parentElement.parentElement,
badge = target.dataset.badge,
version = target.dataset.version,
room = container.dataset.roomId,
data = this.chat.getBadge(badge, version, room);
if ( ! data )
return;
return [
this.chat.context.get('tooltip.badge-images') && e('img', {
className: 'preview-image',
src: data.image4x,
style: {
height: '72px'
},
onLoad: tip.update
}),
data.title
]
};
}
onEnable() {
this.tips = new Tooltip('[data-reactroot]', 'ffz-tooltip', {
html: true,
content: this.process.bind(this),
popper: {
placement: 'top'
}
});
}
process(target, tip) { //eslint-disable-line class-methods-use-this
const type = target.dataset.tooltipType,
handler = this.types[type];
if ( ! handler )
return [
e('strong', null, 'Unhandled Tooltip Type'),
e('code', {
className: 'block pd-t-05 border-t mg-t-05',
style: {
fontFamily: 'monospace',
textAlign: 'left'
}
}, JSON.stringify(target.dataset, null, 4))
];
return handler(target, tip);
}
}

View file

@ -0,0 +1,3 @@
'use strict';
export default require.context('./components', false, /\.vue$/);

View file

@ -0,0 +1,42 @@
'use strict';
// ============================================================================
// Translation UI
// ============================================================================
import Module from 'utilities/module';
import {createElement as e} from 'utilities/dom';
export default class TranslationUI extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('site');
this.inject('vue');
//this.should_enable = true;
this._dialog = null;
this._visible = true;
}
async onLoad() {
this.vue.component(
(await import(/* webpackChunkName: "translation-ui" */ './components.js')).default
);
}
async onEnable(event) {
await this.site.awaitElement('.twilight-root');
this.ps = this.site.web_munch.getModule('ps');
}
onDisable() {
if ( this._visible ) {
this.toggleVisible();
this._visible = true;
}
}
}