1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-08-02 16:08:31 +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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",

View file

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

View file

@ -2,35 +2,32 @@
// ============================================================================
// 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 {get, pick_random, has, timeout} from 'utilities/object';
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'],
format_text = (phrase, token_regex, formatter) => {
const out = [];
transformText = (ast, fn) => {
return ast.map(node => {
if ( typeof node === 'string' )
return fn(node);
let i = 0, match;
token_regex.lastIndex = 0;
while((match = token_regex.exec(phrase))) {
if ( match.index !== i )
out.push(formatter(phrase.slice(i, match.index)))
out.push(match[0]);
i = match.index + match[0].length;
else if ( typeof node === 'object' && node.o ) {
const out = Object.assign(node, {o: {}});
for(const key of Object.keys(node.o))
out.o[key] = transformText(node.o[key], fn)
}
if ( i < phrase.length )
out.push(formatter(phrase.slice(i)));
return out.join('')
return node;
})
},
owo = text => text
@ -44,19 +41,12 @@ const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'Uw
TRANSFORMATIONS = {
double: (key, text) =>
`${text} ${text}`,
upper: (key, text, opts, locale, token_regex) =>
format_text(text, token_regex, t => t.toUpperCase()),
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))
double: (key, ast) => [...ast, ' ', ...ast],
upper: (key, ast) => transformText(ast, n => n.toUpperCase()),
lower: (key, ast) => transformText(ast, n => n.toLowerCase()),
append_key: (key, ast) => [...ast, ` (${key})`],
set_key: (key, ast) => [key],
owo: (key, ast) => transformText(ast, owo)
};
@ -69,14 +59,16 @@ export class TranslationManager extends Module {
super(...args);
this.inject('settings');
this.parser = new Parser;
this._seen = new Set;
this.availableLocales = ['en']; //, 'de', 'ja'];
this.localeData = {
en: { name: 'English' },
//de: { name: 'Deutsch' },
//ja: { name: '日本語' }
en: { name: 'English' }/*,
de: { name: 'Deutsch' },
ja: { name: '日本語' }*/
}
@ -92,6 +84,7 @@ export class TranslationManager extends Module {
{value: 'upper', title: 'Upper Case'},
{value: 'lower', title: 'Lower Case'},
{value: 'append_key', title: 'Append Key'},
{value: 'set_key', title: 'Set to Key'},
{value: 'double', title: 'Double'},
{value: 'owo', title: "owo what's this"}
]
@ -137,10 +130,8 @@ export class TranslationManager extends Module {
}
onEnable() {
this._ = new TranslationCore({
formatters: {
'humanTime': n => this.toHumanTime(n)
}
this._ = new NewTransCore({ //TranslationCore({
warn: (...args) => this.log.warn(...args),
});
if ( window.BroadcastChannel ) {
@ -210,48 +201,8 @@ export class TranslationManager extends Module {
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) {
/*if ( locale === 'en' )
if ( locale === 'en' )
return {};
if ( locale === 'de' )
@ -363,7 +314,7 @@ export class TranslationManager extends Module {
support: '対応'
}
}
}*/
}
const resp = await fetch(`${SERVER}/script/i18n/${locale}.json`);
if ( ! resp.ok ) {
@ -384,6 +335,8 @@ export class TranslationManager extends Module {
if ( new_locale === old_locale )
return [];
await this.loadDayjsLocale(new_locale);
this._.locale = new_locale;
this._.clear();
this.log.info(`Changed Locale: ${new_locale} -- Old: ${old_locale}`);
@ -412,14 +365,49 @@ export class TranslationManager extends Module {
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) {
return this._.has(key);
}
toLocaleString(...args) {
return this._.toLocaleString(...args);
}
toHumanTime(...args) {
return this._.formatHumanTime(...args);
}
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) {
this.see(key);
return this._.t(key, ...args);
@ -437,7 +425,7 @@ export class TranslationManager extends Module {
// TranslationCore
// ============================================================================
const REPLACE = String.prototype.replace,
const REPLACE = String.prototype.replace; /*,
SPLIT = String.prototype.split;
const DEFAULT_FORMATTERS = {
@ -460,7 +448,7 @@ export default class TranslationCore {
this.onMissingKey = typeof options.onMissingKey === 'function' ? options.onMissingKey : allowMissing;
this.transformPhrase = typeof options.transformPhrase === 'function' ? options.transformPhrase : transformPhrase;
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.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);
}
}
}*/
// ============================================================================
@ -593,10 +581,7 @@ export function transformList(phrase, substitutions, locale, token_regex, format
const options = typeof substitutions === 'number' ? {count: substitutions} : substitutions;
if ( is_array )
p = p[pluralTypeIndex(
locale || 'en',
has(options, 'count') ? options.count : 1
)] || p[0];
p = p[0];
const result = [];
@ -642,10 +627,7 @@ export function transformPhrase(phrase, substitutions, locale, token_regex, form
const options = typeof substitutions === 'number' ? {count: substitutions} : substitutions;
if ( is_array )
result = result[pluralTypeIndex(
locale || 'en',
has(options, 'count') ? options.count : 1
)] || result[0];
result = result[0];
if ( typeof result === 'string' )
result = REPLACE.call(result, token_regex, (expr, arg, fmt) => {
@ -664,64 +646,3 @@ export function transformPhrase(phrase, substitutions, locale, token_regex, form
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;
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__,
build: __webpack_hash__,
toString: () =>
@ -166,7 +166,9 @@ FrankerFaceZ.utilities = {
logging: require('utilities/logging'),
object: require('utilities/object'),
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">
{{ t('setting.actions.variables', 'Available Variables: %{vars}', {vars}) }}
{{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }}
</div>
</div>
</div>

View file

@ -14,7 +14,7 @@
>
<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>

View file

@ -40,7 +40,7 @@ export default class Actions extends Module {
always_inherit: true,
ui: {
path: 'Chat > Actions > Reasons',
path: 'Chat > Actions > Reasons >> Custom 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', {
// Filter out actions
process: (ctx, val) =>
@ -184,7 +193,7 @@ export default class Actions extends Module {
renderInlineReasons(data, t, tip) {
const reasons = this.parent.context.get('chat.actions.reasons'),
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;
if ( ! reasons && ! rules ) {
@ -201,7 +210,7 @@ export default class Actions extends Module {
if ( reasons && reasons.length ) {
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">
<a

View file

@ -114,7 +114,7 @@ export const msg_delete = {
title: 'Delete Message',
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) {
@ -147,11 +147,11 @@ export const ban = {
title: 'Ban User',
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) {
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) {
@ -183,11 +183,11 @@ export const timeout = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-timeout.vue'),
title: 'Timeout User',
description: '%{options.duration} second%{options.duration|en_plural}',
description: '{options.duration,number} second%{options.duration,en_plural}',
reason_text(data) {
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,
duration: data.options.duration
@ -198,7 +198,7 @@ export const timeout = {
tooltip(data) {
return this.i18n.t(
'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,
duration: data.options.duration
@ -230,7 +230,7 @@ export const unban = {
title: 'Unban User',
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) {
@ -257,7 +257,7 @@ export const untimeout = {
title: 'Untimeout User',
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) {
@ -283,7 +283,7 @@ export const whisper = {
title: 'Whisper User',
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) {
@ -326,7 +326,7 @@ export const gift_sub = {
title: 'Gift Subscription',
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() {

View file

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

View file

@ -1168,7 +1168,7 @@ export default class Chat extends Module {
l = parts.length,
emotes = {};
let idx = 0, ret, last_type = null;
let idx = 0, ret, last_type = null, bits = 0;
for(let i=0; i < l; i++) {
const part = parts[i],
@ -1186,10 +1186,11 @@ export default class Chat extends Module {
else if ( content.url )
ret = content.url;
else if ( content.cheerAmount )
else if ( content.cheerAmount ) {
bits += 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),
match = url && /\/emoticons\/v1\/(\d+)\/[\d.]+$/.exec(url['1x']),
id = match && match[1];
@ -1219,6 +1220,7 @@ export default class Chat extends Module {
if ( ! emotes_only )
msg.message = out.join('');
msg.bits = bits;
msg.ffz_emotes = emotes;
return msg;
}
@ -1228,8 +1230,15 @@ export default class Chat extends Module {
if (!( time instanceof Date ))
time = new Date(time);
const fmt = this.context.get('chat.timestamp-format');
return dayjs(time).locale(this.i18n.locale).format(fmt);
const fmt = this.context.get('chat.timestamp-format'),
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;
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
});
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,
game: game_display
});
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 {
url: token.url,
image: clip.thumbnailURL,
title: clip.title,
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'),
views: clip.viewCount
})
@ -170,25 +170,25 @@ export const Videos = {
let desc_1;
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
});
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,
game: game_display
});
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 {
url: token.url,
image: video.previewThumbnailURL,
title: video.title,
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,
views: video.viewCount,
date: video.publishedAt

View file

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

View file

@ -44,7 +44,7 @@ export const Links = {
return '';
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 => {
if ( ! data || (data.v || 1) > TOOLTIP_VERSION )
@ -58,7 +58,7 @@ export const Links = {
content += (content.length ? '<hr>' : '') +
sanitize(this.i18n.t(
'tooltip.link-destination',
'Destination: %{url}',
'Destination: {url}',
{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(', ');
content = this.i18n.t(
'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())}
) + (content.length ? `<hr>${content}` : '');
}
@ -96,7 +96,7 @@ export const Links = {
return content;
}).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-tier={tier}
/>),
this.i18n.t('tooltip.bits', '%{count|number} Bits', amount),
this.i18n.t('tooltip.bits', '{count,number} Bits', amount),
];
if ( length > 1 ) {
@ -755,7 +755,7 @@ export const CheerEmotes = {
if ( length > 12 ) {
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');
else
source = this.i18n.t('tooltip.channel', 'Channel: %{source}', {source});
source = this.i18n.t('tooltip.channel', 'Channel: {source}', {source});
}
} else if ( provider === 'ffz' ) {
@ -1025,7 +1025,7 @@ export const AddonEmotes = {
if ( emote.owner )
owner = this.i18n.t(
'emote.owner', 'By: %{owner}',
'emote.owner', 'By: {owner}',
{owner: emote.owner.display_name});
if ( emote.urls[4] )
@ -1052,7 +1052,7 @@ export const AddonEmotes = {
plain_name = true;
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
return;
@ -1069,7 +1069,7 @@ export const AddonEmotes = {
onLoad={tip.update}
/>) : 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">
{source}

View file

@ -9,7 +9,7 @@
<h4>{{ title }}</h4>
<div class="description">{{ description }}</div>
<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>
</template>
<template v-else>
@ -281,7 +281,7 @@ export default {
const def = this.data.actions[this.display.action];
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 ) {
const data = this.getData(),
@ -292,7 +292,7 @@ export default {
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++;
}
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
});
}

View file

@ -64,8 +64,7 @@ export default {
data() {
return {
default_reason: {
text: '',
i18n: null
text: ''
}
}
},
@ -101,6 +100,8 @@ export default {
if ( ! reason )
return;
delete reason.i18n;
const vals = Array.from(this.val);
vals.push({v: reason});
this.set(deep_copy(vals));
@ -120,12 +121,9 @@ export default {
},
save(val, new_val) {
if ( val.v && new_val ) {
if ( new_val.i18n && new_val.text !== val.v.text )
new_val.i18n = null;
}
delete new_val.i18n;
val.v = new_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-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>
<select
ref="sort_select"
@ -48,7 +48,7 @@
:key="idx"
:selected="i.value === exp.value"
>
{{ t('setting.experiments.entry', '%{value} (weight: %{weight})', i) }}
{{ t('setting.experiments.entry', '{value} (weight: {weight})', i) }}
</option>
</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">
<div class="ffz--home tw-border-t tw-pd-y-1">
<h2>Feedback</h2>
<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>
<markdown :source="t('home.feedback', md)" />
</div>
</template>
<script>
import FEEDBACK_MD from '../feedback.md';
export default {
props: ['item', 'context'],
data() {
return {
md: FEEDBACK_MD
}
}
}
</script>

View file

@ -9,45 +9,7 @@
</div>
<section class="tw-pd-t-1 tw-border-t tw-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="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>
<markdown :source="t('home.about', md)" />
</section>
@ -90,7 +52,7 @@
</div>
<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>
</div>
</div>
@ -99,11 +61,19 @@
<script>
import HOME_MD from '../home.md';
import {createElement as e} from 'utilities/dom';
export default {
props: ['item', 'context'],
data() {
return {
md: HOME_MD
}
},
mounted() {
let el;
document.head.appendChild(el = e('script', {

View file

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

View file

@ -106,7 +106,7 @@ export default {
},
title() {
if ( this.display.i18n )
if ( typeof this.display.i18n === 'string' )
return this.t(this.display.i18n, 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'
});
this.settings.addUI('faq', {
path: 'Home > FAQ',
component: 'faq-page'
});
this.settings.addUI('feedback', {
path: 'Home > Feedback',
component: 'feedback-page'

View file

@ -103,9 +103,9 @@ export default {
};
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 )
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'
)}<div class="pd-t-05">${this.i18n.t(
'metadata.uptime.since',
'(since %{since})',
{since: data.created.toLocaleString()}
'(since {since,datetime})',
{since: data.created}
)}</div>`;
}
}
@ -205,7 +205,7 @@ export default class Metadata extends Module {
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.',
'Your local clock seems to be off by roughly {count,number} seconds, which could make this inaccurate.',
Math.round(data.drift / 10) / 100
)}<hr>` :
'';
@ -216,7 +216,7 @@ export default class Metadata extends Module {
const stats = data.stats,
video_info = this.i18n.t(
'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
);
@ -226,7 +226,7 @@ export default class Metadata extends Module {
'Video Information'
)}<div class="pd-t-05">${this.i18n.t(
'metadata.player-stats.broadcast-ago',
'Broadcast %{count}s Ago',
'Broadcast {count,number}s Ago',
data.delay
)}</div><div class="pd-t-05">${video_info}</div>`;

View file

@ -38,20 +38,20 @@
:data-title="t('viewer-card.views', '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
:data-title="t('viewer-card.followers', 'Followers')"
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
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"
>
{{ t('viewer-card.age', '%{age|humanTime}', {age: userAge}) }}
{{ t('viewer-card.age', '{age,humantime}', {age: userAge}) }}
</span>
</div>
</div>

View file

@ -551,28 +551,27 @@ export default class EmoteMenu extends Module {
ends = data.ends && data.ends - new Date;
if ( renews > 0 ) {
const time = t.i18n.toHumanTime(renews / 1000);
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 ) {
const time = t.i18n.toHumanTime(ends / 1000);
const seconds = ends / 1000;
if ( data.prime )
calendar = {
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 )
calendar = {
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
calendar = {
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)),
emote_lock = locked && data.locks && data.locks[emote.set_id],
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-up', 'Upgrade your sub to %{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)
) : '';
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">
{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')}
<div class="ffz--sub-buttons tw-mg-t-05">
{Object.values(data.locks).map(lock => (<a
@ -1269,7 +1268,7 @@ export default class EmoteMenu extends Module {
sort_key = 75;
}
} 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;
@ -1516,7 +1515,7 @@ export default class EmoteMenu extends Module {
title = provider === 'main' ?
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;
if ( sort_key == null )
@ -1616,7 +1615,7 @@ export default class EmoteMenu extends Module {
{this.state.filtered ?
t.i18n.t('emote-menu.empty-search', 'There are no matching emotes.') :
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.")}
</div>)
}

View file

@ -560,7 +560,7 @@ export default class ChatHook extends Module {
this.ChatContainer.on('mount', this.containerMounted, 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) => {
const t = this,
@ -1544,15 +1544,23 @@ export default class ChatHook extends Module {
cs = data.user && data.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);
if ( cs.length !== ocs.length )
if ( ! this.hasRoomBadges(cont) || cs.length !== ocs.length )
this.updateRoomBadges(cont, cs);
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
const room = cont._ffz_room;
if ( ! room )

View file

@ -308,10 +308,10 @@ export default class ChatLine extends Module {
return e('div', {
className: 'chat-line__status'
}, t.i18n.t('chat.deleted-messages', [
'%{count} message was deleted by a moderator.',
'%{count} messages were deleted by a moderator.'
], {
}, t.i18n.t('chat.deleted-messages', `{count,plural,
one {One message was deleted by a moderator.}
other {# messages were deleted by a moderator.}
}`, {
count: deleted_count
}));
}
@ -334,7 +334,7 @@ export default class ChatLine extends Module {
const action = msg.modActionType;
if ( action === 'timeout' )
mod_action = t.i18n.t('chat.mod-action.timeout',
'%{duration} Timeout'
'{duration} Timeout'
, {
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');
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,
action: mod_action
});
@ -463,7 +463,7 @@ export default class ChatLine extends Module {
if ( mystery )
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') ?
t.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') :
e('span', {
@ -481,7 +481,7 @@ export default class ChatLine extends Module {
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!"));
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
}));
@ -551,7 +551,7 @@ export default class ChatLine extends Module {
const plan = msg.sub_plan || {},
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') ?
t.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') :
e('span', {
@ -562,7 +562,7 @@ export default class ChatLine extends Module {
className: 'tw-c-text-base tw-strong'
}, user.userDisplayName)),
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', {
role: 'button',
className: 'chatter-name',
@ -576,7 +576,7 @@ export default class ChatLine extends Module {
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!"));
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
}));
@ -609,7 +609,7 @@ export default class ChatLine extends Module {
const plan = msg.sub_plan || {},
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', {
role: 'button',
className: 'chatter-name',
@ -619,13 +619,13 @@ export default class ChatLine extends Module {
}, user.userDisplayName)),
plan: plan.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 ) {
sub_msg.push(t.i18n.t(
'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,
streak: msg.sub_streak
@ -635,7 +635,7 @@ export default class ChatLine extends Module {
} else if ( months > 1 ) {
sub_msg.push(t.i18n.t(
'chat.sub.months',
"They've subscribed for %{count} months!",
"They've subscribed for {count,number} months!",
{
count: months
}
@ -668,7 +668,7 @@ export default class ChatLine extends Module {
let system_msg;
if ( msg.ritual === 'new_chatter' )
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', {
role: 'button',
className: 'chatter-name',

View file

@ -103,7 +103,7 @@ export default class RichContent extends Module {
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="chat-card__title tw-ellipsis">
<span

View file

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

View file

@ -340,7 +340,7 @@ export default class Following extends SiteModule {
card_title = card.querySelector('.live-channel-card__title'),
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,
channel: data.displayName
}) : inst.props.title;

View file

@ -255,11 +255,11 @@ export default class Directory extends SiteModule {
<p class="tw-c-text-alt tw-ellipsis">{
game ?
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,
game: game_link
}) :
t.i18n.tList('directory.user-playing', '%{user} playing %{game}', {
t.i18n.tList('directory.user-playing', '{user} playing {game}', {
user: user_link,
game: game_link
})
@ -269,8 +269,8 @@ export default class Directory extends SiteModule {
<div data-test-selector="preview-card-titles__subtitle">
<p class="tw-c-text-alt tw-ellipsis">{
nodes.length > 1 ?
t.i18n.t('directory.hosted.by-many', 'Hosted by %{count} channel%{count|en_plural}', nodes.length) :
t.i18n.tList('directory.hosted.by-one', 'Hosted by %{user}', {
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}', {
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>
@ -421,8 +421,8 @@ export default class Directory extends SiteModule {
if ( inst.ffz_last_created_at !== created_at ) {
inst.ffz_uptime_tt.textContent = this.i18n.t(
'metadata.uptime.since',
'(since %{since})',
{since: up_since.toLocaleString()}
'(since {since,datetime})',
{since: up_since}
);
inst.ffz_last_created_at = created_at;

View file

@ -86,23 +86,23 @@ export default class HostButton extends Module {
return;
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))
? this.i18n.t('metadata.host.button.unhost', 'Unhost')
: this.i18n.t('metadata.host.button.host', 'Host');
? this.i18n.t('metadata.host-button.unhost', 'Unhost')
: this.i18n.t('metadata.host-button.host', 'Host');
},
tooltip: () => {
if (this._host_error) {
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);
} else {
return this.i18n.t('metadata.host.button.tooltip',
'Currently hosting: %{channel}',
return this.i18n.t('metadata.host-button.tooltip',
'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() {
this.on('i18n:update', () => this.metadata.updateMetadata('host'));
this.metadata.updateMetadata('host');
this.chat.ChatService.ready((cls, instances) => {

View file

@ -36,6 +36,12 @@ export default class Player extends Module {
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', {
default: false,
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', {
default: true,
ui: {
@ -375,6 +391,19 @@ export default class Player extends Module {
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('unmount', this.onUnmount, this);

View file

@ -4,7 +4,7 @@
// Video Chat Hooks
// ============================================================================
import {get} from 'utilities/object';
import {get, has} from 'utilities/object';
import {print_duration} from 'utilities/time';
//import {ClickOutside} from 'utilities/dom';
import {formatBitsConfig} from '../chat';
@ -36,6 +36,12 @@ export default class VideoChatHook extends Module {
['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(
'video-chat-line',
n => n.onReplyClickHandler && n.shouldFocusMessage,
@ -135,10 +141,63 @@ export default class VideoChatHook extends Module {
const createElement = React.createElement,
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 => {
const old_render = cls.prototype.render;
cls.prototype.ffzRenderMessage = function(msg) {
cls.prototype.ffzRenderMessage = function(msg, reply) {
const is_action = msg.is_action,
user = msg.user,
color = t.site_chat.colors.process(user.color),
@ -175,6 +234,15 @@ export default class VideoChatHook extends Module {
{rich_content && createElement(FFZRichContent, rich_content)}
</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>);
}
@ -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>
</button>
<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
}) }
<div class="tw-tooltip tw-tooltip--align-center tw-tooltip--up" role="tooltip">
{ msg.timestamp.toLocaleString() }
{ t.i18n.formatDateTime(msg.timestamp, 'full') }
</div>
</span>
</div>)
@ -206,7 +274,7 @@ export default class VideoChatHook extends Module {
const context = this.props.messageContext,
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;
@ -245,7 +313,7 @@ export default class VideoChatHook extends Module {
</div>)}
<ul>{
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) }
</li>))
}</ul>

View file

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

View file

@ -584,6 +584,12 @@ export class FineWrapper extends EventEmitter {
return original.apply(this, args);
} :
key === 'shouldComponentUpdate' ?
function(...args) {
t.emit(event, this, ...args);
return true;
}
:
function(...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$/,
loader: 'raw-loader'