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:
parent
c2688646af
commit
262757a20d
187 changed files with 22878 additions and 38882 deletions
27
src/modules/chat/badges.js
Normal file
27
src/modules/chat/badges.js
Normal 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
325
src/modules/chat/emotes.js
Normal 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
513
src/modules/chat/index.js
Normal 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
216
src/modules/chat/room.js
Normal 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'));
|
||||
}
|
||||
}
|
702
src/modules/chat/tokenizers.js
Normal file
702
src/modules/chat/tokenizers.js
Normal 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;
|
||||
}
|
||||
}
|
3
src/modules/main_menu/components.js
Normal file
3
src/modules/main_menu/components.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
'use strict';
|
||||
|
||||
export default require.context('./components', false, /\.vue$/);
|
50
src/modules/main_menu/components/changelog.vue
Normal file
50
src/modules/main_menu/components/changelog.vue
Normal 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>
|
35
src/modules/main_menu/components/feedback-page.vue
Normal file
35
src/modules/main_menu/components/feedback-page.vue
Normal 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>
|
67
src/modules/main_menu/components/filter-editor.vue
Normal file
67
src/modules/main_menu/components/filter-editor.vue
Normal 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>
|
100
src/modules/main_menu/components/home-page.vue
Normal file
100
src/modules/main_menu/components/home-page.vue
Normal 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>
|
180
src/modules/main_menu/components/main-menu.vue
Normal file
180
src/modules/main_menu/components/main-menu.vue
Normal 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>
|
34
src/modules/main_menu/components/menu-container.vue
Normal file
34
src/modules/main_menu/components/menu-container.vue
Normal 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>
|
89
src/modules/main_menu/components/menu-page.vue
Normal file
89
src/modules/main_menu/components/menu-page.vue
Normal 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">» </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>
|
165
src/modules/main_menu/components/menu-tree.vue
Normal file
165
src/modules/main_menu/components/menu-tree.vue
Normal 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>
|
205
src/modules/main_menu/components/profile-editor.vue
Normal file
205
src/modules/main_menu/components/profile-editor.vue
Normal 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>
|
129
src/modules/main_menu/components/profile-manager.vue
Normal file
129
src/modules/main_menu/components/profile-manager.vue
Normal 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>
|
218
src/modules/main_menu/components/profile-selector.vue
Normal file
218
src/modules/main_menu/components/profile-selector.vue
Normal 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>
|
58
src/modules/main_menu/components/setting-check-box.vue
Normal file
58
src/modules/main_menu/components/setting-check-box.vue
Normal 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>
|
44
src/modules/main_menu/components/setting-hotkey.vue
Normal file
44
src/modules/main_menu/components/setting-hotkey.vue
Normal 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"
|
||||
>
|
||||
|
||||
</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>
|
27
src/modules/main_menu/components/setting-radio-buttons.vue
Normal file
27
src/modules/main_menu/components/setting-radio-buttons.vue
Normal 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>
|
64
src/modules/main_menu/components/setting-select-box.vue
Normal file
64
src/modules/main_menu/components/setting-select-box.vue
Normal 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>
|
59
src/modules/main_menu/components/setting-text-box.vue
Normal file
59
src/modules/main_menu/components/setting-text-box.vue
Normal 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>
|
551
src/modules/main_menu/index.js
Normal file
551
src/modules/main_menu/index.js
Normal 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'];
|
178
src/modules/main_menu/setting-mixin.js
Normal file
178
src/modules/main_menu/setting-mixin.js
Normal 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
342
src/modules/metadata.js
Normal 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
93
src/modules/tooltips.js
Normal 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);
|
||||
}
|
||||
}
|
3
src/modules/translation_ui/components.js
Normal file
3
src/modules/translation_ui/components.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
'use strict';
|
||||
|
||||
export default require.context('./components', false, /\.vue$/);
|
42
src/modules/translation_ui/nondex.js
Normal file
42
src/modules/translation_ui/nondex.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue