mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-07-31 23:18:31 +00:00
4.20.19
* Changed: Update the styling for rich chat embeds to match modern vanilla Twitch. * Fixed: Do not display FFZ's Alternative Viewer Count if the stream is not live. * Fixed: Automatically opening the chat when accessing a channel. (Now using route state instead of page elements.) * Fixed: Dual channel points redemption messages when FFZ is rendering them. * Fixed: Stopping the host video player when accessing a user's home page. * Removed: The old, unfinished logviewer module. Logviewer is a dead project. * API Changed: Add support for rich formatting in rich chat embeds. * API Changed: `fine-router` can now check state when determining the current route.
This commit is contained in:
parent
fa3d73e05a
commit
a4fa1d1491
21 changed files with 451 additions and 629 deletions
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "frankerfacez",
|
"name": "frankerfacez",
|
||||||
"author": "Dan Salvato LLC",
|
"author": "Dan Salvato LLC",
|
||||||
"version": "4.20.18",
|
"version": "4.20.19",
|
||||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -647,6 +647,10 @@ export class TranslationManager extends Module {
|
||||||
return this._.formatNumber(...args);
|
return this._.formatNumber(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatDuration(...args) {
|
||||||
|
return this._.formatDuration(...args);
|
||||||
|
}
|
||||||
|
|
||||||
formatDate(...args) {
|
formatDate(...args) {
|
||||||
return this._.formatDate(...args)
|
return this._.formatDate(...args)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,77 +1,8 @@
|
||||||
<template>
|
|
||||||
<a :href="url" class="chat-card__link" target="_blank" rel="noreferrer noopener">
|
|
||||||
<div class="ffz--chat-card tw-elevation-1 tw-mg-t">
|
|
||||||
<div class="tw-c-background-base tw-flex tw-flex-nowrap tw-pd-05">
|
|
||||||
<div class="chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center">
|
|
||||||
<img
|
|
||||||
v-if="error"
|
|
||||||
class="chat-card__error-img"
|
|
||||||
src=""
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="tw-card-img tw-flex-shrink-0 tw-overflow-hidden"
|
|
||||||
>
|
|
||||||
<aspect :ratio="16/9">
|
|
||||||
<img
|
|
||||||
v-if="loaded && image"
|
|
||||||
:src="image"
|
|
||||||
:alt="title"
|
|
||||||
class="tw-image"
|
|
||||||
>
|
|
||||||
</aspect>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:class="{'ffz--two-line': desc_2}"
|
|
||||||
class="ffz--card-text tw-overflow-hidden tw-align-items-center tw-flex"
|
|
||||||
>
|
|
||||||
<div class="tw-full-width tw-pd-l-1">
|
|
||||||
<div class="chat-card__title tw-ellipsis">
|
|
||||||
<span
|
|
||||||
v-if="! loaded"
|
|
||||||
class="tw-font-size-5"
|
|
||||||
data-test-selector="chat-card-title"
|
|
||||||
>
|
|
||||||
{{ t('card.loading', 'Loading...') }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
:title="title"
|
|
||||||
class="tw-font-size-5"
|
|
||||||
data-test-selector="chat-card-title"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="loaded" class="tw-ellipsis">
|
|
||||||
<span
|
|
||||||
:title="desc_1"
|
|
||||||
class="tw-c-text-alt-2 tw-font-size-6"
|
|
||||||
data-test-selector="chat-card-description"
|
|
||||||
>
|
|
||||||
{{ desc_1 }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="loaded && desc_2" class="tw-ellipsis">
|
|
||||||
<span
|
|
||||||
:title="desc_2"
|
|
||||||
class="tw-c-text-alt-2 tw-font-size-6"
|
|
||||||
data-test-selector="chat-card-description"
|
|
||||||
>
|
|
||||||
{{ desc_2 }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import {timeout} from 'utilities/object';
|
import {has, timeout} from 'utilities/object';
|
||||||
|
|
||||||
|
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ['data', 'url'],
|
props: ['data', 'url'],
|
||||||
|
@ -80,10 +11,17 @@ export default {
|
||||||
return {
|
return {
|
||||||
loaded: false,
|
loaded: false,
|
||||||
error: false,
|
error: false,
|
||||||
title: null,
|
html: null,
|
||||||
|
title: this.t('card.loading', 'Loading...'),
|
||||||
|
title_tokens: null,
|
||||||
desc_1: null,
|
desc_1: null,
|
||||||
|
desc_1_tokens: null,
|
||||||
desc_2: null,
|
desc_2: null,
|
||||||
image: null
|
desc_2_tokens: null,
|
||||||
|
image: null,
|
||||||
|
image_title: null,
|
||||||
|
image_square: false,
|
||||||
|
accent: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -92,7 +30,7 @@ export default {
|
||||||
try {
|
try {
|
||||||
data = this.data.getData();
|
data = this.data.getData();
|
||||||
if ( data instanceof Promise ) {
|
if ( data instanceof Promise ) {
|
||||||
const to_wait = this.data.timeout || 1000;
|
const to_wait = has(this.data, 'timeout') ? this.data.timeout : 1000;
|
||||||
if ( to_wait )
|
if ( to_wait )
|
||||||
data = await timeout(data, to_wait);
|
data = await timeout(data, to_wait);
|
||||||
else
|
else
|
||||||
|
@ -105,7 +43,6 @@ export default {
|
||||||
title: this.t('card.error', 'An error occured.'),
|
title: this.t('card.error', 'An error occured.'),
|
||||||
desc_1: this.t('card.empty', 'No data was returned.')
|
desc_1: this.t('card.empty', 'No data was returned.')
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
data = {
|
data = {
|
||||||
error: true,
|
error: true,
|
||||||
|
@ -116,11 +53,157 @@ export default {
|
||||||
|
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
this.error = data.error;
|
this.error = data.error;
|
||||||
this.image = data.image;
|
this.html = data.html;
|
||||||
this.title = data.title;
|
this.title = data.title;
|
||||||
|
this.title_tokens = data.title_tokens;
|
||||||
this.desc_1 = data.desc_1;
|
this.desc_1 = data.desc_1;
|
||||||
|
this.desc_1_tokens = data.desc_1_tokens;
|
||||||
this.desc_2 = data.desc_2;
|
this.desc_2 = data.desc_2;
|
||||||
|
this.desc_2_tokens = data.desc_2_tokens;
|
||||||
|
this.image = data.image;
|
||||||
|
this.image_square = data.image_square;
|
||||||
|
this.image_title = data.image_title;
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
renderCard(h) {
|
||||||
|
if ( this.data.renderBody )
|
||||||
|
return [this.data.renderBody(h)];
|
||||||
|
|
||||||
|
if ( this.html )
|
||||||
|
return [h('div', {
|
||||||
|
domProps: {
|
||||||
|
innerHTML: this.html
|
||||||
|
}
|
||||||
|
})];
|
||||||
|
|
||||||
|
return [
|
||||||
|
this.renderImage(h),
|
||||||
|
this.renderDescription(h)
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderTokens(tokens, h) {
|
||||||
|
let out = [];
|
||||||
|
if ( ! Array.isArray(tokens) )
|
||||||
|
tokens = [tokens];
|
||||||
|
|
||||||
|
for(const token of tokens) {
|
||||||
|
if ( Array.isArray(token) )
|
||||||
|
out = out.concat(this.renderTokens(token, h));
|
||||||
|
|
||||||
|
else if ( typeof token !== 'object' )
|
||||||
|
out.push(token);
|
||||||
|
|
||||||
|
else {
|
||||||
|
const el = h(token.tag || 'span', {
|
||||||
|
class: token.class,
|
||||||
|
}, this.renderTokens(token.content, h));
|
||||||
|
|
||||||
|
out.push(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderDescription(h) {
|
||||||
|
let title = this.title,
|
||||||
|
title_tokens = this.title_tokens,
|
||||||
|
desc_1 = this.desc_1,
|
||||||
|
desc_1_tokens = this.desc_1_tokens,
|
||||||
|
desc_2 = this.desc_2,
|
||||||
|
desc_2_tokens = this.desc_2_tokens;
|
||||||
|
|
||||||
|
if ( ! this.loaded ) {
|
||||||
|
desc_1 = this.t('card.loading', 'Loading...');
|
||||||
|
desc_1_tokens = desc_2 = desc_2_tokens = title = title_tokens = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('div', {
|
||||||
|
class: [
|
||||||
|
'ffz--card-text tw-overflow-hidden tw-align-items-center tw-flex',
|
||||||
|
desc_2 && 'ffz--two-line'
|
||||||
|
]
|
||||||
|
}, [h('div', {class: 'tw-full-width tw-pd-l-1'}, [
|
||||||
|
h('div', {class: 'chat-card__title tw-ellipsis'},
|
||||||
|
[h('span', {class: 'tw-strong', attrs: {title}}, title_tokens ? this.renderTokens(title_tokens, h) : title)]),
|
||||||
|
h('div', {class: 'tw-ellipsis'},
|
||||||
|
[h('span', {class: 'tw-c-text-alt-2', attrs: {title: desc_1}}, desc_1_tokens ? this.renderTokens(desc_1_tokens, h) : desc_1)]),
|
||||||
|
desc_2 && h('div', {class: 'tw-ellipsis'},
|
||||||
|
[h('span', {class: 'tw-c-text-alt-2', attrs: {title: desc_2}}, desc_2_tokens ? this.renderTokens(desc_2_tokens, h) : desc_2)])
|
||||||
|
])]);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderImage(h) {
|
||||||
|
let content;
|
||||||
|
if ( this.error )
|
||||||
|
content = h('img', {
|
||||||
|
class: 'chat-card__error-img',
|
||||||
|
attrs: {
|
||||||
|
src: ERROR_IMAGE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
content = h('div', {
|
||||||
|
class: 'tw-card-img tw-flex-shrink-0 tw-overflow-hidden'
|
||||||
|
}, [h('aspect', {
|
||||||
|
props: {
|
||||||
|
ratio: 16/9
|
||||||
|
}
|
||||||
|
}, [this.loaded && this.image && h('img', {
|
||||||
|
class: 'tw-image',
|
||||||
|
attrs: {
|
||||||
|
src: this.image,
|
||||||
|
alt: this.image_title ?? this.title
|
||||||
|
}
|
||||||
|
})])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('div', {
|
||||||
|
class: [
|
||||||
|
'chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center',
|
||||||
|
this.image_square && 'square'
|
||||||
|
]
|
||||||
|
}, [content])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render(h) {
|
||||||
|
let content = h('div', {
|
||||||
|
class: 'tw-flex tw-flex-nowrap tw-pd-05'
|
||||||
|
}, this.renderCard(h));
|
||||||
|
|
||||||
|
if ( this.url ) {
|
||||||
|
const tooltip = this.data.card_tooltip;
|
||||||
|
content = h('a', {
|
||||||
|
class: [
|
||||||
|
tooltip && 'ffz-tooltip',
|
||||||
|
this.accent && 'ffz-accent-card',
|
||||||
|
!this.error && 'tw-interactable--hover-enabled',
|
||||||
|
'tw-block tw-border-radius-medium tw-full-width tw-interactable tw-interactable--alpha tw-interactive'
|
||||||
|
],
|
||||||
|
attrs: {
|
||||||
|
'data-tooltip-type': 'link',
|
||||||
|
'data-url': this.url,
|
||||||
|
'data-is-mail': false,
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noreferrer noopener',
|
||||||
|
href: this.url
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('div', {
|
||||||
|
class: 'tw-border-radius-medium tw-elevation-1 ffz--chat-card',
|
||||||
|
style: {
|
||||||
|
'--ffz-color-accent': this.accent
|
||||||
|
}
|
||||||
|
}, [h('div', {
|
||||||
|
class: 'tw-border-radius-medium tw-c-background-base tw-flex tw-full-width'
|
||||||
|
}, [content])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
|
@ -7,6 +7,12 @@
|
||||||
const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/;
|
const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/;
|
||||||
const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/(\w+)/;
|
const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/(\w+)/;
|
||||||
const VIDEO_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(?:\w+\/v|videos)\/(\w+)/;
|
const VIDEO_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(?:\w+\/v|videos)\/(\w+)/;
|
||||||
|
const USER_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([^/]+)$/;
|
||||||
|
|
||||||
|
const BAD_USERS = [
|
||||||
|
'directory', '_deck', 'p', 'downloads', 'jobs', 'turbo', 'settings', 'friends',
|
||||||
|
'subscriptions', 'inventory', 'wallet'
|
||||||
|
];
|
||||||
|
|
||||||
import GET_CLIP from './clip_info.gql';
|
import GET_CLIP from './clip_info.gql';
|
||||||
import GET_VIDEO from './video_info.gql';
|
import GET_VIDEO from './video_info.gql';
|
||||||
|
@ -55,7 +61,9 @@ export const Links = {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: token.url,
|
url: token.url,
|
||||||
|
accent: data.accent,
|
||||||
image: this.context.get('tooltip.link-images') ? (data.image_safe || this.context.get('tooltip.link-nsfw-images') ) ? data.preview || data.image : null : null,
|
image: this.context.get('tooltip.link-images') ? (data.image_safe || this.context.get('tooltip.link-nsfw-images') ) ? data.preview || data.image : null : null,
|
||||||
|
image_square: data.image_square,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
desc_1: data.desc_1,
|
desc_1: data.desc_1,
|
||||||
desc_2: data.desc_2
|
desc_2: data.desc_2
|
||||||
|
@ -66,13 +74,100 @@ export const Links = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Users
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const Users = {
|
||||||
|
type: 'user',
|
||||||
|
hide_token: false,
|
||||||
|
|
||||||
|
test(token) {
|
||||||
|
if ( token.type !== 'link' || (! this.context.get('chat.rich.all-links') && ! token.force_rich) )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return USER_URL.test(token.url);
|
||||||
|
},
|
||||||
|
|
||||||
|
process(token) {
|
||||||
|
const match = USER_URL.exec(token.url),
|
||||||
|
twitch_data = this.resolve('site.twitch_data');
|
||||||
|
|
||||||
|
if ( ! twitch_data || ! match || BAD_USERS.includes(match[1]) )
|
||||||
|
return;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: token.url,
|
||||||
|
|
||||||
|
getData: async () => {
|
||||||
|
const user = await twitch_data.getUser(null, match[1]);
|
||||||
|
if ( ! user || ! user.id )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const game = user.broadcastSettings?.game?.displayName;
|
||||||
|
|
||||||
|
let desc_1 = null, desc_2 = null, desc_1_tokens = null, desc_2_tokens = null;
|
||||||
|
if ( user.stream?.id && game ) {
|
||||||
|
desc_1_tokens = this.i18n.tList('cards.user.streaming', 'streaming {game}', {
|
||||||
|
game: {class: 'tw-semibold', content: [game]}
|
||||||
|
});
|
||||||
|
desc_1 = this.i18n.t('cards.user.streaming', 'streaming {game}', {
|
||||||
|
game
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bits_tokens = this.i18n.tList('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', {
|
||||||
|
views: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.profileViewCount || 0)]},
|
||||||
|
followers: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.followers?.totalCount || 0)]}
|
||||||
|
}),
|
||||||
|
bits = this.i18n.t('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', {
|
||||||
|
views: user.profileViewCount || 0,
|
||||||
|
followers: user.followers?.totalCount || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if ( desc_1 ) {
|
||||||
|
desc_2 = bits;
|
||||||
|
desc_2_tokens = bits_tokens;
|
||||||
|
} else {
|
||||||
|
desc_1 = bits;
|
||||||
|
desc_1_tokens = bits_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
const has_i18n = user.displayName.trim().toLowerCase() !== user.login;
|
||||||
|
let title = user.displayName, title_tokens = null;
|
||||||
|
if ( has_i18n ) {
|
||||||
|
title = `${user.displayName} (${user.login})`;
|
||||||
|
title_tokens = [
|
||||||
|
user.displayName,
|
||||||
|
{class: 'chat-author__intl-login', content: ` (${user.login})`}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: token.url,
|
||||||
|
accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null,
|
||||||
|
image: user.profileImageURL,
|
||||||
|
image_square: true,
|
||||||
|
title,
|
||||||
|
title_tokens,
|
||||||
|
desc_1,
|
||||||
|
desc_1_tokens,
|
||||||
|
desc_2,
|
||||||
|
desc_2_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Clips
|
// Clips
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const Clips = {
|
export const Clips = {
|
||||||
type: 'clip',
|
type: 'clip',
|
||||||
hide_token: true,
|
hide_token: false,
|
||||||
|
|
||||||
test(token) {
|
test(token) {
|
||||||
if ( token.type !== 'link' )
|
if ( token.type !== 'link' )
|
||||||
|
@ -110,29 +205,47 @@ export const Clips = {
|
||||||
game_name = game && game.name,
|
game_name = game && game.name,
|
||||||
game_display = game && game.displayName;
|
game_display = game && game.displayName;
|
||||||
|
|
||||||
let desc_1;
|
let desc_1, desc_1_tokens;
|
||||||
if ( game_name === 'creative' )
|
if ( game_name === 'creative' ) {
|
||||||
|
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', {
|
||||||
|
user: {class: 'tw-semibold', content: user}
|
||||||
|
});
|
||||||
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_tokens = this.i18n.tList('clip.desc.1.playing', '{user} playing {game}', {
|
||||||
|
user: {class: 'tw-semibold', content: user},
|
||||||
|
game: {class: 'tw-semibold', game_display}
|
||||||
|
});
|
||||||
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_tokens = this.i18n.tList('clip.desc.1', 'Clip of {user}', {
|
||||||
|
user: {class: 'tw-semibold', content: user}
|
||||||
|
});
|
||||||
desc_1 = this.i18n.t('clip.desc.1', 'Clip of {user}', {user});
|
desc_1 = this.i18n.t('clip.desc.1', 'Clip of {user}', {user});
|
||||||
|
}
|
||||||
|
|
||||||
|
const curator = clip.curator ? clip.curator.displayName : this.i18n.t('clip.unknown', 'Unknown');
|
||||||
|
|
||||||
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_1_tokens,
|
||||||
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,
|
||||||
views: clip.viewCount
|
views: clip.viewCount
|
||||||
|
}),
|
||||||
|
desc_2_tokens: this.i18n.tList('clip.desc.2', 'Clipped by {curator} — {views,number} View{views,en_plural}', {
|
||||||
|
curator: clip.curator ? {class: 'tw-semibold', content: curator} : curator,
|
||||||
|
views: {class: 'tw-semibold', content: this.i18n.formatNumber(clip.viewCount)}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -143,7 +256,7 @@ export const Clips = {
|
||||||
|
|
||||||
export const Videos = {
|
export const Videos = {
|
||||||
type: 'video',
|
type: 'video',
|
||||||
hide_token: true,
|
hide_token: false,
|
||||||
|
|
||||||
test(token) {
|
test(token) {
|
||||||
return token.type === 'link' && VIDEO_URL.test(token.url)
|
return token.type === 'link' && VIDEO_URL.test(token.url)
|
||||||
|
@ -174,26 +287,38 @@ export const Videos = {
|
||||||
game_name = game && game.name,
|
game_name = game && game.name,
|
||||||
game_display = game && game.displayName;
|
game_display = game && game.displayName;
|
||||||
|
|
||||||
let desc_1;
|
let desc_1, desc_1_tokens;
|
||||||
if ( game_name === 'creative' )
|
if ( game_name === 'creative' ) {
|
||||||
|
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', {
|
||||||
|
user: {class: 'tw-semibold', content: user}
|
||||||
|
});
|
||||||
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_tokens = this.i18n.tList('clip.desc.1.playing', '{user} playing {game}', {
|
||||||
|
user: {class: 'tw-semibold', content: user},
|
||||||
|
game: {class: 'tw-semibold', content: game_display}
|
||||||
|
});
|
||||||
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_tokens = this.i18n.tList('video.desc.1', 'Video of {user}', {
|
||||||
|
user: {class: 'tw-semibold', content: user}
|
||||||
|
});
|
||||||
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_1_tokens,
|
||||||
desc_2: this.i18n.t('video.desc.2', '{length,duration} — {views,number} Views - {date,datetime}', {
|
desc_2: this.i18n.t('video.desc.2', '{length,duration} — {views,number} Views - {date,datetime}', {
|
||||||
length: video.lengthSeconds,
|
length: video.lengthSeconds,
|
||||||
views: video.viewCount,
|
views: video.viewCount,
|
||||||
|
|
|
@ -1,97 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Logviewer Integration
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import {once} from 'utilities/object';
|
|
||||||
import Module from 'utilities/module';
|
|
||||||
|
|
||||||
import LVSocketClient from './socket';
|
|
||||||
|
|
||||||
export default class Logviewer extends Module {
|
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
|
||||||
|
|
||||||
this.should_enable = true;
|
|
||||||
|
|
||||||
this.inject('site');
|
|
||||||
this.inject('socket');
|
|
||||||
this.inject('viewer_cards');
|
|
||||||
|
|
||||||
this.inject('lv_socket', LVSocketClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
get token() {
|
|
||||||
const token = this._token;
|
|
||||||
if ( token && token.token && token.expires > ((Date.now() / 1000) + 300) )
|
|
||||||
return token.token;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
onEnable() {
|
|
||||||
this.viewer_cards.addTab('logs', {
|
|
||||||
visible: true,
|
|
||||||
label: 'Chat History',
|
|
||||||
|
|
||||||
component: () => import(/* webpackChunkName: 'viewer-cards' */ './tab-logs.vue')
|
|
||||||
});
|
|
||||||
|
|
||||||
this.on('viewer_cards:open', this.onCardOpen);
|
|
||||||
this.on('viewer_cards:close', this.onCardClose);
|
|
||||||
this.on('viewer_cards:load', this.onCardLoad);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onCardOpen() {
|
|
||||||
// We're going to need a token soon, so make sure we have one.
|
|
||||||
this.getToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
onCardLoad(card) {
|
|
||||||
this.log.info('card:load', card);
|
|
||||||
|
|
||||||
if ( ! card.channel || ! card.user )
|
|
||||||
return;
|
|
||||||
|
|
||||||
card.lv_topic = `logs-${card.channel.login}-${card.user.login}`;
|
|
||||||
this.lv_socket.subscribe(card, card.lv_topic);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
onCardClose(card) {
|
|
||||||
this.log.info('card:close', card);
|
|
||||||
|
|
||||||
if ( card.lv_topic ) {
|
|
||||||
this.lv_socket.unsubscribe(card, card.lv_topic);
|
|
||||||
card.lv_topic = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async getToken() {
|
|
||||||
const user = this.site.getUser(),
|
|
||||||
token = this._token,
|
|
||||||
now = Date.now() / 1000;
|
|
||||||
|
|
||||||
if ( ! user || ! user.login )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( token && token.token && token.expires > (now + 300) )
|
|
||||||
return token.token;
|
|
||||||
|
|
||||||
const new_token = this._token = await this.socket.call('get_logviewer_token');
|
|
||||||
if ( new_token ) {
|
|
||||||
this.lv_socket.maybeSendToken();
|
|
||||||
return new_token.token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Logviewer.getToken = once(Logviewer.getToken);
|
|
|
@ -1,339 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Socket Client
|
|
||||||
// This connects to Logviewer's socket.io server for PubSub.
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
|
||||||
import {LV_SOCKET_SERVER} from 'utilities/constants';
|
|
||||||
|
|
||||||
|
|
||||||
export const State = {
|
|
||||||
DISCONNECTED: 0,
|
|
||||||
CONNECTING: 1,
|
|
||||||
CONNECTED: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default class LVSocketClient extends Module {
|
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
|
||||||
|
|
||||||
this._topics = new Map;
|
|
||||||
|
|
||||||
this._socket = null;
|
|
||||||
this._state = 0;
|
|
||||||
this._delay = 0;
|
|
||||||
|
|
||||||
this.ping_interval = 25000;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Properties
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
get connected() {
|
|
||||||
return this._state === State.CONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
get connecting() {
|
|
||||||
return this._state === State.CONNECTING;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
get disconnected() {
|
|
||||||
return this._state === State.DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Connection Logic
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
scheduleDisconnect() {
|
|
||||||
if ( this._disconnect_timer ) {
|
|
||||||
clearTimeout(this._disconnect_timer);
|
|
||||||
this._disconnect_timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( this.disconnected || this._topics.size )
|
|
||||||
return;
|
|
||||||
|
|
||||||
this._disconnect_timer = setTimeout(() => this.disconnect(), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
scheduleReconnect() {
|
|
||||||
if ( this._reconnect_timer )
|
|
||||||
return;
|
|
||||||
|
|
||||||
if ( this._delay < 60000 )
|
|
||||||
this._delay += (Math.floor(Math.random() * 10) + 5) * 1000;
|
|
||||||
else
|
|
||||||
this._delay = (Math.floor(Math.random() * 60) + 30) * 1000;
|
|
||||||
|
|
||||||
this._reconnect_timer = setTimeout(() => this.connect(), this._delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
reconnect() {
|
|
||||||
this.disconnect();
|
|
||||||
this.scheduleReconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.want_connection = true;
|
|
||||||
this.clearTimers();
|
|
||||||
|
|
||||||
if ( ! this.disconnected )
|
|
||||||
return;
|
|
||||||
|
|
||||||
this._state = State.CONNECTING;
|
|
||||||
this._delay = 0;
|
|
||||||
|
|
||||||
const host = `${LV_SOCKET_SERVER}?EIO=3&transport=websocket`;
|
|
||||||
this.log.info(`Using Server: ${host}`);
|
|
||||||
|
|
||||||
let ws;
|
|
||||||
|
|
||||||
try {
|
|
||||||
ws = this._socket = new WebSocket(host);
|
|
||||||
} catch(err) {
|
|
||||||
this._state = State.DISCONNECTED;
|
|
||||||
this.scheduleReconnect();
|
|
||||||
this.log.error('Unable to create WebSocket.', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
if ( this._socket !== ws ) {
|
|
||||||
this.log.warn('A socket connected that is not our primary socket.');
|
|
||||||
return ws.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._state = State.CONNECTED;
|
|
||||||
this._sent_token = false;
|
|
||||||
|
|
||||||
ws.send('2probe');
|
|
||||||
ws.send('5');
|
|
||||||
|
|
||||||
this.maybeSendToken();
|
|
||||||
|
|
||||||
for(const topic of this._topics.keys())
|
|
||||||
this.send('subscribe', topic);
|
|
||||||
|
|
||||||
this.log.info('Connected.');
|
|
||||||
this.emit(':connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ws.onclose = event => {
|
|
||||||
if ( ws !== this._socket )
|
|
||||||
return;
|
|
||||||
|
|
||||||
this._state = State.DISCONNECTED;
|
|
||||||
|
|
||||||
this.log.info(`Disconnected. (${event.code}:${event.reason})`);
|
|
||||||
this.emit(':closed', event.code, event.reason);
|
|
||||||
|
|
||||||
if ( ! this.want_connection )
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.clearTimers();
|
|
||||||
this.scheduleReconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ws.onmessage = event => {
|
|
||||||
if ( ws !== this._socket || ! event.data )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const raw = event.data,
|
|
||||||
type = raw.charAt(0);
|
|
||||||
|
|
||||||
if ( type === '0' ) {
|
|
||||||
// OPEN. Try reading ping interval.
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(raw.slice(1));
|
|
||||||
this.ping_interval = data.ping_interval || 25000;
|
|
||||||
} catch(err) { /* don't care */ }
|
|
||||||
|
|
||||||
} else if ( type === '1' ) {
|
|
||||||
// CLOSE. We should never get this, but whatever.
|
|
||||||
ws.close();
|
|
||||||
|
|
||||||
} else if ( type === '2' ) {
|
|
||||||
// PING. Respone with a PONG. Shouldn't get this.
|
|
||||||
ws.send(`3${raw.slice(1)}`);
|
|
||||||
|
|
||||||
} else if ( type === '3' ) {
|
|
||||||
// PONG. Wait for the next ping.
|
|
||||||
this.schedulePing();
|
|
||||||
|
|
||||||
} else if ( type === '4' ) {
|
|
||||||
const dt = raw.charAt(1);
|
|
||||||
if ( dt === '0' ) {
|
|
||||||
// This is sent at connection. Who knows what it is.
|
|
||||||
|
|
||||||
} else if ( dt === '2' ) {
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = JSON.parse(raw.slice(2));
|
|
||||||
} catch(err) {
|
|
||||||
this.log.warn('Error decoding packet.', err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit(':message', ...data);
|
|
||||||
|
|
||||||
} else
|
|
||||||
this.log.debug('Unexpected Data Type', raw);
|
|
||||||
|
|
||||||
} else if ( type === '6' ) {
|
|
||||||
// NOOP.
|
|
||||||
|
|
||||||
} else
|
|
||||||
this.log.debug('Unexpected Packet Type', raw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
clearTimers() {
|
|
||||||
if ( this._ping_timer ) {
|
|
||||||
clearTimeout(this._ping_timer);
|
|
||||||
this._ping_timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( this._reconnect_timer ) {
|
|
||||||
clearTimeout(this._reconnect_timer);
|
|
||||||
this._reconnect_timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( this._disconnect_timer ) {
|
|
||||||
clearTimeout(this._disconnect_timer);
|
|
||||||
this._disconnect_timer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.want_connection = false;
|
|
||||||
this.clearTimers();
|
|
||||||
|
|
||||||
if ( this.disconnected )
|
|
||||||
return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this._socket.close();
|
|
||||||
} catch(err) { /* if this caused an exception, we don't care -- it's still closed */ }
|
|
||||||
|
|
||||||
this._socket = null;
|
|
||||||
this._state = State.DISCONNECTED;
|
|
||||||
|
|
||||||
this.log.info(`Disconnected. (1000:)`);
|
|
||||||
this.emit(':closed', 1000, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Communication
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
maybeSendToken() {
|
|
||||||
if ( ! this.connected )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const token = this.parent.token;
|
|
||||||
if ( token ) {
|
|
||||||
this.send('token', token);
|
|
||||||
this._sent_token = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
send(...args) {
|
|
||||||
if ( ! this.connected )
|
|
||||||
return;
|
|
||||||
|
|
||||||
this._socket.send(`42${JSON.stringify(args)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
schedulePing() {
|
|
||||||
if ( this._ping_timer )
|
|
||||||
clearTimeout(this._ping_timer);
|
|
||||||
|
|
||||||
this._ping_timer = setTimeout(() => this.ping(), this.ping_interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ping() {
|
|
||||||
if ( ! this.connected )
|
|
||||||
return;
|
|
||||||
|
|
||||||
if ( this._ping_timer ) {
|
|
||||||
clearTimeout(this._ping_timer);
|
|
||||||
this._ping_timer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._socket.send('2');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Topics
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
subscribe(referrer, ...topics) {
|
|
||||||
const t = this._topics;
|
|
||||||
for(const topic of topics) {
|
|
||||||
if ( ! t.has(topic) ) {
|
|
||||||
if ( this.connected )
|
|
||||||
this.send('subscribe', topic);
|
|
||||||
|
|
||||||
else if ( this.disconnected )
|
|
||||||
this.connect();
|
|
||||||
|
|
||||||
t.set(topic, new Set);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tp = t.get(topic);
|
|
||||||
tp.add(referrer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scheduleDisconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
unsubscribe(referrer, ...topics) {
|
|
||||||
const t = this._topics;
|
|
||||||
for(const topic of topics) {
|
|
||||||
if ( ! t.has(topic) )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const tp = t.get(topic);
|
|
||||||
tp.delete(referrer);
|
|
||||||
|
|
||||||
if ( ! tp.size ) {
|
|
||||||
t.delete(topic);
|
|
||||||
if ( this.connected )
|
|
||||||
this.send('unsubscribe', topic);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scheduleDisconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
get topics() {
|
|
||||||
return Array.from(this._topics.keys());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
LVSocketClient.State = State;
|
|
|
@ -1,33 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<error-tab v-if="errored" />
|
|
||||||
<loading-tab v-else-if="loading" />
|
|
||||||
<template v-else>
|
|
||||||
{{ data }}
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
import LoadingTab from 'src/modules/viewer_cards/components/loading-tab.vue';
|
|
||||||
import ErrorTab from 'src/modules/viewer_cards/components/error-tab.vue';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
'loading-tab': LoadingTab,
|
|
||||||
'error-tab': ErrorTab
|
|
||||||
},
|
|
||||||
|
|
||||||
props: ['tab', 'channel', 'user', 'self', 'getFFZ'],
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loading: true,
|
|
||||||
errored: false,
|
|
||||||
|
|
||||||
data: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -13,7 +13,9 @@
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
const VIDEOS = [
|
const VIDEOS = [
|
||||||
'https://www.youtube.com/watch?v=BFSWlDpA6C4'
|
'https://www.twitch.tv/dansalvato',
|
||||||
|
'https://www.twitch.tv/sirstendec',
|
||||||
|
//'https://www.youtube.com/watch?v=BFSWlDpA6C4'
|
||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
@ -109,17 +109,20 @@ export default class Metadata extends Module {
|
||||||
refresh() { return this.settings.get('metadata.viewers') },
|
refresh() { return this.settings.get('metadata.viewers') },
|
||||||
|
|
||||||
setup(data) {
|
setup(data) {
|
||||||
return data.getViewerCount();
|
return {
|
||||||
|
live: data.channel?.live_since != null,
|
||||||
|
count: data.getViewerCount()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
order: 1,
|
order: 1,
|
||||||
icon: 'ffz-i-viewers',
|
icon: 'ffz-i-viewers',
|
||||||
|
|
||||||
label(data) {
|
label(data) {
|
||||||
if ( ! this.settings.get('metadata.viewers') )
|
if ( ! this.settings.get('metadata.viewers') || ! data.live )
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return this.i18n.formatNumber(data)
|
return this.i18n.formatNumber(data.count)
|
||||||
},
|
},
|
||||||
|
|
||||||
tooltip() {
|
tooltip() {
|
||||||
|
|
|
@ -50,6 +50,9 @@ export default class Twilight extends BaseSite {
|
||||||
this.router.route(Twilight.ROUTES);
|
this.router.route(Twilight.ROUTES);
|
||||||
this.router.routeName(Twilight.ROUTE_NAMES);
|
this.router.routeName(Twilight.ROUTE_NAMES);
|
||||||
|
|
||||||
|
this.router.route('user', '/:userName', null, state => state?.channelView !== 'Home');
|
||||||
|
this.router.route('user-home', '/:userName', null, state => state?.channelView === 'Home');
|
||||||
|
|
||||||
this.router.route(Twilight.DASH_ROUTES, 'dashboard.twitch.tv');
|
this.router.route(Twilight.DASH_ROUTES, 'dashboard.twitch.tv');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -213,6 +216,7 @@ Twilight.CHAT_ROUTES = [
|
||||||
'dash-chat',
|
'dash-chat',
|
||||||
'video',
|
'video',
|
||||||
'user-video',
|
'user-video',
|
||||||
|
'user-home',
|
||||||
'user-clip',
|
'user-clip',
|
||||||
'user-videos',
|
'user-videos',
|
||||||
'user-clips',
|
'user-clips',
|
||||||
|
@ -312,7 +316,7 @@ Twilight.ROUTES = {
|
||||||
'product': '/products/:productName',
|
'product': '/products/:productName',
|
||||||
'prime': '/prime',
|
'prime': '/prime',
|
||||||
'turbo': '/turbo',
|
'turbo': '/turbo',
|
||||||
'user': '/:userName',
|
//'user': '/:userName',
|
||||||
'squad': '/:userName/squad',
|
'squad': '/:userName/squad',
|
||||||
'command-center': '/:userName/commandcenter',
|
'command-center': '/:userName/commandcenter',
|
||||||
'embed-chat': '/embed/:userName/chat',
|
'embed-chat': '/embed/:userName/chat',
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {debounce} from 'utilities/object';
|
||||||
import { createElement, setChildren } from 'utilities/dom';
|
import { createElement, setChildren } from 'utilities/dom';
|
||||||
|
|
||||||
|
|
||||||
const USER_PAGES = ['user', 'video', 'user-video', 'user-clip', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following'];
|
const USER_PAGES = ['user', 'user-home', 'video', 'user-video', 'user-clip', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following'];
|
||||||
|
|
||||||
export default class Channel extends Module {
|
export default class Channel extends Module {
|
||||||
|
|
||||||
|
@ -103,12 +103,18 @@ export default class Channel extends Module {
|
||||||
|
|
||||||
this.subpump.on(':pubsub-message', this.onPubSub, this);
|
this.subpump.on(':pubsub-message', this.onPubSub, this);
|
||||||
|
|
||||||
this.router.on(':route', route => {
|
this.router.on(':route', this.checkNavigation, this);
|
||||||
if ( route?.name === 'user' )
|
this.checkNavigation();
|
||||||
setTimeout(this.maybeClickChat.bind(this), 1000);
|
}
|
||||||
}, this);
|
|
||||||
|
|
||||||
this.maybeClickChat();
|
checkNavigation() {
|
||||||
|
if ( ! this.settings.get('channel.auto-click-chat') || this.router.current_name !== 'user-home' )
|
||||||
|
return;
|
||||||
|
|
||||||
|
if ( this.router.old_location === this.router.location )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.router.history.replace(this.router.location, {channelView: 'Watch'});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLinks() {
|
updateLinks() {
|
||||||
|
@ -118,14 +124,6 @@ export default class Channel extends Module {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeClickChat() {
|
|
||||||
if ( this.settings.get('channel.auto-click-chat') && this.router.current_name === 'user' ) {
|
|
||||||
const el = document.querySelector('a[data-a-target="channel-home-tab-Chat"]');
|
|
||||||
if ( el )
|
|
||||||
el.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHost(channel_id, channel_login, target_id, target_login) {
|
setHost(channel_id, channel_login, target_id, target_login) {
|
||||||
const topic = `stream-chat-room-v1.${channel_id}`;
|
const topic = `stream-chat-room-v1.${channel_id}`;
|
||||||
|
|
||||||
|
|
|
@ -115,7 +115,8 @@ const CHAT_TYPES = make_enum(
|
||||||
'FirstCheerMessage',
|
'FirstCheerMessage',
|
||||||
'BitsBadgeTierMessage',
|
'BitsBadgeTierMessage',
|
||||||
'InlinePrivateCallout',
|
'InlinePrivateCallout',
|
||||||
'ChannelPointsReward'
|
'ChannelPointsReward',
|
||||||
|
'CommunityChallengeContribution'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
@ -1089,6 +1090,9 @@ export default class ChatHook extends Module {
|
||||||
if ( ! message || ! service || message.type !== 'reward-redeemed' || service.props.channelID != data?.channel_id )
|
if ( ! message || ! service || message.type !== 'reward-redeemed' || service.props.channelID != data?.channel_id )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if ( data.user_input )
|
||||||
|
return;
|
||||||
|
|
||||||
const reward = data.reward?.id && get(data.reward.id, service.props.rewardMap);
|
const reward = data.reward?.id && get(data.reward.id, service.props.rewardMap);
|
||||||
if ( ! reward )
|
if ( ! reward )
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -75,7 +75,7 @@ export default class RichContent extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCardImage() {
|
renderCardImage() {
|
||||||
return (<div class="chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center">
|
return (<div class={`chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center${this.state.image_square ? ' square' : ''}`}>
|
||||||
{this.state.error ?
|
{this.state.error ?
|
||||||
(<img
|
(<img
|
||||||
class="chat-card__error-img"
|
class="chat-card__error-img"
|
||||||
|
@ -85,58 +85,84 @@ export default class RichContent extends Module {
|
||||||
<div class="tw-aspect tw-aspect--align-top">
|
<div class="tw-aspect tw-aspect--align-top">
|
||||||
<div class="tw-aspect__spacer" style={{paddingTop: '56.25%'}} />
|
<div class="tw-aspect__spacer" style={{paddingTop: '56.25%'}} />
|
||||||
{this.state.loaded && this.state.image ?
|
{this.state.loaded && this.state.image ?
|
||||||
(<img class="tw-image" src={this.state.image} alt={this.state.title} />)
|
(<img class="tw-image" src={this.state.image} alt={this.state.image_title ?? this.state.title} />)
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderTokens(tokens) {
|
||||||
|
let out = [];
|
||||||
|
if ( ! Array.isArray(tokens) )
|
||||||
|
tokens = [tokens];
|
||||||
|
|
||||||
|
for(const token of tokens) {
|
||||||
|
if ( Array.isArray(token) )
|
||||||
|
out = out.concat(this.renderTokens(token));
|
||||||
|
|
||||||
|
else if ( typeof token !== 'object' )
|
||||||
|
out.push(token);
|
||||||
|
|
||||||
|
else {
|
||||||
|
const el = createElement(token.tag || 'span', {
|
||||||
|
className: token.class
|
||||||
|
}, this.renderTokens(token.content));
|
||||||
|
|
||||||
|
out.push(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
renderCardDescription() {
|
renderCardDescription() {
|
||||||
let title = this.state.title,
|
let title = this.state.title,
|
||||||
|
title_tokens = this.state.title_tokens,
|
||||||
desc_1 = this.state.desc_1,
|
desc_1 = this.state.desc_1,
|
||||||
desc_2 = this.state.desc_2;
|
desc_1_tokens = this.state.desc_1_tokens,
|
||||||
|
desc_2 = this.state.desc_2,
|
||||||
|
desc_2_tokens = this.state.desc_2_tokens;
|
||||||
|
|
||||||
if ( ! this.state.loaded ) {
|
if ( ! this.state.loaded ) {
|
||||||
desc_1 = t.i18n.t('card.loading', 'Loading...');
|
desc_1 = t.i18n.t('card.loading', 'Loading...');
|
||||||
desc_2 = '';
|
desc_1_tokens = desc_2 = desc_2_tokens = title = title_tokens = null;
|
||||||
title = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<div class={`ffz--card-text 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
|
||||||
class="tw-font-size-5"
|
class="tw-strong"
|
||||||
data-test-selector="chat-card-title"
|
data-test-selector="chat-card-title"
|
||||||
title={title}
|
title={title}
|
||||||
>
|
>
|
||||||
{title}
|
{title_tokens ? this.renderTokens(title_tokens) : title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-ellipsis">
|
<div class="tw-ellipsis">
|
||||||
<span
|
<span
|
||||||
class="tw-c-text-alt-2 tw-font-size-6"
|
class="tw-c-text-alt-2"
|
||||||
data-test-selector="chat-card-description"
|
data-test-selector="chat-card-description"
|
||||||
title={desc_1}
|
title={desc_1}
|
||||||
>
|
>
|
||||||
{desc_1}
|
{desc_1_tokens ? this.renderTokens(desc_1_tokens) : desc_1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{desc_2 && (<div class="tw-ellipsis">
|
{(desc_2_tokens || desc_2) && (<div class="tw-ellipsis">
|
||||||
<span
|
<span
|
||||||
class="tw-c-text-alt-2 tw-font-size-6"
|
class="tw-c-text-alt-2"
|
||||||
data-test-selector="chat-card-description"
|
data-test-selector="chat-card-description"
|
||||||
title={desc_2}
|
title={desc_2}
|
||||||
>
|
>
|
||||||
{desc_2}
|
{desc_2_tokens ? this.renderTokens(desc_2_tokens) : desc_2}
|
||||||
</span>
|
</span>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCardBody() {
|
renderCard() {
|
||||||
if ( this.props.renderBody )
|
if ( this.props.renderBody )
|
||||||
return this.props.renderBody(this.state, this, createElement);
|
return this.props.renderBody(this.state, this, createElement);
|
||||||
|
|
||||||
|
@ -149,31 +175,31 @@ export default class RichContent extends Module {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCard() {
|
|
||||||
return (<div class="ffz--chat-card tw-elevation-1 tw-mg-t">
|
|
||||||
<div class="tw-c-background-base tw-flex tw-flex-nowrap tw-pd-05">
|
|
||||||
{this.renderCardBody()}
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if ( ! this.state.url )
|
let content = <div class="tw-flex tw-flex-nowrap tw-pd-05">{this.renderCard()}</div>;
|
||||||
return this.renderCard();
|
if ( this.state.url ) {
|
||||||
|
const tooltip = this.props.card_tooltip;
|
||||||
|
content = (<a
|
||||||
|
class={`${tooltip ? 'ffz-tooltip ' : ''}${this.state.accent ? 'ffz-accent-card ' : ''} tw-block tw-border-radius-medium tw-full-width tw-interactable tw-interactable--alpha tw-interactable--hover-enabled tw-interactive`}
|
||||||
|
data-tooltip-type="link"
|
||||||
|
data-url={this.state.url}
|
||||||
|
data-is-mail={false}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
href={this.state.url}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>);
|
||||||
|
}
|
||||||
|
|
||||||
const tooltip = this.props.card_tooltip;
|
return (<div
|
||||||
|
class="tw-border-radius-medium tw-elevation-1 ffz--chat-card"
|
||||||
return (<a
|
style={{'--ffz-color-accent': this.state.accent || null}}
|
||||||
class={`${tooltip ? 'ffz-tooltip ' : ''} chat-card__link`}
|
|
||||||
data-tooltip-type="link"
|
|
||||||
data-url={this.state.url}
|
|
||||||
data-is-mail={false}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
href={this.state.url}
|
|
||||||
>
|
>
|
||||||
{this.renderCard()}
|
<div class="tw-border-radius-medium tw-c-background-base tw-flex tw-full-width">
|
||||||
</a>);
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -349,7 +349,7 @@ export default class CSSTweaks extends Module {
|
||||||
this.settings.add('channel.hide-live-indicator', {
|
this.settings.add('channel.hide-live-indicator', {
|
||||||
requires: ['context.route.name'],
|
requires: ['context.route.name'],
|
||||||
process(ctx, val) {
|
process(ctx, val) {
|
||||||
return ctx.get('context.route.name') === 'user' ? val : false
|
return (ctx.get('context.route.name') === 'user' || ctx.get('context.route.name') === 'user-home') ? val : false
|
||||||
},
|
},
|
||||||
default: false,
|
default: false,
|
||||||
ui: {
|
ui: {
|
||||||
|
|
|
@ -12,7 +12,7 @@ export const PLAYER_ROUTES = [
|
||||||
'front-page', 'user', 'video', 'user-video', 'user-clip', 'user-videos',
|
'front-page', 'user', 'video', 'user-video', 'user-clip', 'user-videos',
|
||||||
'user-clips', 'user-collections', 'user-events', 'user-followers',
|
'user-clips', 'user-collections', 'user-events', 'user-followers',
|
||||||
'user-following', 'dash', 'squad', 'command-center', 'dash-stream-manager',
|
'user-following', 'dash', 'squad', 'command-center', 'dash-stream-manager',
|
||||||
'mod-view'
|
'mod-view', 'user-home'
|
||||||
];
|
];
|
||||||
|
|
||||||
const HAS_COMPRESSOR = window.AudioContext && window.DynamicsCompressorNode != null;
|
const HAS_COMPRESSOR = window.AudioContext && window.DynamicsCompressorNode != null;
|
||||||
|
@ -74,13 +74,13 @@ export default class Player extends Module {
|
||||||
this.TheatreHost = this.fine.define(
|
this.TheatreHost = this.fine.define(
|
||||||
'theatre-host',
|
'theatre-host',
|
||||||
n => n.toggleTheatreMode && n.props && n.props.onTheatreModeEnabled,
|
n => n.toggleTheatreMode && n.props && n.props.onTheatreModeEnabled,
|
||||||
['user', 'video', 'user-video', 'user-clip']
|
['user', 'user-home', 'video', 'user-video', 'user-clip']
|
||||||
);
|
);
|
||||||
|
|
||||||
this.PlayerSource = this.fine.define(
|
this.PlayerSource = this.fine.define(
|
||||||
'player-source',
|
'player-source',
|
||||||
n => n.setSrc && n.setInitialPlaybackSettings,
|
n => n.setSrc && n.setInitialPlaybackSettings,
|
||||||
false
|
PLAYER_ROUTES
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
@ -648,7 +648,7 @@ export default class Player extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
cls.prototype.ffzStopAutoplay = function() {
|
cls.prototype.ffzStopAutoplay = function() {
|
||||||
if ( t.settings.get('player.no-autoplay') || (! t.settings.get('player.home.autoplay') && t.router.current.name === 'front-page') )
|
if ( t.settings.get('player.no-autoplay') || (! t.settings.get('player.home.autoplay') && t.router.current?.name === 'front-page') )
|
||||||
t.stopPlayer(this.props.mediaPlayerInstance, this.props.playerEvents, this);
|
t.stopPlayer(this.props.mediaPlayerInstance, this.props.playerEvents, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,7 @@ export default class SubButton extends Module {
|
||||||
this.SubButton = this.fine.define(
|
this.SubButton = this.fine.define(
|
||||||
'sub-button',
|
'sub-button',
|
||||||
n => n.handleSubMenuAction && n.isUserDataReady,
|
n => n.handleSubMenuAction && n.isUserDataReady,
|
||||||
['user', 'user-video', 'user-clip', 'video', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following']
|
['user', 'user-home', 'user-video', 'user-clip', 'video', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -62,6 +62,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ffz-accent-card {
|
||||||
|
border-right: .5rem solid var(--ffz-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-card__preview-img.square {
|
||||||
|
width: 4.5rem;
|
||||||
|
|
||||||
|
.tw-aspect__spacer {
|
||||||
|
padding-top: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-card__title {
|
.chat-card__title {
|
||||||
max-width: unset;
|
max-width: unset;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
class="tw-aspect"
|
class="tw-aspect"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:style="{paddingBottom: (props.padding ? props.padding : (100 * (1 / (props.ratio || 1)))) + '%'}"
|
:style="{paddingTop: (props.padding ? props.padding : (100 * (1 / (props.ratio || 1)))) + '%'}"
|
||||||
class="tw-aspect__spacer"
|
class="tw-aspect__spacer"
|
||||||
/>
|
/>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import {parse, tokensToRegExp, tokensToFunction} from 'path-to-regexp';
|
import {parse, tokensToRegExp, tokensToFunction} from 'path-to-regexp';
|
||||||
import Module from 'utilities/module';
|
import Module from 'utilities/module';
|
||||||
import {has} from 'utilities/object';
|
import {has, deep_equals} from 'utilities/object';
|
||||||
|
|
||||||
|
|
||||||
export default class FineRouter extends Module {
|
export default class FineRouter extends Module {
|
||||||
|
@ -39,20 +39,27 @@ export default class FineRouter extends Module {
|
||||||
this._navigateTo(history.location);
|
this._navigateTo(history.location);
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(route, data, opts) {
|
navigate(route, data, opts, state) {
|
||||||
this.history.push(this.getURL(route, data, opts));
|
this.history.push(this.getURL(route, data, opts), state);
|
||||||
}
|
}
|
||||||
|
|
||||||
_navigateTo(location) {
|
_navigateTo(location) {
|
||||||
this.log.debug('New Location', location);
|
this.log.debug('New Location', location);
|
||||||
const host = window.location.host,
|
const host = window.location.host,
|
||||||
path = location.pathname;
|
path = location.pathname,
|
||||||
|
state = location.state;
|
||||||
|
|
||||||
if ( path === this.location && host === this.domain )
|
if ( path === this.location && host === this.domain && deep_equals(state, this.current_state) )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
this.old_location = this.location;
|
||||||
|
this.old_domain = this.domain;
|
||||||
|
this.old_state = this.current_state;
|
||||||
|
|
||||||
this.location = path;
|
this.location = path;
|
||||||
this.domain = host;
|
this.domain = host;
|
||||||
|
this.current_state = state;
|
||||||
|
|
||||||
this._pickRoute();
|
this._pickRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,12 +67,16 @@ export default class FineRouter extends Module {
|
||||||
const path = this.location,
|
const path = this.location,
|
||||||
host = this.domain;
|
host = this.domain;
|
||||||
|
|
||||||
|
this.old_route = this.current;
|
||||||
|
this.old_name = this.current_name;
|
||||||
|
this.old_match = this.match;
|
||||||
|
|
||||||
for(const route of this.__routes) {
|
for(const route of this.__routes) {
|
||||||
if ( route.domain && route.domain !== host )
|
if ( route.domain && route.domain !== host )
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const match = route.regex.exec(path);
|
const match = route.regex.exec(path);
|
||||||
if ( match ) {
|
if ( match && (! route.state_fn || route.state_fn(this.current_state)) ) {
|
||||||
this.log.debug('Matching Route', route, match);
|
this.log.debug('Matching Route', route, match);
|
||||||
this.current = route;
|
this.current = route;
|
||||||
this.current_name = route.name;
|
this.current_name = route.name;
|
||||||
|
@ -146,12 +157,12 @@ export default class FineRouter extends Module {
|
||||||
this.emit(':updated-route-names');
|
this.emit(':updated-route-names');
|
||||||
}
|
}
|
||||||
|
|
||||||
route(name, path, domain = null, process = true) {
|
route(name, path, domain = null, state_fn = null, process = true) {
|
||||||
if ( typeof name === 'object' ) {
|
if ( typeof name === 'object' ) {
|
||||||
domain = path;
|
domain = path;
|
||||||
for(const key in name)
|
for(const key in name)
|
||||||
if ( has(name, key) )
|
if ( has(name, key) )
|
||||||
this.route(key, name[key], domain, false);
|
this.route(key, name[key], domain, state_fn, false);
|
||||||
|
|
||||||
if ( process ) {
|
if ( process ) {
|
||||||
this.__routes.sort((a,b) => b.score - a.score);
|
this.__routes.sort((a,b) => b.score - a.score);
|
||||||
|
@ -173,6 +184,7 @@ export default class FineRouter extends Module {
|
||||||
parts,
|
parts,
|
||||||
score,
|
score,
|
||||||
domain,
|
domain,
|
||||||
|
state_fn,
|
||||||
regex: tokensToRegExp(parts),
|
regex: tokensToRegExp(parts),
|
||||||
url: tokensToFunction(parts)
|
url: tokensToFunction(parts)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,21 @@ query FFZ_FetchUser($id: ID, $login: String) {
|
||||||
login
|
login
|
||||||
displayName
|
displayName
|
||||||
profileImageURL(width: 50)
|
profileImageURL(width: 50)
|
||||||
|
profileViewCount
|
||||||
primaryColorHex
|
primaryColorHex
|
||||||
|
broadcastSettings {
|
||||||
|
id
|
||||||
|
game {
|
||||||
|
id
|
||||||
|
displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
followers {
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
roles {
|
roles {
|
||||||
isAffiliate
|
isAffiliate
|
||||||
isPartner
|
isPartner
|
||||||
|
|
|
@ -69,7 +69,7 @@ export const DEFAULT_TYPES = {
|
||||||
},
|
},
|
||||||
|
|
||||||
duration(val) {
|
duration(val) {
|
||||||
return duration_to_string(val);
|
return this.formatDuration(val);
|
||||||
},
|
},
|
||||||
|
|
||||||
localestring(val) {
|
localestring(val) {
|
||||||
|
@ -257,6 +257,10 @@ export default class TranslationCore {
|
||||||
return formatter.format(value);
|
return formatter.format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatDuration(value) { // eslint-disable-line class-methods-use-this
|
||||||
|
return duration_to_string(value);
|
||||||
|
}
|
||||||
|
|
||||||
formatDate(value, format) {
|
formatDate(value, format) {
|
||||||
if ( typeof format === 'string' && format.startsWith('::') ) {
|
if ( typeof format === 'string' && format.startsWith('::') ) {
|
||||||
const f = format.substr(2),
|
const f = format.substr(2),
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue