1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-07-31 06:58:30 +00:00
* 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:
SirStendec 2020-07-26 17:50:14 -04:00
parent fa3d73e05a
commit a4fa1d1491
21 changed files with 451 additions and 629 deletions

View file

@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.20.18",
"version": "4.20.19",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"license": "Apache-2.0",
"scripts": {

View file

@ -647,6 +647,10 @@ export class TranslationManager extends Module {
return this._.formatNumber(...args);
}
formatDuration(...args) {
return this._.formatDuration(...args);
}
formatDate(...args) {
return this._.formatDate(...args)
}

View file

@ -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>
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 {
props: ['data', 'url'],
@ -80,10 +11,17 @@ export default {
return {
loaded: false,
error: false,
title: null,
html: null,
title: this.t('card.loading', 'Loading...'),
title_tokens: null,
desc_1: null,
desc_1_tokens: 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 {
data = this.data.getData();
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 )
data = await timeout(data, to_wait);
else
@ -105,7 +43,6 @@ export default {
title: this.t('card.error', 'An error occured.'),
desc_1: this.t('card.empty', 'No data was returned.')
}
} catch(err) {
data = {
error: true,
@ -116,11 +53,157 @@ export default {
this.loaded = true;
this.error = data.error;
this.image = data.image;
this.html = data.html;
this.title = data.title;
this.title_tokens = data.title_tokens;
this.desc_1 = data.desc_1;
this.desc_1_tokens = data.desc_1_tokens;
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>

View file

@ -7,6 +7,12 @@
const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/;
const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/(\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_VIDEO from './video_info.gql';
@ -55,7 +61,9 @@ export const Links = {
return {
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_square: data.image_square,
title: data.title,
desc_1: data.desc_1,
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
// ============================================================================
export const Clips = {
type: 'clip',
hide_token: true,
hide_token: false,
test(token) {
if ( token.type !== 'link' )
@ -110,29 +205,47 @@ export const Clips = {
game_name = game && game.name,
game_display = game && game.displayName;
let desc_1;
if ( game_name === 'creative' )
let desc_1, desc_1_tokens;
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', {
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}', {
user,
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});
}
const curator = clip.curator ? clip.curator.displayName : this.i18n.t('clip.unknown', 'Unknown');
return {
url: token.url,
image: clip.thumbnailURL,
title: clip.title,
desc_1,
desc_1_tokens,
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
}),
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 = {
type: 'video',
hide_token: true,
hide_token: false,
test(token) {
return token.type === 'link' && VIDEO_URL.test(token.url)
@ -174,26 +287,38 @@ export const Videos = {
game_name = game && game.name,
game_display = game && game.displayName;
let desc_1;
if ( game_name === 'creative' )
let desc_1, desc_1_tokens;
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', {
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}', {
user,
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});
}
return {
url: token.url,
image: video.previewThumbnailURL,
title: video.title,
desc_1,
desc_1_tokens,
desc_2: this.i18n.t('video.desc.2', '{length,duration} — {views,number} Views - {date,datetime}', {
length: video.lengthSeconds,
views: video.viewCount,

View file

@ -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);

View file

@ -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;

View file

@ -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>

View file

@ -13,7 +13,9 @@
<script>
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 {

View file

@ -109,17 +109,20 @@ export default class Metadata extends Module {
refresh() { return this.settings.get('metadata.viewers') },
setup(data) {
return data.getViewerCount();
return {
live: data.channel?.live_since != null,
count: data.getViewerCount()
}
},
order: 1,
icon: 'ffz-i-viewers',
label(data) {
if ( ! this.settings.get('metadata.viewers') )
if ( ! this.settings.get('metadata.viewers') || ! data.live )
return null;
return this.i18n.formatNumber(data)
return this.i18n.formatNumber(data.count)
},
tooltip() {

View file

@ -50,6 +50,9 @@ export default class Twilight extends BaseSite {
this.router.route(Twilight.ROUTES);
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');
}
@ -213,6 +216,7 @@ Twilight.CHAT_ROUTES = [
'dash-chat',
'video',
'user-video',
'user-home',
'user-clip',
'user-videos',
'user-clips',
@ -312,7 +316,7 @@ Twilight.ROUTES = {
'product': '/products/:productName',
'prime': '/prime',
'turbo': '/turbo',
'user': '/:userName',
//'user': '/:userName',
'squad': '/:userName/squad',
'command-center': '/:userName/commandcenter',
'embed-chat': '/embed/:userName/chat',

View file

@ -10,7 +10,7 @@ import {debounce} from 'utilities/object';
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 {
@ -103,12 +103,18 @@ export default class Channel extends Module {
this.subpump.on(':pubsub-message', this.onPubSub, this);
this.router.on(':route', route => {
if ( route?.name === 'user' )
setTimeout(this.maybeClickChat.bind(this), 1000);
}, this);
this.router.on(':route', this.checkNavigation, this);
this.checkNavigation();
}
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() {
@ -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) {
const topic = `stream-chat-room-v1.${channel_id}`;

View file

@ -115,7 +115,8 @@ const CHAT_TYPES = make_enum(
'FirstCheerMessage',
'BitsBadgeTierMessage',
'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 )
return;
if ( data.user_input )
return;
const reward = data.reward?.id && get(data.reward.id, service.props.rewardMap);
if ( ! reward )
return;

View file

@ -75,7 +75,7 @@ export default class RichContent extends Module {
}
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 ?
(<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__spacer" style={{paddingTop: '56.25%'}} />
{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}
</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() {
let title = this.state.title,
title_tokens = this.state.title_tokens,
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 ) {
desc_1 = t.i18n.t('card.loading', 'Loading...');
desc_2 = '';
title = '';
desc_1_tokens = desc_2 = desc_2_tokens = title = title_tokens = null;
}
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
class="tw-font-size-5"
class="tw-strong"
data-test-selector="chat-card-title"
title={title}
>
{title}
{title_tokens ? this.renderTokens(title_tokens) : title}
</span>
</div>
<div class="tw-ellipsis">
<span
class="tw-c-text-alt-2 tw-font-size-6"
class="tw-c-text-alt-2"
data-test-selector="chat-card-description"
title={desc_1}
>
{desc_1}
{desc_1_tokens ? this.renderTokens(desc_1_tokens) : desc_1}
</span>
</div>
{desc_2 && (<div class="tw-ellipsis">
{(desc_2_tokens || desc_2) && (<div class="tw-ellipsis">
<span
class="tw-c-text-alt-2 tw-font-size-6"
class="tw-c-text-alt-2"
data-test-selector="chat-card-description"
title={desc_2}
>
{desc_2}
{desc_2_tokens ? this.renderTokens(desc_2_tokens) : desc_2}
</span>
</div>)}
</div>
</div>)
}
renderCardBody() {
renderCard() {
if ( this.props.renderBody )
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() {
if ( ! this.state.url )
return this.renderCard();
let content = <div class="tw-flex tw-flex-nowrap tw-pd-05">{this.renderCard()}</div>;
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 (<a
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}
return (<div
class="tw-border-radius-medium tw-elevation-1 ffz--chat-card"
style={{'--ffz-color-accent': this.state.accent || null}}
>
{this.renderCard()}
</a>);
<div class="tw-border-radius-medium tw-c-background-base tw-flex tw-full-width">
{content}
</div>
</div>);
}
}
}

View file

@ -349,7 +349,7 @@ export default class CSSTweaks extends Module {
this.settings.add('channel.hide-live-indicator', {
requires: ['context.route.name'],
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,
ui: {

View file

@ -12,7 +12,7 @@ export const PLAYER_ROUTES = [
'front-page', 'user', 'video', 'user-video', 'user-clip', 'user-videos',
'user-clips', 'user-collections', 'user-events', 'user-followers',
'user-following', 'dash', 'squad', 'command-center', 'dash-stream-manager',
'mod-view'
'mod-view', 'user-home'
];
const HAS_COMPRESSOR = window.AudioContext && window.DynamicsCompressorNode != null;
@ -74,13 +74,13 @@ export default class Player extends Module {
this.TheatreHost = this.fine.define(
'theatre-host',
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(
'player-source',
n => n.setSrc && n.setInitialPlaybackSettings,
false
PLAYER_ROUTES
);
@ -648,7 +648,7 @@ export default class Player extends Module {
}
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);
}

View file

@ -33,7 +33,7 @@ export default class SubButton extends Module {
this.SubButton = this.fine.define(
'sub-button',
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']
);
}

View file

@ -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 {
max-width: unset;
}

View file

@ -4,7 +4,7 @@
class="tw-aspect"
>
<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"
/>
<slot />

View file

@ -6,7 +6,7 @@
import {parse, tokensToRegExp, tokensToFunction} from 'path-to-regexp';
import Module from 'utilities/module';
import {has} from 'utilities/object';
import {has, deep_equals} from 'utilities/object';
export default class FineRouter extends Module {
@ -39,20 +39,27 @@ export default class FineRouter extends Module {
this._navigateTo(history.location);
}
navigate(route, data, opts) {
this.history.push(this.getURL(route, data, opts));
navigate(route, data, opts, state) {
this.history.push(this.getURL(route, data, opts), state);
}
_navigateTo(location) {
this.log.debug('New Location', location);
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;
this.old_location = this.location;
this.old_domain = this.domain;
this.old_state = this.current_state;
this.location = path;
this.domain = host;
this.current_state = state;
this._pickRoute();
}
@ -60,12 +67,16 @@ export default class FineRouter extends Module {
const path = this.location,
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) {
if ( route.domain && route.domain !== host )
continue;
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.current = route;
this.current_name = route.name;
@ -146,12 +157,12 @@ export default class FineRouter extends Module {
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' ) {
domain = path;
for(const key in name)
if ( has(name, key) )
this.route(key, name[key], domain, false);
this.route(key, name[key], domain, state_fn, false);
if ( process ) {
this.__routes.sort((a,b) => b.score - a.score);
@ -173,6 +184,7 @@ export default class FineRouter extends Module {
parts,
score,
domain,
state_fn,
regex: tokensToRegExp(parts),
url: tokensToFunction(parts)
}

View file

@ -4,7 +4,21 @@ query FFZ_FetchUser($id: ID, $login: String) {
login
displayName
profileImageURL(width: 50)
profileViewCount
primaryColorHex
broadcastSettings {
id
game {
id
displayName
}
}
stream {
id
}
followers {
totalCount
}
roles {
isAffiliate
isPartner

View file

@ -69,7 +69,7 @@ export const DEFAULT_TYPES = {
},
duration(val) {
return duration_to_string(val);
return this.formatDuration(val);
},
localestring(val) {
@ -257,6 +257,10 @@ export default class TranslationCore {
return formatter.format(value);
}
formatDuration(value) { // eslint-disable-line class-methods-use-this
return duration_to_string(value);
}
formatDate(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),