1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-08-06 22:30:57 +00:00

4.0.0-rc19

* Added: Option to hide Squad Streaming banners on channel pages.
* Changed: Rewrite the entire i18n system to use the standard ICU message format.
* Changed: Render the context menu for chat messages in Chat on Videos.
* Fixed: Badges not appearing correctly in chat.
* Fixed: Messages in Chat on Videos with embeds appearing too wide.
This commit is contained in:
SirStendec 2019-05-03 19:30:46 -04:00
parent 8bc25b8d5f
commit 35316b8827
43 changed files with 1061 additions and 348 deletions

5
package-lock.json generated
View file

@ -176,6 +176,11 @@
} }
} }
}, },
"@ffz/icu-msgparser": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@ffz/icu-msgparser/-/icu-msgparser-1.0.1.tgz",
"integrity": "sha512-0+i29DZUJIqrK02rHcxDXmuLSOyp4EJERu4uQDOh7w39d5TqXHbFP2CRtSXjYYCQZWHoKwn37ZZScn+8+zH56g=="
},
"abbrev": { "abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",

View file

@ -54,6 +54,7 @@
"url": "https://github.com/FrankerFaceZ/FrankerFaceZ.git" "url": "https://github.com/FrankerFaceZ/FrankerFaceZ.git"
}, },
"dependencies": { "dependencies": {
"@ffz/icu-msgparser": "^1.0.1",
"crypto-js": "^3.1.9-1", "crypto-js": "^3.1.9-1",
"dayjs": "^1.7.7", "dayjs": "^1.7.7",
"displacejs": "^1.2.4", "displacejs": "^1.2.4",

View file

@ -2,35 +2,32 @@
// ============================================================================ // ============================================================================
// Localization // Localization
// This is based on Polyglot, but with some changes to avoid dependencies on
// additional libraries and with support for Vue.
// ============================================================================ // ============================================================================
import Parser from '@ffz/icu-msgparser';
import {SERVER} from 'utilities/constants'; import {SERVER} from 'utilities/constants';
import {get, pick_random, has, timeout} from 'utilities/object'; import {get, pick_random, has, timeout} from 'utilities/object';
import Module from 'utilities/module'; import Module from 'utilities/module';
import NewTransCore from 'utilities/translation-core';
const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'UwU', '>w<', '^w^', '> w >', 'v.v'], const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'UwU', '>w<', '^w^', '> w >', 'v.v'],
format_text = (phrase, token_regex, formatter) => { transformText = (ast, fn) => {
const out = []; return ast.map(node => {
if ( typeof node === 'string' )
return fn(node);
let i = 0, match; else if ( typeof node === 'object' && node.o ) {
token_regex.lastIndex = 0; const out = Object.assign(node, {o: {}});
for(const key of Object.keys(node.o))
while((match = token_regex.exec(phrase))) { out.o[key] = transformText(node.o[key], fn)
if ( match.index !== i )
out.push(formatter(phrase.slice(i, match.index)))
out.push(match[0]);
i = match.index + match[0].length;
} }
if ( i < phrase.length ) return node;
out.push(formatter(phrase.slice(i))); })
return out.join('')
}, },
owo = text => text owo = text => text
@ -44,19 +41,12 @@ const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'Uw
TRANSFORMATIONS = { TRANSFORMATIONS = {
double: (key, text) => double: (key, ast) => [...ast, ' ', ...ast],
`${text} ${text}`, upper: (key, ast) => transformText(ast, n => n.toUpperCase()),
lower: (key, ast) => transformText(ast, n => n.toLowerCase()),
upper: (key, text, opts, locale, token_regex) => append_key: (key, ast) => [...ast, ` (${key})`],
format_text(text, token_regex, t => t.toUpperCase()), set_key: (key, ast) => [key],
owo: (key, ast) => transformText(ast, owo)
lower: (key, text, opts, locale, token_regex) =>
format_text(text, token_regex, t => t.toLowerCase()),
append_key: (key, text) => `${text} (${key})`,
owo: (key, text, opts, locale, token_regex) =>
format_text(text, token_regex, t => owo(t))
}; };
@ -69,14 +59,16 @@ export class TranslationManager extends Module {
super(...args); super(...args);
this.inject('settings'); this.inject('settings');
this.parser = new Parser;
this._seen = new Set; this._seen = new Set;
this.availableLocales = ['en']; //, 'de', 'ja']; this.availableLocales = ['en']; //, 'de', 'ja'];
this.localeData = { this.localeData = {
en: { name: 'English' }, en: { name: 'English' }/*,
//de: { name: 'Deutsch' }, de: { name: 'Deutsch' },
//ja: { name: '日本語' } ja: { name: '日本語' }*/
} }
@ -92,6 +84,7 @@ export class TranslationManager extends Module {
{value: 'upper', title: 'Upper Case'}, {value: 'upper', title: 'Upper Case'},
{value: 'lower', title: 'Lower Case'}, {value: 'lower', title: 'Lower Case'},
{value: 'append_key', title: 'Append Key'}, {value: 'append_key', title: 'Append Key'},
{value: 'set_key', title: 'Set to Key'},
{value: 'double', title: 'Double'}, {value: 'double', title: 'Double'},
{value: 'owo', title: "owo what's this"} {value: 'owo', title: "owo what's this"}
] ]
@ -137,10 +130,8 @@ export class TranslationManager extends Module {
} }
onEnable() { onEnable() {
this._ = new TranslationCore({ this._ = new NewTransCore({ //TranslationCore({
formatters: { warn: (...args) => this.log.warn(...args),
'humanTime': n => this.toHumanTime(n)
}
}); });
if ( window.BroadcastChannel ) { if ( window.BroadcastChannel ) {
@ -210,48 +201,8 @@ export class TranslationManager extends Module {
this.broadcast({type: 'seen', key}); this.broadcast({type: 'seen', key});
} }
toLocaleString(thing) {
if ( thing && thing.toLocaleString )
return thing.toLocaleString(this._.locale);
return thing;
}
toHumanTime(duration, factor = 1) {
// TODO: Make this better. Make all time handling better in fact.
if ( duration instanceof Date )
duration = (Date.now() - duration.getTime()) / 1000;
duration = Math.floor(duration);
const years = Math.floor((duration * factor) / 31536000) / factor;
if ( years >= 1 )
return this.t('human-time.years', '%{count} year%{count|en_plural}', years);
const days = Math.floor((duration %= 31536000) / 86400);
if ( days >= 1 )
return this.t('human-time.days', '%{count} day%{count|en_plural}', days);
const hours = Math.floor((duration %= 86400) / 3600);
if ( hours >= 1 )
return this.t('human-time.hours', '%{count} hour%{count|en_plural}', hours);
const minutes = Math.floor((duration %= 3600) / 60);
if ( minutes >= 1 )
return this.t('human-time.minutes', '%{count} minute%{count|en_plural}', minutes);
const seconds = duration % 60;
if ( seconds >= 1 )
return this.t('human-time.seconds', '%{count} second%{count|en_plural}', seconds);
return this.t('human-time.none', 'less than a second');
}
async loadLocale(locale) { async loadLocale(locale) {
/*if ( locale === 'en' ) if ( locale === 'en' )
return {}; return {};
if ( locale === 'de' ) if ( locale === 'de' )
@ -363,7 +314,7 @@ export class TranslationManager extends Module {
support: '対応' support: '対応'
} }
} }
}*/ }
const resp = await fetch(`${SERVER}/script/i18n/${locale}.json`); const resp = await fetch(`${SERVER}/script/i18n/${locale}.json`);
if ( ! resp.ok ) { if ( ! resp.ok ) {
@ -384,6 +335,8 @@ export class TranslationManager extends Module {
if ( new_locale === old_locale ) if ( new_locale === old_locale )
return []; return [];
await this.loadDayjsLocale(new_locale);
this._.locale = new_locale; this._.locale = new_locale;
this._.clear(); this._.clear();
this.log.info(`Changed Locale: ${new_locale} -- Old: ${old_locale}`); this.log.info(`Changed Locale: ${new_locale} -- Old: ${old_locale}`);
@ -412,14 +365,49 @@ export class TranslationManager extends Module {
return added; return added;
} }
async loadDayjsLocale(locale) {
if ( locale === 'en' )
return;
try {
await import(
/* webpackMode: 'lazy' */
/* webpackChunkName: 'i18n-[index]' */
`dayjs/locale/${locale}`
);
} catch(err) {
this.log.warn(`Unable to load day.js locale data for locale "${locale}"`, err);
}
}
has(key) { has(key) {
return this._.has(key); return this._.has(key);
} }
toLocaleString(...args) {
return this._.toLocaleString(...args);
}
toHumanTime(...args) {
return this._.formatHumanTime(...args);
}
formatNumber(...args) { formatNumber(...args) {
return this._.formatNumber(...args); return this._.formatNumber(...args);
} }
formatDate(...args) {
return this._.formatDate(...args)
}
formatTime(...args) {
return this._.formatTime(...args)
}
formatDateTime(...args) {
return this._.formatDateTime(...args)
}
t(key, ...args) { t(key, ...args) {
this.see(key); this.see(key);
return this._.t(key, ...args); return this._.t(key, ...args);
@ -437,7 +425,7 @@ export class TranslationManager extends Module {
// TranslationCore // TranslationCore
// ============================================================================ // ============================================================================
const REPLACE = String.prototype.replace, const REPLACE = String.prototype.replace; /*,
SPLIT = String.prototype.split; SPLIT = String.prototype.split;
const DEFAULT_FORMATTERS = { const DEFAULT_FORMATTERS = {
@ -460,7 +448,7 @@ export default class TranslationCore {
this.onMissingKey = typeof options.onMissingKey === 'function' ? options.onMissingKey : allowMissing; this.onMissingKey = typeof options.onMissingKey === 'function' ? options.onMissingKey : allowMissing;
this.transformPhrase = typeof options.transformPhrase === 'function' ? options.transformPhrase : transformPhrase; this.transformPhrase = typeof options.transformPhrase === 'function' ? options.transformPhrase : transformPhrase;
this.transformList = typeof options.transformList === 'function' ? options.transformList : transformList; this.transformList = typeof options.transformList === 'function' ? options.transformList : transformList;
this.delimiter = options.delimiter || /\s*\|\|\|\|\s*/; this.delimiter = options.delimiter || /\s*\|\|\|\|\s/;
this.tokenRegex = options.tokenRegex || /%\{(.*?)(?:\|(.*?))?\}/g; this.tokenRegex = options.tokenRegex || /%\{(.*?)(?:\|(.*?))?\}/g;
this.formatters = Object.assign({}, DEFAULT_FORMATTERS, options.formatters || {}); this.formatters = Object.assign({}, DEFAULT_FORMATTERS, options.formatters || {});
} }
@ -575,7 +563,7 @@ export default class TranslationCore {
return this.transformList(p, opts, locale, this.tokenRegex, this.formatters); return this.transformList(p, opts, locale, this.tokenRegex, this.formatters);
} }
} }*/
// ============================================================================ // ============================================================================
@ -593,10 +581,7 @@ export function transformList(phrase, substitutions, locale, token_regex, format
const options = typeof substitutions === 'number' ? {count: substitutions} : substitutions; const options = typeof substitutions === 'number' ? {count: substitutions} : substitutions;
if ( is_array ) if ( is_array )
p = p[pluralTypeIndex( p = p[0];
locale || 'en',
has(options, 'count') ? options.count : 1
)] || p[0];
const result = []; const result = [];
@ -642,10 +627,7 @@ export function transformPhrase(phrase, substitutions, locale, token_regex, form
const options = typeof substitutions === 'number' ? {count: substitutions} : substitutions; const options = typeof substitutions === 'number' ? {count: substitutions} : substitutions;
if ( is_array ) if ( is_array )
result = result[pluralTypeIndex( result = result[0];
locale || 'en',
has(options, 'count') ? options.count : 1
)] || result[0];
if ( typeof result === 'string' ) if ( typeof result === 'string' )
result = REPLACE.call(result, token_regex, (expr, arg, fmt) => { result = REPLACE.call(result, token_regex, (expr, arg, fmt) => {
@ -664,64 +646,3 @@ export function transformPhrase(phrase, substitutions, locale, token_regex, form
return result; return result;
} }
// ============================================================================
// Plural Nonsense
// ============================================================================
const PLURAL_TYPE_TO_LANG = {
arabic: ['ar'],
chinese: ['fa', 'id', 'ja', 'ko', 'lo', 'ms', 'th', 'tr', 'zh'],
german: ['da', 'de', 'en', 'es', 'es', 'fi', 'el', 'he', 'hu', 'it', 'nl', 'no', 'pt', 'sv'],
french: ['fr', 'tl', 'pt-br'],
russian: ['hr', 'ru', 'lt'],
czech: ['cs', 'sk'],
polish: ['pl'],
icelandic: ['is']
};
const PLURAL_LANG_TO_TYPE = {};
for(const type in PLURAL_TYPE_TO_LANG) // eslint-disable-line guard-for-in
for(const lang of PLURAL_TYPE_TO_LANG[type])
PLURAL_LANG_TO_TYPE[lang] = type;
const PLURAL_TYPES = {
arabic: n => {
if ( n < 3 ) return n;
const n1 = n % 100;
if ( n1 >= 3 && n1 <= 10 ) return 3;
return n1 >= 11 ? 4 : 5;
},
chinese: () => 0,
german: n => n !== 1 ? 1 : 0,
french: n => n > 1 ? 1 : 0,
russian: n => {
const n1 = n % 10, n2 = n % 100;
if ( n1 === 1 && n2 !== 11 ) return 0;
return n1 >= 2 && n1 <= 4 && (n2 < 10 || n2 >= 20) ? 1 : 2;
},
czech: n => {
if ( n === 1 ) return 0;
return n >= 2 && n <= 4 ? 1 : 2;
},
polish: n => {
if ( n === 1 ) return 0;
const n1 = n % 10, n2 = n % 100;
return n1 >= 2 && n1 <= 4 && (n2 < 10 || n2 >= 20) ? 1 : 2;
},
icelandic: n => n % 10 !== 1 || n % 100 === 11 ? 1 : 0
};
export function pluralTypeIndex(locale, n) {
let type = PLURAL_LANG_TO_TYPE[locale];
if ( ! type ) {
const idx = locale.indexOf('-');
if ( idx !== -1 )
type = PLURAL_LANG_TO_TYPE[locale.slice(0, idx)]
}
return PLURAL_TYPES[type || 'german'](n);
}

View file

@ -149,7 +149,7 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`
FrankerFaceZ.Logger = Logger; FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = { const VER = FrankerFaceZ.version_info = {
major: 4, minor: 0, revision: 0, extra: '-rc18.2', major: 4, minor: 0, revision: 0, extra: '-rc19',
commit: __git_commit__, commit: __git_commit__,
build: __webpack_hash__, build: __webpack_hash__,
toString: () => toString: () =>
@ -166,7 +166,9 @@ FrankerFaceZ.utilities = {
logging: require('utilities/logging'), logging: require('utilities/logging'),
object: require('utilities/object'), object: require('utilities/object'),
time: require('utilities/time'), time: require('utilities/time'),
tooltip: require('utilities/tooltip') tooltip: require('utilities/tooltip'),
i18n: require('utilities/translation-core'),
dayjs: require('dayjs')
} }

View file

@ -14,7 +14,7 @@
> >
<div class="tw-c-text-alt-2 tw-mg-b-1"> <div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.variables', 'Available Variables: %{vars}', {vars}) }} {{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }}
</div> </div>
</div> </div>
</div> </div>

View file

@ -14,7 +14,7 @@
> >
<div class="tw-c-text-alt-2 tw-mg-b-1"> <div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.variables', 'Available Variables: %{vars}', {vars}) }} {{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }}
</div> </div>
</div> </div>
</div> </div>

View file

@ -40,7 +40,7 @@ export default class Actions extends Module {
always_inherit: true, always_inherit: true,
ui: { ui: {
path: 'Chat > Actions > Reasons', path: 'Chat > Actions > Reasons >> Custom Reasons',
component: 'chat-reasons', component: 'chat-reasons',
} }
}); });
@ -80,6 +80,15 @@ export default class Actions extends Module {
} }
}); });
this.settings.add('chat.actions.rules-as-reasons', {
default: true,
ui: {
path: 'Chat > Actions > Reasons >> Rules',
component: 'setting-check-box',
title: "Include the current room's rules in the list of reasons."
}
});
this.settings.add('chat.actions.viewer-card', { this.settings.add('chat.actions.viewer-card', {
// Filter out actions // Filter out actions
process: (ctx, val) => process: (ctx, val) =>
@ -184,7 +193,7 @@ export default class Actions extends Module {
renderInlineReasons(data, t, tip) { renderInlineReasons(data, t, tip) {
const reasons = this.parent.context.get('chat.actions.reasons'), const reasons = this.parent.context.get('chat.actions.reasons'),
reason_elements = [], reason_elements = [],
room = this.parent.getRoom(data.room.id, data.room.login, true), room = this.parent.context.get('chat.actions.rules-as-reasons') && this.parent.getRoom(data.room.id, data.room.login, true),
rules = room && room.rules; rules = room && room.rules;
if ( ! reasons && ! rules ) { if ( ! reasons && ! rules ) {
@ -201,7 +210,7 @@ export default class Actions extends Module {
if ( reasons && reasons.length ) { if ( reasons && reasons.length ) {
for(const reason of reasons) { for(const reason of reasons) {
const text = this.replaceVariables(reason.i18n ? this.i18n.t(reason.i18n, reason.text) : reason.text, data); const text = this.replaceVariables((typeof reason.i18n === 'string') ? this.i18n.t(reason.i18n, reason.text) : reason.text, data);
reason_elements.push(<li class="tw-full-width tw-relative"> reason_elements.push(<li class="tw-full-width tw-relative">
<a <a

View file

@ -114,7 +114,7 @@ export const msg_delete = {
title: 'Delete Message', title: 'Delete Message',
tooltip(data) { tooltip(data) {
return this.i18n.t('chat.actions.delete', "Delete %{user.login}'s message", {user: data.user}); return this.i18n.t('chat.actions.delete', "Delete {user.login}'s message", {user: data.user});
}, },
click(event, data) { click(event, data) {
@ -147,11 +147,11 @@ export const ban = {
title: 'Ban User', title: 'Ban User',
reason_text(data) { reason_text(data) {
return this.i18n.t('chat.actions.ban-reason', 'Ban %{user.login} for:', {user: data.user}); return this.i18n.t('chat.actions.ban-reason', 'Ban {user.login} for:', {user: data.user});
}, },
tooltip(data) { tooltip(data) {
return this.i18n.t('chat.actions.ban', 'Ban %{user.login}', {user: data.user}); return this.i18n.t('chat.actions.ban', 'Ban {user.login}', {user: data.user});
}, },
click(event, data) { click(event, data) {
@ -183,11 +183,11 @@ export const timeout = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-timeout.vue'), editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-timeout.vue'),
title: 'Timeout User', title: 'Timeout User',
description: '%{options.duration} second%{options.duration|en_plural}', description: '{options.duration,number} second%{options.duration,en_plural}',
reason_text(data) { reason_text(data) {
return this.i18n.t('chat.actions.timeout-reason', return this.i18n.t('chat.actions.timeout-reason',
'Timeout %{user.login} for %{duration} second%{duration|en_plural} for:', 'Timeout {user.login} for {duration,number} second{duration,en_plural} for:',
{ {
user: data.user, user: data.user,
duration: data.options.duration duration: data.options.duration
@ -198,7 +198,7 @@ export const timeout = {
tooltip(data) { tooltip(data) {
return this.i18n.t( return this.i18n.t(
'chat.actions.timeout', 'chat.actions.timeout',
'Timeout %{user.login} for %{duration} second%{duration|en_plural}', 'Timeout {user.login} for {duration,number} second{duration,en_plural}',
{ {
user: data.user, user: data.user,
duration: data.options.duration duration: data.options.duration
@ -230,7 +230,7 @@ export const unban = {
title: 'Unban User', title: 'Unban User',
tooltip(data) { tooltip(data) {
return this.i18n.t('chat.actions.unban', 'Unban %{user.login}', {user: data.user}); return this.i18n.t('chat.actions.unban', 'Unban {user.login}', {user: data.user});
}, },
click(event, data) { click(event, data) {
@ -257,7 +257,7 @@ export const untimeout = {
title: 'Untimeout User', title: 'Untimeout User',
tooltip(data) { tooltip(data) {
return this.i18n.t('chat.actions.untimeout', 'Untimeout %{user.login}', {user: data.user}); return this.i18n.t('chat.actions.untimeout', 'Untimeout {user.login}', {user: data.user});
}, },
click(event, data) { click(event, data) {
@ -283,7 +283,7 @@ export const whisper = {
title: 'Whisper User', title: 'Whisper User',
tooltip(data) { tooltip(data) {
return this.i18n.t('chat.actions.whisper', 'Whisper %{user.login}', data); return this.i18n.t('chat.actions.whisper', 'Whisper {user.login}', data);
}, },
click(event, data) { click(event, data) {
@ -326,7 +326,7 @@ export const gift_sub = {
title: 'Gift Subscription', title: 'Gift Subscription',
tooltip(data) { tooltip(data) {
return this.i18n.t('chat.actions.gift_sub', 'Gift a Sub to %{user.login}', data); return this.i18n.t('chat.actions.gift_sub', 'Gift a Sub to {user.login}', data);
}, },
context() { context() {

View file

@ -284,7 +284,7 @@ export default class Badges extends Module {
let title = bd.title || global_badge.title; let title = bd.title || global_badge.title;
if ( d.data ) { if ( d.data ) {
if ( d.badge === 'subscriber' ) { if ( d.badge === 'subscriber' ) {
title = this.i18n.t('badges.subscriber.months', '%{title} (%{count} Month%{count|en_plural})', { title = this.i18n.t('badges.subscriber.months', '{title} ({count,number} Month{count,en_plural})', {
title, title,
count: d.data count: d.data
}); });
@ -645,17 +645,24 @@ export default class Badges extends Module {
return b; return b;
} }
hasTwitchBadges() {
return !! this.twitch_badges
}
updateTwitchBadges(badges) { updateTwitchBadges(badges) {
if ( ! badges ) if ( ! Array.isArray(badges) )
this.twitch_badges = badges; this.twitch_badges = badges;
else { else {
const b = {}; let b = null;
if ( badges.length ) {
b = {};
for(const data of badges) { for(const data of badges) {
const sid = data.setID, const sid = data.setID,
bs = b[sid] = b[sid] || {__game: /_\d+$/.test(sid)}; bs = b[sid] = b[sid] || {__game: /_\d+$/.test(sid)};
bs[data.version] = data; bs[data.version] = data;
} }
}
this.twitch_badges = b; this.twitch_badges = b;
} }

View file

@ -1168,7 +1168,7 @@ export default class Chat extends Module {
l = parts.length, l = parts.length,
emotes = {}; emotes = {};
let idx = 0, ret, last_type = null; let idx = 0, ret, last_type = null, bits = 0;
for(let i=0; i < l; i++) { for(let i=0; i < l; i++) {
const part = parts[i], const part = parts[i],
@ -1186,10 +1186,11 @@ export default class Chat extends Module {
else if ( content.url ) else if ( content.url )
ret = content.url; ret = content.url;
else if ( content.cheerAmount ) else if ( content.cheerAmount ) {
bits += content.cheerAmount;
ret = `${content.alt}${content.cheerAmount}`; ret = `${content.alt}${content.cheerAmount}`;
else if ( content.images ) { } else if ( content.images ) {
const url = (content.images.themed ? content.images.dark : content.images.sources), const url = (content.images.themed ? content.images.dark : content.images.sources),
match = url && /\/emoticons\/v1\/(\d+)\/[\d.]+$/.exec(url['1x']), match = url && /\/emoticons\/v1\/(\d+)\/[\d.]+$/.exec(url['1x']),
id = match && match[1]; id = match && match[1];
@ -1219,6 +1220,7 @@ export default class Chat extends Module {
if ( ! emotes_only ) if ( ! emotes_only )
msg.message = out.join(''); msg.message = out.join('');
msg.bits = bits;
msg.ffz_emotes = emotes; msg.ffz_emotes = emotes;
return msg; return msg;
} }
@ -1228,8 +1230,15 @@ export default class Chat extends Module {
if (!( time instanceof Date )) if (!( time instanceof Date ))
time = new Date(time); time = new Date(time);
const fmt = this.context.get('chat.timestamp-format'); const fmt = this.context.get('chat.timestamp-format'),
return dayjs(time).locale(this.i18n.locale).format(fmt); d = dayjs(time);
try {
return d.locale(this.i18n.locale).format(fmt);
} catch(err) {
// If the locale isn't loaded, this can fail.
return d.format(fmt);
}
} }

View file

@ -106,25 +106,25 @@ export const Clips = {
let desc_1; let desc_1;
if ( game_name === 'creative' ) if ( game_name === 'creative' )
desc_1 = this.i18n.t('clip.desc.1.creative', '%{user} being Creative', { desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
user user
}); });
else if ( game ) else if ( game )
desc_1 = this.i18n.t('clip.desc.1.playing', '%{user} playing %{game}', { desc_1 = this.i18n.t('clip.desc.1.playing', '{user} playing {game}', {
user, user,
game: game_display game: game_display
}); });
else else
desc_1 = this.i18n.t('clip.desc.1', 'Clip of %{user}', {user}); desc_1 = this.i18n.t('clip.desc.1', 'Clip of {user}', {user});
return { return {
url: token.url, url: token.url,
image: clip.thumbnailURL, image: clip.thumbnailURL,
title: clip.title, title: clip.title,
desc_1, desc_1,
desc_2: this.i18n.t('clip.desc.2', 'Clipped by %{curator} — %{views|number} View%{views|en_plural}', { desc_2: this.i18n.t('clip.desc.2', 'Clipped by {curator} — {views,number} View{views,en_plural}', {
curator: clip.curator ? clip.curator.displayName : this.i18n.t('clip.unknown', 'Unknown'), curator: clip.curator ? clip.curator.displayName : this.i18n.t('clip.unknown', 'Unknown'),
views: clip.viewCount views: clip.viewCount
}) })
@ -170,25 +170,25 @@ export const Videos = {
let desc_1; let desc_1;
if ( game_name === 'creative' ) if ( game_name === 'creative' )
desc_1 = this.i18n.t('clip.desc.1.creative', '%{user} being Creative', { desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
user user
}); });
else if ( game ) else if ( game )
desc_1 = this.i18n.t('clip.desc.1.playing', '%{user} playing %{game}', { desc_1 = this.i18n.t('clip.desc.1.playing', '{user} playing %{game}', {
user, user,
game: game_display game: game_display
}); });
else else
desc_1 = this.i18n.t('video.desc.1', 'Video of %{user}', {user}); desc_1 = this.i18n.t('video.desc.1', 'Video of {user}', {user});
return { return {
url: token.url, url: token.url,
image: video.previewThumbnailURL, image: video.previewThumbnailURL,
title: video.title, title: video.title,
desc_1, desc_1,
desc_2: this.i18n.t('video.desc.2', '%{length} — %{views} Views - %{date}', { desc_2: this.i18n.t('video.desc.2', '{length,duration} — {views,number} Views - {date}', {
length: video.lengthSeconds, length: video.lengthSeconds,
views: video.viewCount, views: video.viewCount,
date: video.publishedAt date: video.publishedAt

View file

@ -380,17 +380,24 @@ export default class Room {
// Badge Data // Badge Data
// ======================================================================== // ========================================================================
hasBadges() {
return !! this.badges
}
updateBadges(badges) { updateBadges(badges) {
if ( ! badges ) if ( ! Array.isArray(badges) )
this.badges = badges; this.badges = badges;
else { else {
const b = {}; let b = null;
if ( badges.length ) {
b = {};
for(const data of badges) { for(const data of badges) {
const sid = data.setID, const sid = data.setID,
bs = b[sid] = b[sid] || {}; bs = b[sid] = b[sid] || {};
bs[data.version] = data; bs[data.version] = data;
} }
}
this.badges = b; this.badges = b;
} }

View file

@ -44,7 +44,7 @@ export const Links = {
return ''; return '';
if ( target.dataset.isMail === 'true' ) if ( target.dataset.isMail === 'true' )
return [this.i18n.t('tooltip.email-link', 'E-Mail %{address}', {address: target.textContent})]; return [this.i18n.t('tooltip.email-link', 'E-Mail {address}', {address: target.textContent})];
return this.get_link_info(target.dataset.url).then(data => { return this.get_link_info(target.dataset.url).then(data => {
if ( ! data || (data.v || 1) > TOOLTIP_VERSION ) if ( ! data || (data.v || 1) > TOOLTIP_VERSION )
@ -58,7 +58,7 @@ export const Links = {
content += (content.length ? '<hr>' : '') + content += (content.length ? '<hr>' : '') +
sanitize(this.i18n.t( sanitize(this.i18n.t(
'tooltip.link-destination', 'tooltip.link-destination',
'Destination: %{url}', 'Destination: {url}',
{url: data.urls[data.urls.length-1][1]} {url: data.urls[data.urls.length-1][1]}
)); ));
@ -66,7 +66,7 @@ export const Links = {
const reasons = Array.from(new Set(data.urls.map(x => x[2]).filter(x => x))).join(', '); const reasons = Array.from(new Set(data.urls.map(x => x[2]).filter(x => x))).join(', ');
content = this.i18n.t( content = this.i18n.t(
'tooltip.link-unsafe', 'tooltip.link-unsafe',
"Caution: This URL is on Google's Safe Browsing List for: %{reasons}", "Caution: This URL is on Google's Safe Browsing List for: {reasons}",
{reasons: sanitize(reasons.toLowerCase())} {reasons: sanitize(reasons.toLowerCase())}
) + (content.length ? `<hr>${content}` : ''); ) + (content.length ? `<hr>${content}` : '');
} }
@ -96,7 +96,7 @@ export const Links = {
return content; return content;
}).catch(error => }).catch(error =>
sanitize(this.i18n.t('tooltip.error', 'An error occurred. (%{error})', {error})) sanitize(this.i18n.t('tooltip.error', 'An error occurred. ({error})', {error}))
); );
}, },
@ -736,7 +736,7 @@ export const CheerEmotes = {
data-prefix={prefix} data-prefix={prefix}
data-tier={tier} data-tier={tier}
/>), />),
this.i18n.t('tooltip.bits', '%{count|number} Bits', amount), this.i18n.t('tooltip.bits', '{count,number} Bits', amount),
]; ];
if ( length > 1 ) { if ( length > 1 ) {
@ -755,7 +755,7 @@ export const CheerEmotes = {
if ( length > 12 ) { if ( length > 12 ) {
out.push(<br />); out.push(<br />);
out.push(this.i18n.t('tooltip.bits.more', '(and %{count} more)', length-12)); out.push(this.i18n.t('tooltip.bits.more', '(and {count} more)', length-12));
} }
} }
@ -1008,7 +1008,7 @@ export const AddonEmotes = {
source = this.i18n.t('emote.prime', 'Twitch Prime'); source = this.i18n.t('emote.prime', 'Twitch Prime');
else else
source = this.i18n.t('tooltip.channel', 'Channel: %{source}', {source}); source = this.i18n.t('tooltip.channel', 'Channel: {source}', {source});
} }
} else if ( provider === 'ffz' ) { } else if ( provider === 'ffz' ) {
@ -1025,7 +1025,7 @@ export const AddonEmotes = {
if ( emote.owner ) if ( emote.owner )
owner = this.i18n.t( owner = this.i18n.t(
'emote.owner', 'By: %{owner}', 'emote.owner', 'By: {owner}',
{owner: emote.owner.display_name}); {owner: emote.owner.display_name});
if ( emote.urls[4] ) if ( emote.urls[4] )
@ -1052,7 +1052,7 @@ export const AddonEmotes = {
plain_name = true; plain_name = true;
name = `:${emoji.names[0]}:${vcode ? `:${vcode.names[0]}:` : ''}`; name = `:${emoji.names[0]}:${vcode ? `:${vcode.names[0]}:` : ''}`;
source = this.i18n.t('tooltip.emoji', 'Emoji - %{category}', emoji); source = this.i18n.t('tooltip.emoji', 'Emoji - {category}', emoji);
} else } else
return; return;
@ -1069,7 +1069,7 @@ export const AddonEmotes = {
onLoad={tip.update} onLoad={tip.update}
/>) : preview), />) : preview),
plain_name || (hide_source && ! owner) ? name : this.i18n.t('tooltip.emote', 'Emote: %{name}', {name}), plain_name || (hide_source && ! owner) ? name : this.i18n.t('tooltip.emote', 'Emote: {name}', {name}),
! hide_source && source && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05"> ! hide_source && source && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05">
{source} {source}

View file

@ -9,7 +9,7 @@
<h4>{{ title }}</h4> <h4>{{ title }}</h4>
<div class="description">{{ description }}</div> <div class="description">{{ description }}</div>
<div v-if="canEdit" class="visibility tw-c-text-alt"> <div v-if="canEdit" class="visibility tw-c-text-alt">
{{ t('setting.actions.visible', 'visible: %{list}', {list: visibility}) }} {{ t('setting.actions.visible', 'visible: {list}', {list: visibility}) }}
</div> </div>
</template> </template>
<template v-else> <template v-else>
@ -281,7 +281,7 @@ export default {
const def = this.data.actions[this.display.action]; const def = this.data.actions[this.display.action];
if ( ! def ) if ( ! def )
return this.t('setting.actions.unknown', 'Unknown Action Type: %{action}', this.display); return this.t('setting.actions.unknown', 'Unknown Action Type: {action}', this.display);
if ( def.title ) { if ( def.title ) {
const data = this.getData(), const data = this.getData(),
@ -292,7 +292,7 @@ export default {
return this.t(i18n, out, data); return this.t(i18n, out, data);
} }
return this.t('setting.actions.untitled', 'Action: %{action}', this.display); return this.t('setting.actions.untitled', 'Action: {action}', this.display);
}, },

View file

@ -132,7 +132,7 @@ export default {
i++; i++;
} }
this.message = this.t('setting.backup-restore.restored', '%{count} items have been restored. Please refresh this page.', { this.message = this.t('setting.backup-restore.restored', '{count,number} items have been restored. Please refresh this page.', {
count: i count: i
}); });
} }

View file

@ -64,8 +64,7 @@ export default {
data() { data() {
return { return {
default_reason: { default_reason: {
text: '', text: ''
i18n: null
} }
} }
}, },
@ -101,6 +100,8 @@ export default {
if ( ! reason ) if ( ! reason )
return; return;
delete reason.i18n;
const vals = Array.from(this.val); const vals = Array.from(this.val);
vals.push({v: reason}); vals.push({v: reason});
this.set(deep_copy(vals)); this.set(deep_copy(vals));
@ -120,12 +121,9 @@ export default {
}, },
save(val, new_val) { save(val, new_val) {
if ( val.v && new_val ) { delete new_val.i18n;
if ( new_val.i18n && new_val.text !== val.v.text )
new_val.i18n = null;
}
val.v = new_val; val.v = new_val;
this.set(deep_copy(this.val)); this.set(deep_copy(this.val));
} }
} }

View file

@ -6,7 +6,7 @@
<div class="tw-mg-b-2 tw-flex tw-align-items-center"> <div class="tw-mg-b-2 tw-flex tw-align-items-center">
<div class="tw-flex-grow-1"> <div class="tw-flex-grow-1">
{{ t('setting.experiments.unique-id', 'Unique ID: %{id}', {id: unique_id}) }} {{ t('setting.experiments.unique-id', 'Unique ID: {id}', {id: unique_id}) }}
</div> </div>
<select <select
ref="sort_select" ref="sort_select"
@ -48,7 +48,7 @@
:key="idx" :key="idx"
:selected="i.value === exp.value" :selected="i.value === exp.value"
> >
{{ t('setting.experiments.entry', '%{value} (weight: %{weight})', i) }} {{ t('setting.experiments.entry', '{value} (weight: {weight})', i) }}
</option> </option>
</select> </select>

View file

@ -0,0 +1,20 @@
<template lang="html">
<div class="ffz--home tw-border-t tw-pd-y-1">
<markdown :source="t('home.faq', md)" />
</div>
</template>
<script>
import FAQ_MD from '../faq.md';
export default {
props: ['item', 'context'],
data() {
return {
md: FAQ_MD
}
}
}
</script>

View file

@ -1,31 +1,20 @@
<template lang="html"> <template lang="html">
<div class="ffz--home tw-border-t tw-pd-y-1"> <div class="ffz--home tw-border-t tw-pd-y-1">
<h2>Feedback</h2> <markdown :source="t('home.feedback', md)" />
<p>
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>
<p>
When creating an issue, please also upload the following logs and
include a link in your report.
</p>
</div> </div>
</template> </template>
<script> <script>
import FEEDBACK_MD from '../feedback.md';
export default { export default {
props: ['item', 'context'], props: ['item', 'context'],
data() {
return {
md: FEEDBACK_MD
}
}
} }
</script> </script>

View file

@ -9,45 +9,7 @@
</div> </div>
<section class="tw-pd-t-1 tw-border-t tw-mg-t-1"> <section class="tw-pd-t-1 tw-border-t tw-mg-t-1">
<h2>Welcome to the v4.0 Beta</h2> <markdown :source="t('home.about', md)" />
<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="tw-mg-b-2">
<li>Settings from the old version are not being imported.</li>
<li>Advanced input (better tab completion, history) isn't available.</li>
</ul>
<p>And the biggest features still under development:</p>
<ul class="tw-mg-b-2">
<li>Chat Filtering (Highlighted Words, etc.)</li>
<li>Room Status Indicators</li>
<li>Custom Mod Cards</li>
<li>Recent Highlights</li>
<li>Portrait Mode</li>
<li>Importing and exporting settings</li>
<li>User Aliases</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> </section>
@ -90,7 +52,7 @@
</div> </div>
<a class="twitter-timeline" data-width="300" data-theme="dark" href="https://twitter.com/FrankerFaceZ?ref_src=twsrc%5Etfw"> <a class="twitter-timeline" data-width="300" data-theme="dark" href="https://twitter.com/FrankerFaceZ?ref_src=twsrc%5Etfw">
Tweets by FrankerFaceZ {{ t('home.tweets', 'Tweets by FrankerFaceZ') }}
</a> </a>
</div> </div>
</div> </div>
@ -99,11 +61,19 @@
<script> <script>
import HOME_MD from '../home.md';
import {createElement as e} from 'utilities/dom'; import {createElement as e} from 'utilities/dom';
export default { export default {
props: ['item', 'context'], props: ['item', 'context'],
data() {
return {
md: HOME_MD
}
},
mounted() { mounted() {
let el; let el;
document.head.appendChild(el = e('script', { document.head.appendChild(el = e('script', {

View file

@ -71,7 +71,7 @@
</div> </div>
<footer class="tw-c-text-alt tw-border-t tw-pd-1"> <footer class="tw-c-text-alt tw-border-t tw-pd-1">
<div> <div>
{{ t('main-menu.version', 'Version %{version}', {version: version.toString()}) }} {{ t('main-menu.version', 'Version {version}', {version: version.toString()}) }}
</div> </div>
<div class="tw-c-text-alt-2"> <div class="tw-c-text-alt-2">
<a <a

View file

@ -106,7 +106,7 @@ export default {
}, },
title() { title() {
if ( this.display.i18n ) if ( typeof this.display.i18n === 'string' )
return this.t(this.display.i18n, this.display.text); return this.t(this.display.i18n, this.display.text);
return this.display.text; return this.display.text;

View file

@ -0,0 +1,25 @@
## Frequent Questions
* *What happened to [feature]?*
There are still features missing from FrankerFaceZ v4.0 that were present in v3.0. Unfortunately, a few features are impractical to update on the new version of Twitch's website.
Many features are still planned to return as the developers have time to implement them. There is no schedule of pending features. Features are released immediately upon completion.
* *I've donated, but my badge doesn't show up.*
* *I added an emote to my channel but I don't see it.*
Due to performance problems with our current website, we have to use caching on our API. Because of this, it can take up to half an hour for changes made to badges or emotes to take effect. After waiting, refresh the page and your content should appear. You can also try performing a hard refresh.
* *I don't want the `FFZ Supporter` badge.*
Users can toggle the visibility of their supporter badge at: [https://www.frankerfacez.com/donate](https://www.frankerfacez.com/donate)
* *I can see my emotes, but someone in chat said they can't.*
Users need FrankerFaceZ installed, or another third-party client that supports FFZ emotes, to see emotes from FrankerFaceZ. Users might also not see newly added emotes, depending on their browser cache and when they last refreshed.
* *There's a stolen emote!*
* *There's an offensive emote!*
Please report any issues with emotes using the `Report Emote` tool on our website. Shift-Click the offending emote to open it's page, and select `Report Emote` in the right side-bar. You'll need to sign in to report an emote.

View file

@ -0,0 +1,7 @@
## Feedback
You can provide feedback and bug reports by [opening an issue at our GitHub repository](https://github.com/FrankerFaceZ/FrankerFaceZ/issues). You can also [find us on Discord](https://discord.gg/UrAkGhT) or [tweet at us](https://twitter.com/FrankerFaceZ).
When creating a GitHub issue, please check that someone else hasn't already created one for what you'd like to discuss or report. GitHub issues should be used for issues with the client, and not questions regarding emote approval.
When creating an issue, please also upload the logs provided below and include the link in your report.

View file

@ -0,0 +1,3 @@
Thank you for using FrankerFaceZ.
Version 4.0 of the extension has been under active development since the release of Twitch's React-powered website in late 2017.

View file

@ -58,6 +58,11 @@ export default class MainMenu extends Module {
component: 'home-page' component: 'home-page'
}); });
this.settings.addUI('faq', {
path: 'Home > FAQ',
component: 'faq-page'
});
this.settings.addUI('feedback', { this.settings.addUI('feedback', {
path: 'Home > Feedback', path: 'Home > Feedback',
component: 'feedback-page' component: 'feedback-page'

View file

@ -103,9 +103,9 @@ export default {
}; };
if ( this.isInherited ) if ( this.isInherited )
return this.t('setting.inherited-from', 'Inherited From: %{title}', opts); return this.t('setting.inherited-from', 'Inherited From: {title}', opts);
else if ( this.isOverridden ) else if ( this.isOverridden )
return this.t('setting.overridden-by', 'Overridden By: %{title}', opts); return this.t('setting.overridden-by', 'Overridden By: {title}', opts);
} }
}, },

View file

@ -97,8 +97,8 @@ export default class Metadata extends Module {
'Stream Uptime' 'Stream Uptime'
)}<div class="pd-t-05">${this.i18n.t( )}<div class="pd-t-05">${this.i18n.t(
'metadata.uptime.since', 'metadata.uptime.since',
'(since %{since})', '(since {since,datetime})',
{since: data.created.toLocaleString()} {since: data.created}
)}</div>`; )}</div>`;
} }
} }
@ -205,7 +205,7 @@ export default class Metadata extends Module {
const delayed = data.drift > 5000 ? const delayed = data.drift > 5000 ?
`${this.i18n.t( `${this.i18n.t(
'metadata.player-stats.delay-warning', 'metadata.player-stats.delay-warning',
'Your local clock seems to be off by roughly %{count} seconds, which could make this inaccurate.', 'Your local clock seems to be off by roughly {count,number} seconds, which could make this inaccurate.',
Math.round(data.drift / 10) / 100 Math.round(data.drift / 10) / 100
)}<hr>` : )}<hr>` :
''; '';
@ -216,7 +216,7 @@ export default class Metadata extends Module {
const stats = data.stats, const stats = data.stats,
video_info = this.i18n.t( video_info = this.i18n.t(
'metadata.player-stats.video-info', 'metadata.player-stats.video-info',
'Video: %{videoResolution}p%{fps}\nPlayback Rate: %{playbackRate|number} Kbps\nDropped Frames:%{skippedFrames|number}', 'Video: {videoResolution}p{fps}\nPlayback Rate: {playbackRate,number} Kbps\nDropped Frames:{skippedFrames,number}',
stats stats
); );
@ -226,7 +226,7 @@ export default class Metadata extends Module {
'Video Information' 'Video Information'
)}<div class="pd-t-05">${this.i18n.t( )}<div class="pd-t-05">${this.i18n.t(
'metadata.player-stats.broadcast-ago', 'metadata.player-stats.broadcast-ago',
'Broadcast %{count}s Ago', 'Broadcast {count,number}s Ago',
data.delay data.delay
)}</div><div class="pd-t-05">${video_info}</div>`; )}</div><div class="pd-t-05">${video_info}</div>`;

View file

@ -38,20 +38,20 @@
:data-title="t('viewer-card.views', 'Views')" :data-title="t('viewer-card.views', 'Views')"
class="ffz-tooltip tw-mg-r-05 ffz-i-views" class="ffz-tooltip tw-mg-r-05 ffz-i-views"
> >
{{ t(null, '%{views|number}', {views: user.profileViewCount}) }} {{ t(null, '{views,number}', {views: user.profileViewCount}) }}
</span> </span>
<span <span
:data-title="t('viewer-card.followers', 'Followers')" :data-title="t('viewer-card.followers', 'Followers')"
class="ffz-tooltip tw-mg-r-05 ffz-i-heart" class="ffz-tooltip tw-mg-r-05 ffz-i-heart"
> >
{{ t(null, '%{followers|number}', {followers: user.followers.totalCount}) }} {{ t(null, '{followers,number}', {followers: user.followers.totalCount}) }}
</span> </span>
<span <span
v-if="userAge" v-if="userAge"
:data-title="t('viewer-card.age-tip', 'Member Since: %{age}', {age: userAge.toLocaleString()})" :data-title="t('viewer-card.age-tip', 'Member Since: %{age,datetime}', {age: userAge})"
class="ffz-tooltip ffz-i-clock" class="ffz-tooltip ffz-i-clock"
> >
{{ t('viewer-card.age', '%{age|humanTime}', {age: userAge}) }} {{ t('viewer-card.age', '{age,humantime}', {age: userAge}) }}
</span> </span>
</div> </div>
</div> </div>

View file

@ -551,28 +551,27 @@ export default class EmoteMenu extends Module {
ends = data.ends && data.ends - new Date; ends = data.ends && data.ends - new Date;
if ( renews > 0 ) { if ( renews > 0 ) {
const time = t.i18n.toHumanTime(renews / 1000);
calendar = { calendar = {
icon: 'calendar', icon: 'calendar',
message: t.i18n.t('emote-menu.sub-renews', 'This sub renews in %{time}.', {time}) message: t.i18n.t('emote-menu.sub-renews', 'This sub renews in {seconds,humantime}.', {seconds: renews / 1000})
} }
} else if ( ends ) { } else if ( ends ) {
const time = t.i18n.toHumanTime(ends / 1000); const seconds = ends / 1000;
if ( data.prime ) if ( data.prime )
calendar = { calendar = {
icon: 'crown', icon: 'crown',
message: t.i18n.t('emote-menu.sub-prime', 'This is your free sub with Twitch Prime.\nIt ends in %{time}.', {time}) message: t.i18n.t('emote-menu.sub-prime', 'This is your free sub with Twitch Prime.\nIt ends in {seconds,humantime}.', {seconds})
} }
else if ( data.gift ) else if ( data.gift )
calendar = { calendar = {
icon: 'gift', icon: 'gift',
message: t.i18n.t('emote-menu.sub-gift-ends', 'This gifted sub ends in %{time}.', {time}) message: t.i18n.t('emote-menu.sub-gift-ends', 'This gifted sub ends in {seconds,humantime}.', {seconds})
} }
else else
calendar = { calendar = {
icon: 'calendar-empty', icon: 'calendar-empty',
message: t.i18n.t('emote-menu.sub-ends', 'This sub ends in %{time}.', {time}) message: t.i18n.t('emote-menu.sub-ends', 'This sub ends in {seconds,humantime}.', {seconds})
} }
} }
@ -607,8 +606,8 @@ export default class EmoteMenu extends Module {
const locked = emote.locked && (! lock || ! lock.emotes.has(emote.id)), const locked = emote.locked && (! lock || ! lock.emotes.has(emote.id)),
emote_lock = locked && data.locks && data.locks[emote.set_id], emote_lock = locked && data.locks && data.locks[emote.set_id],
sellout = emote_lock ? (data.all_locked ? sellout = emote_lock ? (data.all_locked ?
t.i18n.t('emote-menu.emote-sub', 'Subscribe for %{price} to unlock this emote.', emote_lock) : t.i18n.t('emote-menu.emote-sub', 'Subscribe for {price} to unlock this emote.', emote_lock) :
t.i18n.t('emote-menu.emote-up', 'Upgrade your sub to %{price} to unlock this emote.', emote_lock) t.i18n.t('emote-menu.emote-up', 'Upgrade your sub to {price} to unlock this emote.', emote_lock)
) : ''; ) : '';
return this.renderEmote( return this.renderEmote(
@ -670,7 +669,7 @@ export default class EmoteMenu extends Module {
return (<div class="tw-mg-1 tw-border-t tw-pd-t-1 tw-mg-b-0"> return (<div class="tw-mg-1 tw-border-t tw-pd-t-1 tw-mg-b-0">
{lock ? {lock ?
t.i18n.t('emote-menu.sub-unlock', 'Subscribe for %{price} to unlock %{count} emote%{count|en_plural}', {price: lock.price, count: lock.emotes.size}) : t.i18n.t('emote-menu.sub-unlock', 'Subscribe for {price} to unlock {count,number} emote{count,en_plural}', {price: lock.price, count: lock.emotes.size}) :
t.i18n.t('emote-menu.sub-basic', 'Subscribe to unlock some emotes')} t.i18n.t('emote-menu.sub-basic', 'Subscribe to unlock some emotes')}
<div class="ffz--sub-buttons tw-mg-t-05"> <div class="ffz--sub-buttons tw-mg-t-05">
{Object.values(data.locks).map(lock => (<a {Object.values(data.locks).map(lock => (<a
@ -1269,7 +1268,7 @@ export default class EmoteMenu extends Module {
sort_key = 75; sort_key = 75;
} }
} else } else
title = t.i18n.t('emote-menu.unknown-set', 'Set #%{set_id}', {set_id}) title = t.i18n.t('emote-menu.unknown-set', 'Set #{set_id}', {set_id})
} }
let section, emotes; let section, emotes;
@ -1516,7 +1515,7 @@ export default class EmoteMenu extends Module {
title = provider === 'main' ? title = provider === 'main' ?
t.i18n.t('emote-menu.main-set', 'Channel Emotes') : t.i18n.t('emote-menu.main-set', 'Channel Emotes') :
(emote_set.title || t.i18n.t('emote-menu.unknown', `Set #${emote_set.id}`)); (emote_set.title || t.i18n.t('emote-menu.unknown-set', `Set #{set_id}`, {set_id: emote_set.id}));
let sort_key = pdata && pdata.sort_key || emote_set.sort; let sort_key = pdata && pdata.sort_key || emote_set.sort;
if ( sort_key == null ) if ( sort_key == null )
@ -1616,7 +1615,7 @@ export default class EmoteMenu extends Module {
{this.state.filtered ? {this.state.filtered ?
t.i18n.t('emote-menu.empty-search', 'There are no matching emotes.') : t.i18n.t('emote-menu.empty-search', 'There are no matching emotes.') :
this.state.tab === 'fav' ? this.state.tab === 'fav' ?
t.i18n.t('emote-menu.empty-favs', "You don't have any favorite emotes. To favorite an emote, find it and %{hotkey}-Click it.", {hotkey: IS_OSX ? '⌘' : 'Ctrl'}) : t.i18n.t('emote-menu.empty-favs', "You don't have any favorite emotes. To favorite an emote, find it and {hotkey}-Click it.", {hotkey: IS_OSX ? '⌘' : 'Ctrl'}) :
t.i18n.t('emote-menu.empty', "There's nothing here.")} t.i18n.t('emote-menu.empty', "There's nothing here.")}
</div>) </div>)
} }

View file

@ -560,7 +560,7 @@ export default class ChatHook extends Module {
this.ChatContainer.on('mount', this.containerMounted, this); this.ChatContainer.on('mount', this.containerMounted, this);
this.ChatContainer.on('unmount', this.removeRoom, this); this.ChatContainer.on('unmount', this.removeRoom, this);
this.ChatContainer.on('receive-props', this.containerUpdated, this); this.ChatContainer.on('update', this.containerUpdated, this);
this.ChatContainer.ready((cls, instances) => { this.ChatContainer.ready((cls, instances) => {
const t = this, const t = this,
@ -1544,15 +1544,23 @@ export default class ChatHook extends Module {
cs = data.user && data.user.broadcastBadges || [], cs = data.user && data.user.broadcastBadges || [],
ocs = odata.user && odata.user.broadcastBadges || []; ocs = odata.user && odata.user.broadcastBadges || [];
if ( bs.length !== obs.length ) if ( ! this.chat.badges.hasTwitchBadges() || bs.length !== obs.length )
this.chat.badges.updateTwitchBadges(bs); this.chat.badges.updateTwitchBadges(bs);
if ( cs.length !== ocs.length ) if ( ! this.hasRoomBadges(cont) || cs.length !== ocs.length )
this.updateRoomBadges(cont, cs); this.updateRoomBadges(cont, cs);
this.updateRoomRules(cont, props.chatRules); this.updateRoomRules(cont, props.chatRules);
} }
hasRoomBadges(cont) { // eslint-disable-line class-methods-use-this
const room = cont._ffz_room;
if ( ! room )
return false;
return room.hasBadges();
}
updateRoomBadges(cont, badges) { // eslint-disable-line class-methods-use-this updateRoomBadges(cont, badges) { // eslint-disable-line class-methods-use-this
const room = cont._ffz_room; const room = cont._ffz_room;
if ( ! room ) if ( ! room )

View file

@ -308,10 +308,10 @@ export default class ChatLine extends Module {
return e('div', { return e('div', {
className: 'chat-line__status' className: 'chat-line__status'
}, t.i18n.t('chat.deleted-messages', [ }, t.i18n.t('chat.deleted-messages', `{count,plural,
'%{count} message was deleted by a moderator.', one {One message was deleted by a moderator.}
'%{count} messages were deleted by a moderator.' other {# messages were deleted by a moderator.}
], { }`, {
count: deleted_count count: deleted_count
})); }));
} }
@ -334,7 +334,7 @@ export default class ChatLine extends Module {
const action = msg.modActionType; const action = msg.modActionType;
if ( action === 'timeout' ) if ( action === 'timeout' )
mod_action = t.i18n.t('chat.mod-action.timeout', mod_action = t.i18n.t('chat.mod-action.timeout',
'%{duration} Timeout' '{duration} Timeout'
, { , {
duration: print_duration(msg.duration || 1) duration: print_duration(msg.duration || 1)
}); });
@ -344,7 +344,7 @@ export default class ChatLine extends Module {
mod_action = t.i18n.t('chat.mod-action.delete', 'Deleted'); mod_action = t.i18n.t('chat.mod-action.delete', 'Deleted');
if ( mod_action && msg.modLogin ) if ( mod_action && msg.modLogin )
mod_action = t.i18n.t('chat.mod-action.by', '%{action} by %{login}', { mod_action = t.i18n.t('chat.mod-action.by', '{action} by {login}', {
login: msg.modLogin, login: msg.modLogin,
action: mod_action action: mod_action
}); });
@ -463,7 +463,7 @@ export default class ChatLine extends Module {
if ( mystery ) if ( mystery )
msg.mystery.line = this; msg.mystery.line = this;
const sub_msg = t.i18n.tList('chat.sub.gift', "%{user} is gifting %{count} Tier %{tier} Sub%{count|en_plural} to %{channel}'s community! ", { const sub_msg = t.i18n.tList('chat.sub.gift', "{user} is gifting {count,number} Tier {tier} Sub{count,en_plural} to {channel}'s community! ", {
user: (msg.sub_anon || user.username === 'ananonymousgifter') ? user: (msg.sub_anon || user.username === 'ananonymousgifter') ?
t.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') : t.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') :
e('span', { e('span', {
@ -481,7 +481,7 @@ export default class ChatLine extends Module {
if ( msg.sub_total === 1 ) if ( msg.sub_total === 1 )
sub_msg.push(t.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!")); sub_msg.push(t.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!"));
else if ( msg.sub_total > 1 ) else if ( msg.sub_total > 1 )
sub_msg.push(t.i18n.t('chat.sub.gift-total', "They've gifted %{count} Subs in the channel!", { sub_msg.push(t.i18n.t('chat.sub.gift-total', "They've gifted {count} Subs in the channel!", {
count: msg.sub_total count: msg.sub_total
})); }));
@ -551,7 +551,7 @@ export default class ChatLine extends Module {
const plan = msg.sub_plan || {}, const plan = msg.sub_plan || {},
tier = SUB_TIERS[plan.plan] || 1; tier = SUB_TIERS[plan.plan] || 1;
const sub_msg = t.i18n.tList('chat.sub.mystery', '%{user} gifted a %{plan} Sub to %{recipient}! ', { const sub_msg = t.i18n.tList('chat.sub.mystery', '{user} gifted a {plan} Sub to {recipient}! ', {
user: (msg.sub_anon || user.username === 'ananonymousgifter') ? user: (msg.sub_anon || user.username === 'ananonymousgifter') ?
t.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') : t.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') :
e('span', { e('span', {
@ -562,7 +562,7 @@ export default class ChatLine extends Module {
className: 'tw-c-text-base tw-strong' className: 'tw-c-text-base tw-strong'
}, user.userDisplayName)), }, user.userDisplayName)),
plan: plan.plan === 'custom' ? '' : plan: plan.plan === 'custom' ? '' :
t.i18n.t('chat.sub.gift-plan', 'Tier %{tier}', {tier}), t.i18n.t('chat.sub.gift-plan', 'Tier {tier}', {tier}),
recipient: e('span', { recipient: e('span', {
role: 'button', role: 'button',
className: 'chatter-name', className: 'chatter-name',
@ -576,7 +576,7 @@ export default class ChatLine extends Module {
if ( msg.sub_total === 1 ) if ( msg.sub_total === 1 )
sub_msg.push(t.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!")); sub_msg.push(t.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!"));
else if ( msg.sub_total > 1 ) else if ( msg.sub_total > 1 )
sub_msg.push(t.i18n.t('chat.sub.gift-total', "They've gifted %{count} Subs in the channel!", { sub_msg.push(t.i18n.t('chat.sub.gift-total', "They've gifted {count,number} Subs in the channel!", {
count: msg.sub_total count: msg.sub_total
})); }));
@ -609,7 +609,7 @@ export default class ChatLine extends Module {
const plan = msg.sub_plan || {}, const plan = msg.sub_plan || {},
tier = SUB_TIERS[plan.plan] || 1; tier = SUB_TIERS[plan.plan] || 1;
const sub_msg = t.i18n.tList('chat.sub.main', '%{user} subscribed %{plan}. ', { const sub_msg = t.i18n.tList('chat.sub.main', '{user} subscribed {plan}. ', {
user: e('span', { user: e('span', {
role: 'button', role: 'button',
className: 'chatter-name', className: 'chatter-name',
@ -619,13 +619,13 @@ export default class ChatLine extends Module {
}, user.userDisplayName)), }, user.userDisplayName)),
plan: plan.prime ? plan: plan.prime ?
t.i18n.t('chat.sub.twitch-prime', 'with Twitch Prime') : t.i18n.t('chat.sub.twitch-prime', 'with Twitch Prime') :
t.i18n.t('chat.sub.plan', 'at Tier %{tier}', {tier}) t.i18n.t('chat.sub.plan', 'at Tier {tier}', {tier})
}); });
if ( msg.sub_share_streak && msg.sub_streak > 1 ) { if ( msg.sub_share_streak && msg.sub_streak > 1 ) {
sub_msg.push(t.i18n.t( sub_msg.push(t.i18n.t(
'chat.sub.cumulative-months', 'chat.sub.cumulative-months',
"They've subscribed for %{cumulative} months, currently on a %{streak} month streak!", "They've subscribed for {cumulative,number} months, currently on a {streak,number} month streak!",
{ {
cumulative: msg.sub_cumulative, cumulative: msg.sub_cumulative,
streak: msg.sub_streak streak: msg.sub_streak
@ -635,7 +635,7 @@ export default class ChatLine extends Module {
} else if ( months > 1 ) { } else if ( months > 1 ) {
sub_msg.push(t.i18n.t( sub_msg.push(t.i18n.t(
'chat.sub.months', 'chat.sub.months',
"They've subscribed for %{count} months!", "They've subscribed for {count,number} months!",
{ {
count: months count: months
} }
@ -668,7 +668,7 @@ export default class ChatLine extends Module {
let system_msg; let system_msg;
if ( msg.ritual === 'new_chatter' ) if ( msg.ritual === 'new_chatter' )
system_msg = e('div', {className: 'tw-c-text-alt-2'}, [ system_msg = e('div', {className: 'tw-c-text-alt-2'}, [
t.i18n.tList('chat.ritual', '%{user} is new here. Say hello!', { t.i18n.tList('chat.ritual', '{user} is new here. Say hello!', {
user: e('span', { user: e('span', {
role: 'button', role: 'button',
className: 'chatter-name', className: 'chatter-name',

View file

@ -103,7 +103,7 @@ export default class RichContent extends Module {
title = ''; title = '';
} }
return (<div class={`tw-overflow-hidden tw-align-items-center tw-flex${desc_2 ? ' ffz--two-line' : ''}`}> return (<div class={`ffz--card-text tw-overflow-hidden tw-align-items-center tw-flex${desc_2 ? ' ffz--two-line' : ''}`}>
<div class="tw-full-width tw-pd-l-1"> <div class="tw-full-width tw-pd-l-1">
<div class="chat-card__title tw-ellipsis"> <div class="chat-card__title tw-ellipsis">
<span <span

View file

@ -189,7 +189,7 @@ export default class Scroller extends Module {
this._ffz_freeze_indicator.firstElementChild.textContent = t.i18n.t( this._ffz_freeze_indicator.firstElementChild.textContent = t.i18n.t(
'chat.paused', 'chat.paused',
'(Chat Paused Due to %{reason})', '(Chat Paused Due to {reason})',
{reason} {reason}
); );
} }

View file

@ -340,7 +340,7 @@ export default class Following extends SiteModule {
card_title = card.querySelector('.live-channel-card__title'), card_title = card.querySelector('.live-channel-card__title'),
text_content = host_data.channels.length !== 1 ? text_content = host_data.channels.length !== 1 ?
this.i18n.t('host-menu.multiple', '%{count} hosting %{channel}', { this.i18n.t('host-menu.multiple', '{count,number} hosting {channel}', {
count: host_data.channels.length, count: host_data.channels.length,
channel: data.displayName channel: data.displayName
}) : inst.props.title; }) : inst.props.title;

View file

@ -255,11 +255,11 @@ export default class Directory extends SiteModule {
<p class="tw-c-text-alt tw-ellipsis">{ <p class="tw-c-text-alt tw-ellipsis">{
game ? game ?
game.id == CREATIVE_ID ? game.id == CREATIVE_ID ?
t.i18n.tList('directory.user-creative', '%{user} being %{game}', { t.i18n.tList('directory.user-creative', '{user} being {game}', {
user: user_link, user: user_link,
game: game_link game: game_link
}) : }) :
t.i18n.tList('directory.user-playing', '%{user} playing %{game}', { t.i18n.tList('directory.user-playing', '{user} playing {game}', {
user: user_link, user: user_link,
game: game_link game: game_link
}) })
@ -269,8 +269,8 @@ export default class Directory extends SiteModule {
<div data-test-selector="preview-card-titles__subtitle"> <div data-test-selector="preview-card-titles__subtitle">
<p class="tw-c-text-alt tw-ellipsis">{ <p class="tw-c-text-alt tw-ellipsis">{
nodes.length > 1 ? nodes.length > 1 ?
t.i18n.t('directory.hosted.by-many', 'Hosted by %{count} channel%{count|en_plural}', nodes.length) : t.i18n.t('directory.hosted.by-many', 'Hosted by {count,number} channel{count,en_plural}', nodes.length) :
t.i18n.tList('directory.hosted.by-one', 'Hosted by %{user}', { t.i18n.tList('directory.hosted.by-one', 'Hosted by {user}', {
user: <a href={`/${nodes[0].login}`} data-href={`/${nodes[0].login}`} onClick={t.routeClick} title={nodes[0].displayName} class="tw-link tw-link--inherit">{nodes[0].displayName}</a> user: <a href={`/${nodes[0].login}`} data-href={`/${nodes[0].login}`} onClick={t.routeClick} title={nodes[0].displayName} class="tw-link tw-link--inherit">{nodes[0].displayName}</a>
}) })
}</p> }</p>
@ -421,8 +421,8 @@ export default class Directory extends SiteModule {
if ( inst.ffz_last_created_at !== created_at ) { if ( inst.ffz_last_created_at !== created_at ) {
inst.ffz_uptime_tt.textContent = this.i18n.t( inst.ffz_uptime_tt.textContent = this.i18n.t(
'metadata.uptime.since', 'metadata.uptime.since',
'(since %{since})', '(since {since,datetime})',
{since: up_since.toLocaleString()} {since: up_since}
); );
inst.ffz_last_created_at = created_at; inst.ffz_last_created_at = created_at;

View file

@ -86,23 +86,23 @@ export default class HostButton extends Module {
return; return;
if ( this._host_updating ) if ( this._host_updating )
return 'Updating...'; return this.i18n.t('metadata.host-button.updating', 'Updating...');
return (this._last_hosted_channel && this.isChannelHosted(data.channel && data.channel.login)) return (this._last_hosted_channel && this.isChannelHosted(data.channel && data.channel.login))
? this.i18n.t('metadata.host.button.unhost', 'Unhost') ? this.i18n.t('metadata.host-button.unhost', 'Unhost')
: this.i18n.t('metadata.host.button.host', 'Host'); : this.i18n.t('metadata.host-button.host', 'Host');
}, },
tooltip: () => { tooltip: () => {
if (this._host_error) { if (this._host_error) {
return this.i18n.t( return this.i18n.t(
`metadata.host.button.tooltip.error.${this._host_error.key}`, `metadata.host-button.tooltip.error.${this._host_error.key}`,
this._host_error.text); this._host_error.text);
} else { } else {
return this.i18n.t('metadata.host.button.tooltip', return this.i18n.t('metadata.host-button.tooltip',
'Currently hosting: %{channel}', 'Currently hosting: {channel}',
{ {
channel: this._last_hosted_channel || this.i18n.t('metadata.host.button.tooltip.none', 'None') channel: this._last_hosted_channel || this.i18n.t('metadata.host-button.tooltip.none', 'None')
}); });
} }
} }
@ -189,6 +189,8 @@ export default class HostButton extends Module {
} }
onEnable() { onEnable() {
this.on('i18n:update', () => this.metadata.updateMetadata('host'));
this.metadata.updateMetadata('host'); this.metadata.updateMetadata('host');
this.chat.ChatService.ready((cls, instances) => { this.chat.ChatService.ready((cls, instances) => {

View file

@ -36,6 +36,12 @@ export default class Player extends Module {
PLAYER_ROUTES PLAYER_ROUTES
); );
this.SquadStreamBar = this.fine.define(
'squad-stream-bar',
n => n.shouldRenderSquadBanner && n.props && n.props.triggerPlayerReposition,
PLAYER_ROUTES
);
this.settings.add('player.volume-scroll', { this.settings.add('player.volume-scroll', {
default: false, default: false,
ui: { ui: {
@ -273,6 +279,16 @@ export default class Player extends Module {
} }
}); });
this.settings.add('player.hide-squad-banner', {
default: false,
ui: {
path: 'Player > General >> General',
title: 'Hide Squad Streaming Bar',
component: 'setting-check-box'
},
changed: () => this.SquadStreamBar.forceUpdate()
});
this.settings.add('player.hide-mouse', { this.settings.add('player.hide-mouse', {
default: true, default: true,
ui: { ui: {
@ -375,6 +391,19 @@ export default class Player extends Module {
const t = this; const t = this;
this.SquadStreamBar.ready(cls => {
const old_should_render = cls.prototype.shouldRenderSquadBanner;
cls.prototype.shouldRenderSquadBanner = function(...args) {
if ( t.settings.get('player.hide-squad-banner') )
return false;
return old_should_render.call(this, ...args);
}
this.SquadStreamBar.forceUpdate();
})
this.Player.on('mount', this.onMount, this); this.Player.on('mount', this.onMount, this);
this.Player.on('unmount', this.onUnmount, this); this.Player.on('unmount', this.onUnmount, this);

View file

@ -4,7 +4,7 @@
// Video Chat Hooks // Video Chat Hooks
// ============================================================================ // ============================================================================
import {get} from 'utilities/object'; import {get, has} from 'utilities/object';
import {print_duration} from 'utilities/time'; import {print_duration} from 'utilities/time';
//import {ClickOutside} from 'utilities/dom'; //import {ClickOutside} from 'utilities/dom';
import {formatBitsConfig} from '../chat'; import {formatBitsConfig} from '../chat';
@ -36,6 +36,12 @@ export default class VideoChatHook extends Module {
['user-video', 'user-clip', 'video'] ['user-video', 'user-clip', 'video']
); );
this.VideoChatMenu = this.fine.define(
'video-chat-menu',
n => n.onToggleMenu && n.getContent && n.props && has(n.props, 'isExpandedLayout'),
['user-video', 'user-clip', 'video']
);
this.VideoChatLine = this.fine.define( this.VideoChatLine = this.fine.define(
'video-chat-line', 'video-chat-line',
n => n.onReplyClickHandler && n.shouldFocusMessage, n => n.onReplyClickHandler && n.shouldFocusMessage,
@ -135,10 +141,63 @@ export default class VideoChatHook extends Module {
const createElement = React.createElement, const createElement = React.createElement,
FFZRichContent = this.rich_content && this.rich_content.RichContent; FFZRichContent = this.rich_content && this.rich_content.RichContent;
this.MenuContainer = class FFZMenuContainer extends React.Component {
constructor(props) {
super(props);
this.onBanUser = () => {
this.props.onBanUserClick({
bannedUser: this.props.context.comment.commenter,
targetChannel: this.props.context.comment.channelId,
comment: this.props.context.comment
});
}
this.onDeleteComment = () => {
this.props.onDeleteCommentClick(this.props.context.comment);
}
this.onOpen = () => {
this.props.onDisableSync();
this.setState({
force: true
});
}
this.onClose = () => {
this.setState({
force: false
})
};
this.state = {
force: false
}
}
render() {
if ( ! t.VideoChatMenu._class )
return null;
return (<div class={`tw-flex-shrink-0 video-chat__message-menu${this.state.force ? ' video-chat__message-menu--force-visible' : ''}`}>
<t.VideoChatMenu._class
context={this.props.context}
isCurrentUserModerator={this.props.isCurrentUserModerator}
isExpandedLayout={this.props.isExpandedLayout}
onBanUserClick={this.onBanUser}
onClose={this.onClose}
onDeleteCommentClick={this.onDeleteComment}
onOpen={this.onOpen}
onReplyClick={this.props.onReplyClick}
/>
</div>);
}
}
this.VideoChatLine.ready(cls => { this.VideoChatLine.ready(cls => {
const old_render = cls.prototype.render; const old_render = cls.prototype.render;
cls.prototype.ffzRenderMessage = function(msg) { cls.prototype.ffzRenderMessage = function(msg, reply) {
const is_action = msg.is_action, const is_action = msg.is_action,
user = msg.user, user = msg.user,
color = t.site_chat.colors.process(user.color), color = t.site_chat.colors.process(user.color),
@ -175,6 +234,15 @@ export default class VideoChatHook extends Module {
{rich_content && createElement(FFZRichContent, rich_content)} {rich_content && createElement(FFZRichContent, rich_content)}
</div> </div>
</div> </div>
{ reply ? (<t.MenuContainer
context={reply}
isCurrentUserModerator={this.props.isCurrentUserModerator}
isExpandedLayout={this.props.isExpandedLayout}
onBanUserClick={this.props.onBanUserClick}
onDeleteCommentClick={this.props.onDeleteCommentClick}
onDisableSync={this.props.onDisableSync}
onReplyClick={this.onReplyClickHandler}
/>) : null}
</div>); </div>);
} }
@ -187,11 +255,11 @@ export default class VideoChatHook extends Module {
<span class="tw-button__text tw-pd-0">{ t.i18n.t('video-chat.reply', 'Reply') }</span> <span class="tw-button__text tw-pd-0">{ t.i18n.t('video-chat.reply', 'Reply') }</span>
</button> </button>
<span class="tw-c-text-alt-2 tw-font-size-7 tw-mg-l-05 tw-tooltip-wrapper"> <span class="tw-c-text-alt-2 tw-font-size-7 tw-mg-l-05 tw-tooltip-wrapper">
{ t.i18n.t('video-chat.time', '%{time|humanTime} ago', { { t.i18n.t('video-chat.time', '{time,humantime} ago', {
time: msg.timestamp time: msg.timestamp
}) } }) }
<div class="tw-tooltip tw-tooltip--align-center tw-tooltip--up" role="tooltip"> <div class="tw-tooltip tw-tooltip--align-center tw-tooltip--up" role="tooltip">
{ msg.timestamp.toLocaleString() } { t.i18n.formatDateTime(msg.timestamp, 'full') }
</div> </div>
</span> </span>
</div>) </div>)
@ -206,7 +274,7 @@ export default class VideoChatHook extends Module {
const context = this.props.messageContext, const context = this.props.messageContext,
msg = t.standardizeMessage(context.comment, context.author), msg = t.standardizeMessage(context.comment, context.author),
main_message = this.ffzRenderMessage(msg), main_message = this.ffzRenderMessage(msg, context),
bg_css = msg.mentioned && msg.mention_color ? t.site_chat.inverse_colors.process(msg.mention_color) : null; bg_css = msg.mentioned && msg.mention_color ? t.site_chat.inverse_colors.process(msg.mention_color) : null;
@ -245,7 +313,7 @@ export default class VideoChatHook extends Module {
</div>)} </div>)}
<ul>{ <ul>{
context.replies.map(reply => (<li key={reply.comment && reply.comment.id} class="tw-mg-l-05"> context.replies.map(reply => (<li key={reply.comment && reply.comment.id} class="tw-mg-l-05">
{ this.ffzRenderMessage(t.standardizeMessage(reply.comment, reply.author)) } { this.ffzRenderMessage(t.standardizeMessage(reply.comment, reply.author), reply) }
{ this.props.isExpandedLayout && this.ffzRenderExpanded(msg) } { this.props.isExpandedLayout && this.ffzRenderExpanded(msg) }
</li>)) </li>))
}</ul> }</ul>

View file

@ -47,6 +47,12 @@
} }
.ffz--chat-card { .ffz--chat-card {
.vod-message & {
.ffz--card-text {
max-width: 18rem;
}
}
.chat-card__title { .chat-card__title {
max-width: unset; max-width: unset;
} }

View file

@ -584,6 +584,12 @@ export class FineWrapper extends EventEmitter {
return original.apply(this, args); return original.apply(this, args);
} : } :
key === 'shouldComponentUpdate' ?
function(...args) {
t.emit(event, this, ...args);
return true;
}
:
function(...args) { function(...args) {
t.emit(event, this, ...args); t.emit(event, this, ...args);
}; };

View file

@ -0,0 +1,613 @@
'use strict';
// ============================================================================
// Imports
// ============================================================================
import dayjs from 'dayjs';
import Parser from '@ffz/icu-msgparser';
import {get} from 'utilities/object';
// ============================================================================
// Types
// ============================================================================
export const DEFAULT_TYPES = {
select(val, node, locale, out, ast, data) {
const sub_ast = node.o && (node.o[val] || node.o.other);
if ( ! sub_ast )
return undefined;
return this._processAST(sub_ast, data, locale);
},
plural(val, node, locale, out, ast, data) {
const sub_ast = node.o && (node.o[`=${val}`] || node.o[getCardinalName(locale, val)] || node.o.other);
if ( ! sub_ast )
return undefined;
return this._processAST(sub_ast, data, locale);
},
selectordinal(val, node, locale, out, ast, data) {
const sub_ast = node.o && (node.o[`=${val}`] || node.o[getOrdinalName(locale, val)] || node.o.other);
if ( ! sub_ast )
return undefined;
return this._processAST(sub_ast, data, locale);
},
number(val, node) {
if ( typeof val !== 'number' )
return val;
return this.formatNumber(val, node.f);
},
date(val, node) {
return this.formatDate(val, node.f);
},
time(val, node) {
return this.formatTime(val, node.f);
},
datetime(val, node) {
return this.formatDateTime(val, node.f);
},
duration(val, node) {
},
localestring(val, node) {
return this.toLocaleString(val);
},
humantime(val, node) {
return this.formatHumanTime(val, node.f);
},
en_plural: v => v !== 1 ? 's' : ''
}
export const DEFAULT_FORMATS = {
number: {
currency: {
style: 'currency'
},
percent: {
style: 'percent'
}
},
date: {
short: {
month: 'numeric',
day: 'numeric',
year: '2-digit'
},
long: {
month: 'long',
day: 'numeric',
year: 'numeric'
},
full: {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
}
},
time: {
short: {
hour: 'numeric',
minute: 'numeric'
},
medium: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
},
long: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short'
},
full: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short'
}
},
datetime: {
short: {
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour: 'numeric',
minute: 'numeric'
},
medium: {
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
},
long: {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short'
},
full: {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short'
}
}
}
// ============================================================================
// TranslationCore
// ============================================================================
export default class TranslationCore {
constructor(options) {
options = options || {};
this.warn = options.warn;
this._locale = options.locale || 'en';
this.defaultLocale = options.defaultLocale || this._locale;
this.transformation = null;
this.phrases = new Map;
this.cache = new Map;
this.numberFormats = new Map;
this.formats = Object.assign({}, DEFAULT_FORMATS);
if ( options.formats )
for(const key of Object.keys(options.formats))
this.formats[key] = Object.assign({}, this.formats[key], options.formats[key]);
this.types = Object.assign({}, DEFAULT_TYPES, options.types || {});
this.parser = new Parser(options.parserOptions);
if ( options.phrases )
this.extend(options.phrases);
}
get locale() {
return this._locale;
}
set locale(val) {
if ( val !== this._locale ) {
this._locale = val;
this.numberFormats.clear();
}
}
toLocaleString(thing) {
if ( thing && thing.toLocaleString )
return thing.toLocaleString(this._locale);
return thing;
}
formatHumanTime(value, factor) {
if ( value instanceof Date )
value = (Date.now() - value.getTime()) / 1000;
value = Math.floor(value);
factor = Number(factor) || 1;
const years = Math.floor((value * factor) / 31536000) / factor;
if ( years >= 1 )
return this.t('human-time.years', '{count,number} year{count,en_plural}', years);
const days = Math.floor((value %= 31536000) / 86400);
if ( days >= 1 )
return this.t('human-time.days', '{count,number} day{count,en_plural}', days);
const hours = Math.floor((value %= 86400) / 3600);
if ( hours >= 1 )
return this.t('human-time.hours', '{count,number} hour{count,en_plural}', hours);
const minutes = Math.floor((value %= 3600) / 60);
if ( minutes >= 1 )
return this.t('human-time.minutes', '{count,number} minute{count,en_plural}', minutes);
const seconds = value % 60;
if ( seconds >= 1 )
return this.t('human-time.seconds', '{count,number} second{count,en_plural}', seconds);
return this.t('human-time.none', 'less than a second');
}
formatNumber(value, format) {
let formatter = this.numberFormats.get(format);
if ( ! formatter ) {
formatter = new Intl.NumberFormat(this.locale, this.formats.number[format]);
this.numberFormats.set(format, formatter);
}
return formatter.format(value);
}
formatDate(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),
d = dayjs(value);
try {
return d.locale(this._locale).format(f);
} catch(err) {
return d.format(f);
}
}
if ( !(value instanceof Date) )
value = new Date(value);
return value.toLocaleDateString(this._locale, this.formats.date[format] || {});
}
formatTime(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),
d = dayjs(value);
try {
return d.locale(this._locale).format(f);
} catch(err) {
return d.format(f);
}
}
if ( !(value instanceof Date) )
value = new Date(value);
return value.toLocaleTimeString(this._locale, this.formats.time[format] || {});
}
formatDateTime(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),
d = dayjs(value);
try {
return d.locale(this._locale).format(f);
} catch(err) {
return d.format(f);
}
}
if ( !(value instanceof Date) )
value = new Date(value);
return value.toLocaleString(this._locale, this.formats.datetime[format] || {});
}
extend(phrases, prefix) {
const added = [];
if ( ! phrases || typeof phrases !== 'object' )
return added;
for(const key of Object.keys(phrases)) {
const full_key = prefix ? key === '_' ? prefix : `${prefix}.${key}` : key,
phrase = phrases[key];
if ( typeof phrase === 'object' )
added.push(...this.extend(phrase, full_key));
else {
let parsed;
try {
parsed = this.parser.parse(phrase);
} catch(err) {
if ( this.warn )
this.warn(`Error parsing i18n phrase for key "${full_key}": ${phrase}`, err);
continue;
}
this.phrases.set(full_key, phrase);
this.cache.set(full_key, parsed);
added.push(full_key);
}
}
return added;
}
unset(phrases, prefix) {
if ( typeof phrases === 'string' )
phrases = [phrases];
const keys = Array.isArray(phrases) ? phrases : Object.keys(phrases);
for(const key of keys) {
const full_key = prefix ? key === '_' ? prefix : `${prefix}.${key}` : key,
phrase = phrases[key];
if ( typeof phrase === 'object' )
this.unset(phrases, full_key);
else {
this.phrases.delete(full_key);
this.cache.delete(full_key);
}
}
}
has(key) {
return this.phrases.has(key);
}
set(key, phrase) {
const parsed = this.parser.parse(phrase);
this.phrases.set(key, phrase);
this.cache.set(key, parsed);
}
clear() {
this.phrases.clear();
this.cache.clear();
}
replace(phrases) {
this.clear();
this.extend(phrases);
}
_preTransform(key, phrase, options) {
let ast, locale, data = options == null ? {} : options;
if ( typeof data === 'number' )
data = {count: data};
if ( this.phrases.has(key) ) {
ast = this.cache.get(key);
locale = this.locale;
} else if ( this.cache.has(key) ) {
ast = this.cache.get(key);
locale = this.defaultLocale;
} else {
let parsed = null;
try {
parsed = this.parser.parse(phrase);
} catch(err) {
if ( this.warn )
this.warn(`Error parsing i18n phrase for key "${key}": ${phrase}`, err);
ast = ['parsing error'];
locale = this.defaultLocale;
}
if ( parsed ) {
ast = parsed;
locale = this.locale;
if ( this.locale === this.defaultLocale )
this.phrases.set(key, phrase);
this.cache.set(key, parsed);
}
}
if ( this.transformation )
ast = this.transformation(key, ast);
return [ast, data, locale];
}
t(key, phrase, options, use_default) {
return listToString(this.tList(key, phrase, options, use_default));
}
tList(key, phrase, options, use_default) {
return this._processAST(...this._preTransform(key, phrase, options, use_default));
}
_processAST(ast, data, locale) {
const out = [];
for(const node of ast) {
if ( typeof node === 'string' ) {
out.push(node);
continue;
} else if ( ! node || typeof node !== 'object' )
continue;
let val = get(node.v, data);
if ( val == null )
continue;
if ( node.t ) {
if ( this.types[node.t] )
val = this.types[node.t].call(this, val, node, locale, out, ast, data);
else if ( this.warn )
this.warn(`Encountered unknown type "${node.t}" when processing AST.`);
}
if ( val )
out.push(val);
}
return out;
}
}
function listToString(list) {
if ( ! Array.isArray(list) )
return String(list);
return list.map(listToString).join('');
}
// ============================================================================
// Plural Handling
// ============================================================================
const CARDINAL_TO_LANG = {
arabic: ['ar'],
danish: ['da'],
german: ['de', 'el', 'en', 'es', 'fi', 'hu', 'it', 'nl', 'no', 'nb', 'tr', 'sv'],
hebrew: ['he'],
persian: ['fa'],
french: ['fr', 'pt'],
russian: ['ru']
}
const CARDINAL_TYPES = {
other: () => 5,
arabic(n) {
if ( n === 0 ) return 0;
if ( n === 1 ) return 1;
if ( n === 2 ) return 2;
const n1 = n % 1000;
if ( n1 >= 3 && n1 <= 10 ) return 3;
return n1 >= 11 ? 4 : 5;
},
danish: (n,i,v,t) => (n === 1 || (t !== 0 && (i === 0 || i === 1))) ? 1 : 5,
french: (n, i) => (i === 0 || i === 1) ? 1 : 5,
german: n => n === 1 ? 1 : 5,
hebrew(n) {
if ( n === 1 ) return 1;
if ( n === 2 ) return 2;
return (n > 10 && n % 10 === 0) ? 4 : 5;
},
persian: (n, i) => (i === 0 || n === 1) ? 1 : 5,
russian(n,i,v) {
const n1 = n % 10, n2 = n % 100;
if ( n1 === 1 && n2 !== 11 ) return 1;
if ( v === 0 && (n1 >= 2 && n1 <= 4) && (n2 < 12 || n2 > 14) ) return 3;
return ( v === 0 && (n1 === 0 || (n1 >= 5 && n1 <= 9) || (n2 >= 11 || n2 <= 14)) ) ? 4 : 5
}
}
const ORDINAL_TO_LANG = {
english: ['en'],
hungarian: ['hu'],
italian: ['it'],
one: ['fr', 'lo', 'ms'],
swedish: ['sv']
};
const ORDINAL_TYPES = {
other: () => 5,
one: n => n === 1 ? 1 : 5,
english(n) {
const n1 = n % 10, n2 = n % 100;
if ( n1 === 1 && n2 !== 11 ) return 1;
if ( n1 === 2 && n2 !== 12 ) return 2;
if ( n1 === 3 && n2 !== 13 ) return 3;
return 5;
},
hungarian: n => (n === 1 || n === 5) ? 1 : 5,
italian: n => (n === 11 || n === 8 || n === 80 || n === 800) ? 4 : 5,
swedish(n) {
const n1 = n % 10, n2 = n % 100;
return ((n1 === 1 || n1 === 2) && (n2 !== 11 && n2 !== 12)) ? 1 : 5;
}
}
const PLURAL_TO_NAME = [
'zero', // 0
'one', // 1
'two', // 2
'few', // 3
'many', // 4
'other' // 5
];
const CARDINAL_LANG_TO_TYPE = {},
ORDINAL_LANG_TO_TYPE = {};
for(const type of Object.keys(CARDINAL_TO_LANG))
for(const lang of CARDINAL_TO_LANG[type])
CARDINAL_LANG_TO_TYPE[lang] = type;
for(const type of Object.keys(ORDINAL_TO_LANG))
for(const lang of ORDINAL_TO_LANG[type])
ORDINAL_LANG_TO_TYPE[lang] = type;
function executePlural(fn, input) {
input = Math.abs(Number(input));
const i = Math.floor(input);
let v, t;
if ( i === input ) {
v = 0;
t = 0;
} else {
t = `${input}`.split('.')[1]
v = t ? t.length : 0;
t = t ? Number(t) : 0;
}
return PLURAL_TO_NAME[fn(
input,
i,
v,
t
)]
}
export function getCardinalName(locale, input) {
let type = CARDINAL_LANG_TO_TYPE[locale];
if ( ! type ) {
const idx = locale.indexOf('-');
type = (idx !== -1 && CARDINAL_LANG_TO_TYPE[locale.slice(0, idx)]) || 'other';
CARDINAL_LANG_TO_TYPE[locale] = type;
}
return executePlural(CARDINAL_TYPES[type], input);
}
export function getOrdinalName(locale, input) {
let type = ORDINAL_LANG_TO_TYPE[locale];
if ( ! type ) {
const idx = locale.indexOf('-');
type = (idx !== -1 && ORDINAL_LANG_TO_TYPE[locale.slice(0, idx)]) || 'other';
ORDINAL_LANG_TO_TYPE[locale] = type;
}
return executePlural(ORDINAL_TYPES[type], input);
}

View file

@ -74,6 +74,10 @@ module.exports = {
} }
}] }]
}, },
{
test: /\.md$/,
loader: 'raw-loader'
},
{ {
test: /\.svg$/, test: /\.svg$/,
loader: 'raw-loader' loader: 'raw-loader'