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