From 14400e16bcf8413af92942e1754ea794d5f9e6ce Mon Sep 17 00:00:00 2001 From: SirStendec Date: Sun, 18 Dec 2022 17:30:34 -0500 Subject: [PATCH] 4.39.0 * Added: Option to change the size of Message Hover actions. * Added: New chat action appearance type "Emote" that makes it easy to use an emote image for an action. * Changed: Do not show the "Pin" action on messages with no message body. * Changed: Use Twitch's API for embeds/tooltips of Twitch URLs. This now makes use of clip embed data being sent via PubSub, notably. * Fixed: Multiple emotes with the same name being listed in tab-completion. * Experiment: There's a new chat line render method available. This is not currently enabled for any users, but it will be enabled after more internal testing. The new method is not necessarily faster, though it should not be slower. The main purpose of the rewrite is code de-duplication and making the renderer easier to maintain. * API Added: `chat.addLinkProvider(provider);` to register a handler for link data. * API Fixed: Do not allow duplicate registration of tokenizers or rich embed handlers for chat. --- package.json | 2 +- src/experiments.js | 2 + src/experiments.json | 8 + .../chat/actions/components/edit-emote.vue | 40 + .../chat/actions/components/preview-emote.vue | 5 + src/modules/chat/actions/index.jsx | 12 + src/modules/chat/actions/renderers.jsx | 15 + src/modules/chat/actions/types.jsx | 4 + src/modules/chat/index.js | 103 +++ src/modules/chat/link_providers.js | 459 +++++++++++ src/modules/chat/rich_providers.js | 306 +------ src/modules/chat/video_info.gql | 3 +- .../main_menu/components/chat-tester.vue | 699 ++++++++++++++++ .../main_menu/components/link-tester.vue | 10 +- .../main_menu/sample-chat-messages.json | 29 + .../twitch-twilight/modules/chat/index.js | 17 + .../twitch-twilight/modules/chat/input.jsx | 5 +- .../twitch-twilight/modules/chat/line.js | 774 ++++++++++++++---- src/sites/twitch-twilight/styles/chat.scss | 10 +- src/std-components/emote-picker.vue | 259 ++++++ src/utilities/logging.js | 80 ++ styles/icons.scss | 2 +- styles/widgets.scss | 2 + styles/widgets/icon-picker.scss | 9 + 24 files changed, 2404 insertions(+), 451 deletions(-) create mode 100644 src/modules/chat/actions/components/edit-emote.vue create mode 100644 src/modules/chat/actions/components/preview-emote.vue create mode 100644 src/modules/chat/link_providers.js create mode 100644 src/modules/main_menu/components/chat-tester.vue create mode 100644 src/modules/main_menu/sample-chat-messages.json create mode 100644 src/std-components/emote-picker.vue diff --git a/package.json b/package.json index c0e3b381..3a045a67 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frankerfacez", "author": "Dan Salvato LLC", - "version": "4.38.2", + "version": "4.39.0", "description": "FrankerFaceZ is a Twitch enhancement suite.", "private": true, "license": "Apache-2.0", diff --git a/src/experiments.js b/src/experiments.js index 9086db47..987ee771 100644 --- a/src/experiments.js +++ b/src/experiments.js @@ -48,6 +48,8 @@ export default class ExperimentManager extends Module { constructor(...args) { super(...args); + this.get = this.getAssignment; + this.inject('settings'); this.settings.addUI('experiments', { diff --git a/src/experiments.json b/src/experiments.json index 1edc1968..513291a6 100644 --- a/src/experiments.json +++ b/src/experiments.json @@ -1,4 +1,12 @@ { + "line_renderer": { + "name": "Modular Chat Line Rendering", + "description": "Enable a newer, modular chat line renderer.", + "groups": [ + {"value": true, "weight": 0}, + {"value": false, "weight": 100} + ] + }, "api_load": { "name": "New API Stress Testing", "description": "Send duplicate requests to the new API server for load testing.", diff --git a/src/modules/chat/actions/components/edit-emote.vue b/src/modules/chat/actions/components/edit-emote.vue new file mode 100644 index 00000000..70adf608 --- /dev/null +++ b/src/modules/chat/actions/components/edit-emote.vue @@ -0,0 +1,40 @@ + + + \ No newline at end of file diff --git a/src/modules/chat/actions/components/preview-emote.vue b/src/modules/chat/actions/components/preview-emote.vue new file mode 100644 index 00000000..7ee8b3d9 --- /dev/null +++ b/src/modules/chat/actions/components/preview-emote.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/modules/chat/actions/index.jsx b/src/modules/chat/actions/index.jsx index acc946bb..12099de8 100644 --- a/src/modules/chat/actions/index.jsx +++ b/src/modules/chat/actions/index.jsx @@ -38,6 +38,18 @@ export default class Actions extends Module { } }); + this.settings.add('chat.actions.hover-size', { + default: 30, + ui: { + path: 'Chat > Actions > Message Hover >> Appearance', + title: 'Action Size', + description: "How tall hover actions should be, in pixels. This may be affected by your browser's zoom and font size settings.", + component: 'setting-text-box', + process: 'to_int', + bounds: [1] + } + }); + this.settings.add('chat.actions.reasons', { default: [ {v: {text: 'One-Man Spam', i18n: 'chat.reasons.spam'}}, diff --git a/src/modules/chat/actions/renderers.jsx b/src/modules/chat/actions/renderers.jsx index 59a4d444..a8ecc3cb 100644 --- a/src/modules/chat/actions/renderers.jsx +++ b/src/modules/chat/actions/renderers.jsx @@ -37,6 +37,21 @@ export const text = { } } +// ============================================================================ +// Emote +// ============================================================================ + +export const emote = { + title: 'Emote', + title_i18n: 'setting.actions.appearance.emote', + + editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-emote.vue'), + + component: () => import(/* webpackChunkName: 'main-menu' */ './components/preview-emote.vue'), + render(data, createElement) { + return
; + } +} // ============================================================================ // Icon diff --git a/src/modules/chat/actions/types.jsx b/src/modules/chat/actions/types.jsx index bce2458b..d0b214a5 100644 --- a/src/modules/chat/actions/types.jsx +++ b/src/modules/chat/actions/types.jsx @@ -46,6 +46,10 @@ export const pin = { if ( ! line.props.isPinnable || ! line.onPinMessageClick ) return true; + + // If the message is empty or deleted, we can't pin it. + if ( ! message.message || ! message.message.length || message.deleted ) + return true; }, click(event, data) { diff --git a/src/modules/chat/index.js b/src/modules/chat/index.js index 75ab4b1a..cca33809 100644 --- a/src/modules/chat/index.js +++ b/src/modules/chat/index.js @@ -20,6 +20,7 @@ import Room from './room'; import User from './user'; import * as TOKENIZERS from './tokenizers'; import * as RICH_PROVIDERS from './rich_providers'; +import * as LINK_PROVIDERS from './link_providers'; import Actions from './actions'; import { getFontsList } from 'src/utilities/fonts'; @@ -99,6 +100,9 @@ export default class Chat extends Module { this.rich_providers = {}; this.__rich_providers = []; + this.link_providers = {}; + this.__link_providers = []; + this._hl_reasons = {}; this.addHighlightReason('mention', 'Mentioned'); this.addHighlightReason('user', 'Highlight User'); @@ -1241,6 +1245,8 @@ export default class Chat extends Module { onEnable() { this.socket = this.resolve('socket'); + this.on('site.subpump:pubsub-message', this.onPubSub, this); + if ( this.context.get('chat.filtering.color-mentions') ) this.createColorCache().then(() => this.emit(':update-line-tokens')); @@ -1251,6 +1257,47 @@ export default class Chat extends Module { for(const key in RICH_PROVIDERS) if ( has(RICH_PROVIDERS, key) ) this.addRichProvider(RICH_PROVIDERS[key]); + + for(const key in LINK_PROVIDERS) + if ( has(LINK_PROVIDERS, key) ) + this.addLinkProvider(LINK_PROVIDERS[key]); + } + + + onPubSub(event) { + if ( event.prefix === 'stream-chat-room-v1' && event.message.type === 'chat_rich_embed' ) { + const data = event.message.data, + url = data.request_url, + + providers = this.__link_providers; + + // Don't re-cache. + if ( this._link_info[url] ) + return; + + for(const provider of providers) { + const match = provider.test.call(this, url); + if ( match ) { + const processed = provider.receive ? provider.receive.call(this, match, data) : data; + let result = provider.process.call(this, match, processed); + + if ( !(result instanceof Promise) ) + result = Promise.resolve(result); + + result.then(value => { + // If something is already running, don't override it. + let info = this._link_info[url]; + if ( info ) + return; + + // Save the value. + this._link_info[url] = [true, Date.now() + 120000, value]; + }); + + return; + } + } + } } @@ -1855,6 +1902,11 @@ export default class Chat extends Module { addTokenizer(tokenizer) { const type = tokenizer.type; + if ( has(this.tokenizers, type) ) { + this.log.warn(`Tried adding tokenizer of type '${type}' when one was already present.`); + return; + } + this.tokenizers[type] = tokenizer; if ( tokenizer.priority == null ) tokenizer.priority = 0; @@ -1894,8 +1946,48 @@ export default class Chat extends Module { return tokenizer; } + addLinkProvider(provider) { + const type = provider.type; + if ( has(this.link_providers, type) ) { + this.log.warn(`Tried adding link provider of type '${type}' when one was already present.`); + return; + } + + this.link_providers[type] = provider; + if ( provider.priority == null ) + provider.priority = 0; + + this.__link_providers.push(provider); + this.__link_providers.sort((a,b) => { + if ( a.priority > b.priority ) return -1; + if ( a.priority < b.priority ) return 1; + return a.type < b.type; + }); + } + + removeLinkProvider(provider) { + let type; + if ( typeof provider === 'string' ) type = provider; + else type = provider.type; + + provider = this.link_providers[type]; + if ( ! provider ) + return null; + + const idx = this.__link_providers.indexOf(provider); + if ( idx !== -1 ) + this.__link_providers.splice(idx, 1); + + return provider; + } + addRichProvider(provider) { const type = provider.type; + if ( has(this.rich_providers, type) ) { + this.log.warn(`Tried adding rich provider of type '${type}' when one was already present.`); + return; + } + this.rich_providers[type] = provider; if ( provider.priority == null ) provider.priority = 0; @@ -2108,6 +2200,17 @@ export default class Chat extends Module { cbs[success ? 0 : 1](data); } + // Try using a link provider. + for(const lp of this.__link_providers) { + const match = lp.test.call(this, url); + if ( match ) { + timeout(lp.process.call(this, match), 15000) + .then(data => handle(true, data)) + .catch(err => handle(false, err)); + return; + } + } + let provider = this.settings.get('debug.link-resolver.source'); if ( provider == null ) provider = this.experiments.getAssignment('api_links') ? 'test' : 'socket'; diff --git a/src/modules/chat/link_providers.js b/src/modules/chat/link_providers.js new file mode 100644 index 00000000..0173e134 --- /dev/null +++ b/src/modules/chat/link_providers.js @@ -0,0 +1,459 @@ +'use strict'; + +// ============================================================================ +// Rich Content Providers +// ============================================================================ + +const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/([a-z0-9-_=]+)(?:\/)?(\w+)?(?:\/edit)?/i; +const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:(?:www|m)\.)?twitch\.tv\/\w+\/clip\/([a-z0-9-_=]+)/i; +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'; + + +// ============================================================================ +// Clips +// ============================================================================ + +export const Clip = { + type: 'clip', + + test(url) { + const match = CLIP_URL.exec(url) || NEW_CLIP_URL.exec(url); + if ( match && match[1] && match[1] !== 'create' ) + return match[1]; + }, + + receive(match, data) { + const cd = data?.twitch_metadata?.clip_metadata; + if ( ! cd ) + return; + + return { + id: cd.id, + slug: cd.slug, + title: data.title, + thumbnailURL: data.thumbnail_url, + curator: { + id: cd.curator_id, + displayName: data.author_name + }, + broadcaster: { + id: cd.broadcaster_id, + displayName: cd.channel_display_name + }, + game: { + displayName: cd.game + } + } + }, + + async process(match, received) { + let clip = received; + + if ( ! clip ) { + const apollo = this.resolve('site.apollo'); + if ( ! apollo ) + return null; + + const result = await apollo.client.query({ + query: GET_CLIP, + variables: { + slug: match + } + }); + + clip = result?.data?.clip; + } + + if ( ! clip || ! clip.broadcaster ) + return null; + + const game = clip.game, + game_display = game && game.displayName; + + let user = { + type: 'style', weight: 'semibold', color: 'alt-2', + content: clip.broadcaster.displayName + }; + + if ( clip.broadcaster.login ) + user = { + type: 'link', url: `https://www.twitch.tv/${clip.broadcaster.login}`, + content: user + }; + + const subtitle = game_display ? { + type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: { + user, + game: {type: 'style', weight: 'semibold', content: game_display} + } + } : {type: 'i18n', key: 'clip.desc.1', phrase: 'Clip of {user}', content: {user}}; + + let curator = clip.curator ? { + type: 'style', color: 'alt-2', + content: clip.curator.displayName + } : {type: 'i18n', key: 'clip.unknown', phrase: 'Unknown'}; + + if ( clip.curator?.login ) + curator = { + type: 'link', url: `https://www.twitch.tv/${clip.curator.login}`, + content: curator + }; + + let extra; + + if ( clip.viewCount > 0 ) + extra = { + type: 'i18n', key: 'clip.desc.2', + phrase: 'Clipped by {curator} — {views, plural, one {# View} other {# Views}}', + content: { + curator, + views: clip.viewCount + } + }; + else + extra = { + type: 'i18n', key: 'clip.desc.no-views', + phrase: 'Clipped by {curator}', + content: { + curator + } + }; + + return { + accent: '#6441a4', + + short: { + type: 'header', + image: {type: 'image', url: clip.thumbnailURL, sfw: true, aspect: 16/9}, + title: clip.title, + subtitle, + extra + } + }; + } +} + + +// ============================================================================ +// Users +// ============================================================================ + +export const User = { + type: 'user', + + test(url) { + const match = USER_URL.exec(url); + if ( match && ! BAD_USERS.includes(match[1]) ) + return match[1]; + }, + + async process(match) { + const twitch_data = this.resolve('site.twitch_data'), + user = twitch_data ? await twitch_data.getUser(null, match) : null; + + if ( ! user || ! user.id ) + return null; + + const game = user.broadcastSettings?.game?.displayName, + stream_id = user.stream?.id; + + const fragments = { + avatar: { + type: 'image', + url: user.profileImageURL, + rounding: -1, + aspect: 1 + }, + desc: user.description, + title: [user.displayName] + }; + + if ( stream_id && game ) + fragments.game = {type: 'style', weight: 'semibold', content: game}; + + if ( user.displayName.trim().toLowerCase() !== user.login ) + fragments.title.push({ + type: 'style', color: 'alt-2', + content: [' (', user.login, ')'] + }); + + if ( user.roles?.isPartner ) + fragments.title.push({ + type: 'style', color: 'link', + content: {type: 'icon', name: 'verified'} + }); + + const full = [ + { + type: 'header', + image: {type: 'ref', name: 'avatar'}, + title: {type: 'ref', name: 'title'}, + }, + { + type: 'box', + 'mg-y': 'small', + wrap: 'pre-wrap', + lines: 5, + content: { + type: 'ref', + name: 'desc' + } + } + ]; + + if ( stream_id && game ) { + const thumb_url = user.stream.previewImageURL + ? user.stream.previewImageURL + .replace('{width}', '320') + .replace('{height}', '180') + : null; + + full.push({ + type: 'link', + url: `https://www.twitch.tv/${user.login}`, + embed: true, + interactive: true, + tooltip: false, + content: [ + { + type: 'conditional', + media: true, + content: { + type: 'gallery', + items: [ + { + type: 'image', + url: thumb_url, + aspect: 16/9 + } + ] + } + }, + { + type: 'box', + 'mg-y': 'small', + lines: 2, + content: user.broadcastSettings.title + }, + { + type: 'ref', + name: 'game' + } + ] + }); + } + + full.push({ + type: 'header', + compact: true, + subtitle: [ + { + type: 'icon', + name: 'twitch' + }, + ' Twitch' + ] + }); + + return { + v: 5, + + accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null, + fragments, + + short: { + type: 'header', + image: {type: 'ref', name: 'avatar'}, + title: {type: 'ref', name: 'title'}, + subtitle: {type: 'ref', name: 'desc'}, + extra: stream_id ? { + type: 'i18n', + key: 'cards.user.streaming', + phrase: 'streaming {game}', + content: { + game: {type: 'ref', name: 'game'} + } + } : null + }, + + full + } + } + +} + + +// ============================================================================ +// Videos +// ============================================================================ + +export const Video = { + type: 'video', + + test(url) { + const match = VIDEO_URL.exec(url); + if ( match ) + return match[1]; + }, + + async process(match) { + const apollo = this.resolve('site.apollo'); + if ( ! apollo ) + return null; + + const result = await apollo.client.query({ + query: GET_VIDEO, + variables: { + id: match + } + }); + + if ( ! result || ! result.data || ! result.data.video || ! result.data.video.owner ) + return null; + + const video = result.data.video, + game = video.game, + game_display = game && game.displayName; + + const fragments = { + title: video.title, + thumbnail: { + type: 'image', + url: video.previewThumbnailURL, + aspect: 16/9 + } + }; + + const user = { + type: 'link', + url: `https://www.twitch.tv/${video.owner.login}`, + content: { + type: 'style', + weight: 'semibold', + color: 'alt-2', + content: video.owner.displayName + } + }; + + fragments.subtitle = video.game?.displayName + ? { + type: 'i18n', + key: 'video.desc.1.playing', + phrase: 'Video of {user} playing {game}', + content: { + user, + game: { + type: 'style', + weight: 'semibold', + content: video.game.displayName + } + } + } + : { + type: 'i18n', + key: 'video.desc.1', + phrase: 'Video of {user}', + content: { + user + } + }; + + let length = video.lengthSeconds; + + return { + v: 5, + + fragments, + + short: { + type: 'header', + image: {type: 'ref', name: 'thumbnail'}, + title: {type: 'ref', name: 'title'}, + subtitle: {type: 'ref', name: 'subtitle'}, + extra: { + type: 'i18n', + key: 'video.desc.2', + phrase: '{length,duration} — {views,number} Views — {date,datetime}', + content: { + length, + views: video.viewCount, + date: video.publishedAt + } + } + }, + + full: [ + { + type: 'header', + image: { + type: 'image', + url: video.owner.profileImageURL, + rounding: -1, + aspect: 1 + }, + title: {type: 'ref', name: 'title'}, + subtitle: {type: 'ref', name: 'subtitle'} + }, + { + type: 'box', + 'mg-y': 'small', + lines: 5, + wrap: 'pre-wrap', + content: video.description + }, + { + type: 'conditional', + media: true, + content: { + type: 'gallery', + items: [ + { + type: 'overlay', + content: {type: 'ref', name: 'thumbnail'}, + 'top-left': { + type: 'format', + format: 'duration', + value: length + }, + 'bottom-left': { + type: 'i18n', + key: 'video.views', + phrase: '{views,number} views', + content: { + views: video.viewCount + } + } + } + ] + } + }, + { + type: 'header', + compact: true, + subtitle: [ + { + type: 'icon', + name: 'twitch' + }, + " Twitch • ", + { + type: 'format', + format: 'datetime', + value: video.publishedAt + } + ] + } + ] + }; + } + +} \ No newline at end of file diff --git a/src/modules/chat/rich_providers.js b/src/modules/chat/rich_providers.js index 7af6a449..5539c7ef 100644 --- a/src/modules/chat/rich_providers.js +++ b/src/modules/chat/rich_providers.js @@ -4,24 +4,6 @@ // Rich Content Providers // ============================================================================ -//const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/; -//const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/(\w+)/; -const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/([a-z0-9-_=]+)(?:\/)?(\w+)?(?:\/edit)?/i; -const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:(?:www|m)\.)?twitch\.tv\/\w+\/clip\/([a-z0-9-_=]+)/i; -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'; - -import {truncate} from 'utilities/object'; - - // ============================================================================ // General Links // ============================================================================ @@ -32,10 +14,18 @@ export const Links = { priority: -10, test(token) { - if ( ! this.context.get('chat.rich.all-links') && ! token.force_rich ) + if ( token.type !== 'link' ) return false; - return token.type === 'link' + const url = token.url; + + // Link providers always result in embeds. + for(const provider of this.__link_providers) { + if ( provider.test.call(this, url) ) + return true; + } + + return this.context.get('chat.rich.all-links') || token.force_rich; }, process(token, want_mid) { @@ -70,279 +60,3 @@ export const Links = { } } } - - -// ============================================================================ -// Users -// ============================================================================ - -export const Users = { - type: 'user', - can_hide_token: true, - - 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, - stream_id = user.stream?.id; - - let subtitle - if ( stream_id && game ) - subtitle = { - type: 'i18n', - key: 'cards.user.streaming', phrase: 'streaming {game}', content: { - game: {type: 'style', weight: 'semibold', content: game} - } - }; - - const extra = truncate(user.description); - const title = [user.displayName]; - - if ( user.displayName.trim().toLowerCase() !== user.login ) - title.push({ - type: 'style', color: 'alt-2', - content: [' (', user.login, ')'] - }); - - if ( user.roles?.isPartner ) - title.push({ - type: 'style', color: 'link', - content: {type: 'icon', name: 'verified'} - }); - - /*const full = [{ - type: 'header', - image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1}, - title, - subtitle, - extra: stream_id ? extra : null - }]; - - if ( stream_id ) { - full.push({type: 'box', 'mg-y': 'small', lines: 1, content: user.broadcastSettings.title}); - full.push({type: 'conditional', content: { - type: 'gallery', items: [{ - type: 'image', aspect: 16/9, sfw: false, url: user.stream.previewImageURL - }] - }}); - } else - full.push({type: 'box', 'mg-y': 'small', wrap: 'pre-wrap', lines: 5, content: truncate(user.description, 1000, undefined, undefined, false)}) - - full.push({ - type: 'fieldset', - fields: [ - { - name: {type: 'i18n', key: 'embed.twitch.views', phrase: 'Views'}, - value: {type: 'format', format: 'number', value: user.profileViewCount}, - inline: true - }, - { - name: {type: 'i18n', key: 'embed.twitch.followers', phrase: 'Followers'}, - value: {type: 'format', format: 'number', value: user.followers?.totalCount}, - inline: true - } - ] - }); - - full.push({ - type: 'header', - subtitle: [{type: 'icon', name: 'twitch'}, ' Twitch'] - });*/ - - return { - url: token.url, - accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null, - short: { - type: 'header', - image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1}, - title, - subtitle, - extra - } - } - } - } - } -} - - -// ============================================================================ -// Clips -// ============================================================================ - -export const Clips = { - type: 'clip', - can_hide_token: true, - - test(token) { - if ( token.type !== 'link' ) - return false; - - return CLIP_URL.test(token.url) || NEW_CLIP_URL.test(token.url); - }, - - process(token) { - let match = CLIP_URL.exec(token.url); - if ( ! match ) - match = NEW_CLIP_URL.exec(token.url); - - const apollo = this.resolve('site.apollo'); - if ( ! apollo || ! match || match[1] === 'create' ) - return; - - return { - url: token.url, - - getData: async () => { - const result = await apollo.client.query({ - query: GET_CLIP, - variables: { - slug: match[1] - } - }); - - if ( ! result || ! result.data || ! result.data.clip || ! result.data.clip.broadcaster ) - return null; - - const clip = result.data.clip, - game = clip.game, - game_display = game && game.displayName; - - const user = { - type: 'link', url: `https://www.twitch.tv/${clip.broadcaster.login}`, - content: { - type: 'style', weight: 'semibold', color: 'alt-2', - content: clip.broadcaster.displayName - } - }; - - const subtitle = game_display ? { - type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: { - user, - game: {type: 'style', weight: 'semibold', content: game_display} - } - } : {type: 'i18n', key: 'clip.desc.1', phrase: 'Clip of {user}', content: {user}}; - - const curator = clip.curator ? { - type: 'link', url: `https://www.twitch.tv/${clip.curator.login}`, - content: { - type: 'style', color: 'alt-2', - content: clip.curator.displayName - } - } : {type: 'i18n', key: 'clip.unknown', phrase: 'Unknown'}; - - const extra = { - type: 'i18n', key: 'clip.desc.2', - phrase: 'Clipped by {curator} — {views, plural, one {# View} other {# Views}}', - content: { - curator, - views: clip.viewCount - } - }; - - return { - url: token.url, - accent: '#6441a4', - - short: { - type: 'header', - image: {type: 'image', url: clip.thumbnailURL, sfw: true, aspect: 16/9}, - title: clip.title, - subtitle, - extra - } - } - } - } - } -} - - -export const Videos = { - type: 'video', - can_hide_token: true, - - test(token) { - return token.type === 'link' && VIDEO_URL.test(token.url) - }, - - process(token) { - const match = VIDEO_URL.exec(token.url), - apollo = this.resolve('site.apollo'); - - if ( ! apollo || ! match ) - return; - - return { - getData: async () => { - const result = await apollo.client.query({ - query: GET_VIDEO, - variables: { - id: match[1] - } - }); - - if ( ! result || ! result.data || ! result.data.video || ! result.data.video.owner ) - return null; - - const video = result.data.video, - game = video.game, - game_display = game && game.displayName; - - const user = { - type: 'link', url: `https://www.twitch.tv/${video.owner.login}`, - content: { - type: 'style', weight: 'semibold', color: 'alt-2', - content: video.owner.displayName - } - }; - - const subtitle = game_display ? { - type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: { - user, - game: {type: 'style', weight: 'semibold', content: game_display} - } - } : {type: 'i18n', key: 'video.desc.1', phrase: 'Video of {user}', content: {user}}; - - const extra = { - type: 'i18n', key: 'video.desc.2', - phrase: '{length,duration} — {views,number} Views — {date,datetime}', content: { - length: video.lengthSeconds, - views: video.viewCount, - date: video.publishedAt - } - }; - - return { - url: token.url, - short: { - type: 'header', - image: {type: 'image', url: video.previewThumbnailURL, sfw: true, aspect: 16/9}, - title: video.title, - subtitle, - extra - } - }; - } - } - } -} \ No newline at end of file diff --git a/src/modules/chat/video_info.gql b/src/modules/chat/video_info.gql index 04fe9f45..55fcd5d5 100644 --- a/src/modules/chat/video_info.gql +++ b/src/modules/chat/video_info.gql @@ -2,7 +2,7 @@ query FFZ_GetVideoInfo($id: ID!) { video(id: $id) { id title - previewThumbnailURL(width: 86, height: 45) + previewThumbnailURL(width: 320, height: 180) lengthSeconds publishedAt viewCount @@ -14,6 +14,7 @@ query FFZ_GetVideoInfo($id: ID!) { id login displayName + profileImageURL(width: 50) } } } \ No newline at end of file diff --git a/src/modules/main_menu/components/chat-tester.vue b/src/modules/main_menu/components/chat-tester.vue new file mode 100644 index 00000000..b9871109 --- /dev/null +++ b/src/modules/main_menu/components/chat-tester.vue @@ -0,0 +1,699 @@ +