diff --git a/package.json b/package.json index 2365a97c..61b29275 100755 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/i18n.js b/src/i18n.js index 8312f520..8f02e2c8 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -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) } diff --git a/src/modules/chat/components/chat-rich.vue b/src/modules/chat/components/chat-rich.vue index d61d9a6e..22694263 100644 --- a/src/modules/chat/components/chat-rich.vue +++ b/src/modules/chat/components/chat-rich.vue @@ -1,77 +1,8 @@ - - \ No newline at end of file diff --git a/src/modules/chat/rich_providers.js b/src/modules/chat/rich_providers.js index 0c63f844..61f33d87 100644 --- a/src/modules/chat/rich_providers.js +++ b/src/modules/chat/rich_providers.js @@ -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, diff --git a/src/modules/logviewer/index.js.disabled b/src/modules/logviewer/index.js.disabled deleted file mode 100644 index 901776b6..00000000 --- a/src/modules/logviewer/index.js.disabled +++ /dev/null @@ -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); \ No newline at end of file diff --git a/src/modules/logviewer/socket.js b/src/modules/logviewer/socket.js deleted file mode 100644 index 160b01a9..00000000 --- a/src/modules/logviewer/socket.js +++ /dev/null @@ -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; \ No newline at end of file diff --git a/src/modules/logviewer/tab-logs.vue b/src/modules/logviewer/tab-logs.vue deleted file mode 100644 index e7171ff9..00000000 --- a/src/modules/logviewer/tab-logs.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - \ No newline at end of file diff --git a/src/modules/main_menu/components/chat-rich-example.vue b/src/modules/main_menu/components/chat-rich-example.vue index 8194917f..10e7f32f 100644 --- a/src/modules/main_menu/components/chat-rich-example.vue +++ b/src/modules/main_menu/components/chat-rich-example.vue @@ -13,7 +13,9 @@