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 @@ + + + + {{ t('setting.actions.emote', 'Emote') }} + + + + + + + \ 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 @@ + + + + + {{ t('debug.chat-tester.exclusive', "Hey! This won't work here!") }} + + + + + + + {{ t('debug.chat-tester.message', 'Test Message') }} + + + + + + {{ t('setting.combo-box.custom', 'Custom') }} + + + {{ sample.name }} + + + + + + + + + + + + + + + {{ t('debug.chat-tester.replay-fix', 'Fix ID and Channel') }} + + + + + + + {{ t('debug.chat-tester.play', 'Play Message') }} + + + + + + + + + + + + + {{ t('debug.chat-tester.capture-chat', 'Capture Chat') }} + + + + + + + + + + {{ t('debug.chat-tester.ignore-privmsg', 'Ignore PRIVMSG') }} + + + + + + + + + + {{ t('debug.chat-tester.capture-pubsub', 'Capture PubSub') }} + + + + + + + {{ t('debug.chat-tester.clear-log', 'Clear Log') }} + + + + + + + {{ tTime(item.timestamp, 'HH:mm:ss') }} + + + {{ item.topic }} + + + + + @{{ key }}={{ tag }}; + + + :{{ item.user }}{{ item.prefix }} + {{ item.command }} + #{{ item.channel }} + + + {{ para }} + :{{ item.last_param }} + + + + {{ item.data }} + + + + + {{ t('debug.chat-tester.replay', 'Replay') }} + + + + + {{ t('setting.copy-json', 'Copy') }} + + + + + + + + \ No newline at end of file diff --git a/src/modules/main_menu/components/link-tester.vue b/src/modules/main_menu/components/link-tester.vue index a25ee172..4630ac63 100644 --- a/src/modules/main_menu/components/link-tester.vue +++ b/src/modules/main_menu/components/link-tester.vue @@ -366,15 +366,15 @@ export default { }, beforeDestroy() { - this.chat.off('chat:update-link-resolver', this.checkRefreshRaw, this); - this.settings.off(':changed:debug.link-resolver.source', this.changeProvider, this); - this.chat = null; - this.settings = null; - if (this.es) { this.es.close(); this.es = null; } + + this.chat.off('chat:update-link-resolver', this.checkRefreshRaw, this); + this.settings.off(':changed:debug.link-resolver.source', this.changeProvider, this); + this.chat = null; + this.settings = null; }, methods: { diff --git a/src/modules/main_menu/sample-chat-messages.json b/src/modules/main_menu/sample-chat-messages.json new file mode 100644 index 00000000..48c86f0a --- /dev/null +++ b/src/modules/main_menu/sample-chat-messages.json @@ -0,0 +1,29 @@ +[ + { + "name": "Cheer", + "data": "@bits=42069;badge-info=subscriber/69;badges=subscriber/3024,bits/100;color=#FF526F;display-name=SirStendec;id=813a1ef5-f8dd-406c-a2dd-f74d99db2799 :sirstendec!sirstendec@sirstendec.tmi.twitch.tv PRIVMSG #sirstendec :Hello. cheer42069" + }, + { + "name": "Ping", + "data": "PING :tmi.twitch.tv" + }, + { + "name": "Subscribe (Tier 1, No Message)", + "data": "@badge-info=subscriber/1;badges=subscriber/0,premium/1;color=#007ECC;display-name=Kerokai;emotes=;flags=;id=80b7174c-830b-487b-8bce-99bab02b6378;login=kerokai;mod=0;msg-id=sub;msg-param-cumulative-months=1;msg-param-months=0;msg-param-multimonth-duration=1;msg-param-multimonth-tenure=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(maxisgoatlord);msg-param-sub-plan=1000;msg-param-was-gifted=false;room-id=42490770;subscriber=1;system-msg=Kerokai\\ssubscribed\\sat\\sTier\\s1.;tmi-sent-ts=1671231043209;user-id=32357552;user-type=; :tmi.twitch.tv USERNOTICE #maximum" + }, + { + "name": "Resubscribe (Tier 1, 17 Months, Message)", + "data": "@badge-info=subscriber/17;badges=subscriber/12,moments/1;color=#2E8B57;display-name=FaustDaimos;emotes=;flags=;id=cdb378a9-4e56-4933-a78b-f4bcf2a3961a;login=faustdaimos;mod=0;msg-id=resub;msg-param-cumulative-months=17;msg-param-months=0;msg-param-multimonth-duration=0;msg-param-multimonth-tenure=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=Baka\\sBrigade!;msg-param-sub-plan=1000;msg-param-was-gifted=false;room-id=24761645;subscriber=1;system-msg=FaustDaimos\\ssubscribed\\sat\\sTier\\s1.\\sThey've\\ssubscribed\\sfor\\s17\\smonths!;tmi-sent-ts=1671231277242;user-id=37613709;user-type= :tmi.twitch.tv USERNOTICE #cirno_tv :when do we try it out" + }, + { + "name": "Mass Gift Sub (Tier 1, 5 Total)", + "data": [ + "@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=d0e4a1d9-75c7-406d-8423-cfa3dfb514b5;login=hellbirdza;mod=0;msg-id=submysterygift;msg-param-mass-gift-count=5;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-sender-count=15;msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sis\\sgifting\\s5\\sTier\\s1\\sSubs\\sto\\sAsmongold's\\scommunity!\\sThey've\\sgifted\\sa\\stotal\\sof\\s15\\sin\\sthe\\schannel!;tmi-sent-ts=1671302976346;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold", + "@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=182675e7-db1b-49d3-9650-54c31d938203;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=buddyunique1;msg-param-recipient-id=431251927;msg-param-recipient-user-name=buddyunique1;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\sbuddyunique1!;tmi-sent-ts=1671302976704;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold", + "@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=594ce86d-f956-43cd-8b5d-1b7e8499dca1;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=tartarin_e;msg-param-recipient-id=144049812;msg-param-recipient-user-name=tartarin_e;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\startarin_e!;tmi-sent-ts=1671302976722;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold", + "@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=527abc39-e599-4c1d-a480-e724a9c69823;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=haaryho_stracene_vlasy;msg-param-recipient-id=96664018;msg-param-recipient-user-name=haaryho_stracene_vlasy;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\shaaryho_stracene_vlasy!;tmi-sent-ts=1671302976759;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold", + "@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=f694b0fc-0b5e-4adf-8002-03dae340e9b5;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=corette0;msg-param-recipient-id=149790291;msg-param-recipient-user-name=corette0;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\scorette0!;tmi-sent-ts=1671302976767;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold", + "@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=5893d8a8-5eb3-46d6-9737-f1b2b76400d4;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=2;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=yo_adg;msg-param-recipient-id=465861822;msg-param-recipient-user-name=yo_adg;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\syo_adg!;tmi-sent-ts=1671302976798;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold" + ] + } +] \ No newline at end of file diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index 60dc4a52..fd902c3d 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -20,6 +20,7 @@ import SettingsMenu from './settings_menu'; import EmoteMenu from './emote_menu'; import Input from './input'; import ViewerCards from './viewer_card'; +import { isHighlightedReward } from './points'; /*const REGEX_EMOTES = { @@ -275,6 +276,13 @@ export default class ChatHook extends Module { // Settings + this.settings.addUI('debug.chat-test', { + path: 'Debugging > Chat >> Chat', + component: 'chat-tester', + getChat: () => this, + force_seen: true + }); + this.settings.add('chat.filtering.blocked-types', { default: [], type: 'array_merge', @@ -751,11 +759,15 @@ export default class ChatHook extends Module { const width = this.chat.context.get('chat.effective-width'), action_size = this.chat.context.get('chat.actions.size'), + hover_action_size = this.chat.context.get('chat.actions.hover-size'), ts_size = this.chat.context.get('chat.timestamp-size'), size = this.chat.context.get('chat.font-size'), emote_alignment = this.chat.context.get('chat.lines.emote-alignment'), lh = Math.round((20/12) * size); + const hover_action_icon = Math.round(hover_action_size * (2/3)), + hover_action_padding = hover_action_size - hover_action_icon; + let font = this.chat.context.get('chat.font-family') || 'inherit'; const [processed, unloader] = useFont(font); font = processed; @@ -774,6 +786,8 @@ export default class ChatHook extends Module { this.css_tweaks.delete('ts-size'); this.css_tweaks.setVariable('chat-actions-size', `${action_size/10}rem`); + this.css_tweaks.setVariable('chat-actions-hover-size', `${hover_action_icon/10}rem`); + this.css_tweaks.setVariable('chat-actions-hover-padding', `${hover_action_padding/20}rem`); this.css_tweaks.setVariable('chat-font-size', `${size/10}rem`); this.css_tweaks.setVariable('chat-line-height', `${lh/10}rem`); this.css_tweaks.setVariable('chat-font-family', font); @@ -899,6 +913,7 @@ export default class ChatHook extends Module { this.chat.context.on('changed:chat.effective-width', this.updateChatCSS, this); this.settings.main_context.on('changed:chat.use-width', this.updateChatCSS, this); this.chat.context.on('changed:chat.actions.size', this.updateChatCSS, this); + this.chat.context.on('changed:chat.actions.hover-size', this.updateChatCSS, this); this.chat.context.on('changed:chat.font-size', this.updateChatCSS, this); this.chat.context.on('changed:chat.timestamp-size', this.updateChatCSS, this); this.chat.context.on('changed:chat.font-family', this.updateChatCSS, this); @@ -1332,6 +1347,7 @@ export default class ChatHook extends Module { type: this.chat_types.Message, ffz_type: 'points', ffz_reward: reward, + ffz_reward_highlight: isHighlightedReward(reward), messageParts: [], user: { id: data.user.id, @@ -2465,6 +2481,7 @@ export default class ChatHook extends Module { out.ffz_type = 'points'; out.ffz_reward = reward; + out.ffz_reward_highlight = isHighlightedReward(reward); return i.postMessageToCurrentChannel(e, out); } diff --git a/src/sites/twitch-twilight/modules/chat/input.jsx b/src/sites/twitch-twilight/modules/chat/input.jsx index eea54d8a..0acde24f 100644 --- a/src/sites/twitch-twilight/modules/chat/input.jsx +++ b/src/sites/twitch-twilight/modules/chat/input.jsx @@ -722,6 +722,7 @@ export default class Input extends Module { return {emotes: [], length: 0}; const out = [], + seen = new Set, anim = this.chat.context.get('chat.emotes.animated') > 0, hidden_sets = this.settings.provider.get('emote-menu.hidden-sets'), has_hidden = Array.isArray(hidden_sets) && hidden_sets.length > 0, @@ -760,9 +761,11 @@ export default class Input extends Module { const id = emote.id, token = KNOWN_CODES[emote.token] || emote.token; - if ( ! token ) + if ( ! token || seen.has(token) ) continue; + seen.add(token); + const replacement = REPLACEMENTS[id]; let srcSet; diff --git a/src/sites/twitch-twilight/modules/chat/line.js b/src/sites/twitch-twilight/modules/chat/line.js index 6bc197eb..45ec5d65 100644 --- a/src/sites/twitch-twilight/modules/chat/line.js +++ b/src/sites/twitch-twilight/modules/chat/line.js @@ -8,7 +8,7 @@ import Twilight from 'site'; import Module from 'utilities/module'; import RichContent from './rich_content'; -import { has } from 'utilities/object'; +import { has, maybe_call } from 'utilities/object'; import { KEYS, RERENDER_SETTINGS, UPDATE_BADGE_SETTINGS, UPDATE_TOKEN_SETTINGS } from 'utilities/constants'; import { print_duration } from 'utilities/time'; import { FFZEvent } from 'utilities/events'; @@ -37,113 +37,285 @@ export default class ChatLine extends Module { this.inject('chat.actions'); this.inject('chat.overrides'); - /*this.line_types = {}; + this.line_types = {}; - this.line_types.sub_mystery = (msg, u, r, inst, e) => { - const mystery = msg.mystery; - if ( mystery ) - mystery.line = this; + this.line_types.cheer = { + renderNotice: (msg, current_user, room, inst, e) => { + return this.i18n.tList( + 'chat.bits-message', + 'Cheered {count, plural, one {# Bit} other {# Bits}}', + { + count: msg.bits || 0 + } + ); + } + }; - const sub_msg = this.i18n.tList('chat.sub.gift', "{user} is gifting {count, plural, one {# Tier {tier} Sub} other {# Tier {tier} Subs}} to {channel}'s community! ", { - user: (msg.sub_anon || msg.user.username === 'ananonymousgifter') ? - this.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') : - e('span', { + this.line_types.points = { + getClass: (msg) => { + const highlight = msg.ffz_reward_highlight && this.chat.context.get('chat.points.allow-highlight') === 2; + + return `ffz--points-line tw-pd-l-1 tw-pd-r-2 ${highlight ? 'ffz-custom-color ffz--points-highlight' : ''}`; + }, + + renderNotice: (msg, current_user, room, inst, e) => { + if ( ! msg.ffz_reward ) + return null; + + // We need to get the message's tokens to see if it has a message or not. + const user = msg.user, + tokens = msg.ffz_tokens = msg.ffz_tokens || this.chat.tokenizeMessage(msg, current_user), + has_message = tokens.length > 0; + + // Elements for the reward and cost with nice formatting. + const reward = e('span', {className: 'ffz--points-reward'}, getRewardTitle(msg.ffz_reward, this.i18n)), + cost = e('span', {className: 'ffz--points-cost'}, [ + e('span', {className: 'ffz--points-icon'}), + this.i18n.formatNumber(getRewardCost(msg.ffz_reward)) + ]); + + if (! has_message) + return this.i18n.tList('chat.points.user-redeemed', '{user} redeemed {reward} {cost}', { + reward, cost, + user: e('span', { + role: 'button', + className: 'chatter-name', + onClick: inst.ffz_user_click_handler, + onContextMenu: this.actions.handleUserContext + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, user.displayName)) + }); + + return this.i18n.tList('chat.points.redeemed', 'Redeemed {reward} {cost}', {reward, cost}); + } + }; + + this.line_types.resub = { + getClass: () => `ffz--subscribe-line tw-pd-r-2`, + + renderNotice: (msg, current_user, room, inst, e) => { + const months = msg.sub_cumulative || msg.sub_months, + setting = this.chat.context.get('chat.subs.show'); + + if ( !(setting === 3 || (setting === 1 && out && months > 1) || (setting === 2 && months > 1)) ) + return null; + + const user = msg.user, + plan = msg.sub_plan || {}, + tier = SUB_TIERS[plan.plan] || 1; + + const sub_msg = this.i18n.tList('chat.sub.main', '{user} subscribed {plan}. ', { + user: e('span', { role: 'button', className: 'chatter-name', - onClick: inst.ffz_user_click_handler + onClick: inst.ffz_user_click_handler, + onContextMenu: this.actions.handleUserContext }, e('span', { className: 'tw-c-text-base tw-strong' - }, msg.user.displayName)), - count: msg.sub_count, - tier: SUB_TIERS[msg.sub_plan] || 1, - channel: msg.roomLogin - }); + }, user.displayName)), + plan: plan.prime ? + this.i18n.t('chat.sub.twitch-prime', 'with Prime Gaming') : + this.i18n.t('chat.sub.plan', 'at Tier {tier}', {tier}) + }); - if ( msg.sub_total === 1 ) - sub_msg.push(this.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!")); - else if ( msg.sub_total > 1 ) - sub_msg.push(this.i18n.t('chat.sub.gift-total', "They've gifted {count} Subs in the channel!", { - count: msg.sub_total - })); + if ( msg.sub_share_streak && msg.sub_streak > 1 ) { + sub_msg.push(this.i18n.t( + 'chat.sub.cumulative-months', + "They've subscribed for {cumulative,number} months, currently on a {streak,number} month streak!", + { + cumulative: msg.sub_cumulative, + streak: msg.sub_streak + } + )); - if ( ! inst.ffz_click_expand ) - inst.ffz_click_expand = () => { - inst.setState({ - ffz_expanded: ! inst.state.ffz_expanded + } else if ( months > 1 ) { + sub_msg.push(this.i18n.t( + 'chat.sub.months', + "They've subscribed for {count,number} months!", + { + count: months + } + )); + } + + if ( ! this.chat.context.get('chat.subs.compact') ) + sub_msg.ffz_icon = e('span', { + className: `ffz-i-${plan.prime ? 'crown' : 'star'} tw-mg-r-05` + }); + + return sub_msg; + } + }; + + this.line_types.ritual = { + getClass: () => `ffz--ritual-line tw-pd-r-2`, + + renderNotice: (msg, current_user, room, inst, e) => { + const user = msg.user; + + if ( msg.ritual === 'new_chatter' ) { + return this.i18n.tList('chat.ritual', '{user} is new here. Say hello!', { + user: e('span', { + role: 'button', + className: 'chatter-name', + onClick: inst.ffz_user_click_handler, + onContextMenu: this.actions.handleUserContext + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, user.displayName)) }); } + } + }; - const expanded = this.chat.context.get('chat.subs.merge-gifts-visibility') ? - ! inst.state.ffz_expanded : inst.state.ffz_expanded; + this.line_types.sub_gift = { + getClass: () => 'ffz--subscribe-line', - let sub_list = null; - if( expanded && mystery && mystery.recipients && mystery.recipients.length > 0 ) { - const the_list = []; - for(const x of mystery.recipients) { - if ( the_list.length ) - the_list.push(', '); + renderNotice: (msg, current_user, room, inst, e) => { + const user = msg.user, - the_list.push(e('span', { + plan = msg.sub_plan || {}, + months = msg.sub_months || 1, + tier = SUB_TIERS[plan.plan] || 1; + + let sub_msg; + + const bits = { + months, + user: (msg.sub_anon || user.username === 'ananonymousgifter') ? + this.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') : + e('span', { + role: 'button', + className: 'chatter-name', + onClick: inst.ffz_user_click_handler, + onContextMenu: this.actions.handleUserContext + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, user.displayName)), + plan: plan.plan === 'custom' ? '' : + this.i18n.t('chat.sub.gift-plan', 'Tier {tier}', {tier}), + recipient: e('span', { role: 'button', - className: 'ffz--giftee-name', + className: 'chatter-name', onClick: inst.ffz_user_click_handler, - 'data-user': JSON.stringify(x) + 'data-user': JSON.stringify(msg.sub_recipient) }, e('span', { className: 'tw-c-text-base tw-strong' - }, x.displayName))); + }, msg.sub_recipient.displayName)) + }; + + if ( months <= 1 ) + sub_msg = this.i18n.tList('chat.sub.mystery', '{user} gifted a {plan} Sub to {recipient}! ', bits); + else + sub_msg = this.i18n.tList('chat.sub.gift-months', '{user} gifted {months, plural, one {# month} other {# months}} of {plan} Sub to {recipient}!', bits); + + if ( msg.sub_total === 1 ) + sub_msg.push(this.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!")); + else if ( msg.sub_total > 1 ) + sub_msg.push(this.i18n.t('chat.sub.gift-total', "They've gifted {count,number} Subs in the channel!", { + count: msg.sub_total + })); + + if ( ! this.chat.context.get('chat.subs.compact') ) + sub_msg.ffz_icon = e('span', { + className: `ffz-i-${plan.prime ? 'crown' : 'star'} tw-mg-r-05` + }); + + return sub_msg; + } + } + + this.line_types.sub_mystery = { + + getClass: () => 'ffz--subscribe-line', + + renderNotice: (msg, user, room, inst, e) => { + const mystery = msg.mystery; + if ( mystery ) + mystery.line = inst; + + const sub_msg = this.i18n.tList('chat.sub.gift', "{user} is gifting {count, plural, one {# Tier {tier} Sub} other {# Tier {tier} Subs}} to {channel}'s community! ", { + user: (msg.sub_anon || msg.user.username === 'ananonymousgifter') ? + this.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') : + e('span', { + role: 'button', + className: 'chatter-name', + onClick: inst.ffz_user_click_handler, + onContextMenu: this.actions.handleUserContext + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, msg.user.displayName)), + count: msg.sub_count, + tier: SUB_TIERS[msg.sub_plan] || 1, + channel: msg.roomLogin + }); + + if ( msg.sub_total === 1 ) + sub_msg.push(this.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!")); + else if ( msg.sub_total > 1 ) + sub_msg.push(this.i18n.t('chat.sub.gift-total', "They've gifted {count} Subs in the channel!", { + count: msg.sub_total + })); + + if ( ! inst.ffz_click_expand ) + inst.ffz_click_expand = () => { + inst.setState({ + ffz_expanded: ! inst.state.ffz_expanded + }); + } + + const expanded = this.chat.context.get('chat.subs.merge-gifts-visibility') ? + ! inst.state.ffz_expanded : inst.state.ffz_expanded; + + let sub_list = null; + if( expanded && mystery && mystery.recipients && mystery.recipients.length > 0 ) { + const the_list = []; + for(const x of mystery.recipients) { + if ( the_list.length ) + the_list.push(', '); + + the_list.push(e('span', { + role: 'button', + className: 'ffz--giftee-name', + onClick: inst.ffz_user_click_handler, + 'data-user': JSON.stringify(x) + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, x.displayName))); + } + + sub_list = e('div', { + className: 'tw-mg-t-05 tw-border-t tw-pd-t-05 tw-c-text-alt-2' + }, the_list); } - sub_list = e('div', { - className: 'tw-mg-t-05 tw-border-t tw-pd-t-05 tw-c-text-alt-2' - }, the_list); - } + const target = [ + sub_msg + ]; - const extra_ts = this.chat.context.get('chat.extra-timestamps'); + if ( mystery ) + target.push(e('span', { + className: `tw-pd-l-05 tw-font-size-4 ffz-i-${expanded ? 'down' : 'right'}-dir` + })); - return inst.ffzDrawLine( - msg, - `ffz-notice-line user-notice-line tw-pd-y-05 ffz--subscribe-line`, - [ + const out = [ e('div', { - className: 'tw-flex tw-c-text-alt-2', + className: 'tw-full-width tw-c-text-alt-2', onClick: inst.ffz_click_expand - }, [ - this.chat.context.get('chat.subs.compact') ? null : - e('figure', { - className: `ffz-i-star${msg.sub_anon ? '-empty' : ''} tw-mg-r-05` - }), - e('div', null, [ - extra_ts && (inst.props.showTimestamps || inst.props.isHistorical) && e('span', { - className: 'chat-line__timestamp' - }, this.chat.formatTime(msg.timestamp)), - msg.sub_anon ? null : this.actions.renderInline(msg, inst.props.showModerationIcons, u, r, e), - sub_msg - ]), - mystery ? e('div', { - className: 'tw-pd-l-05 tw-font-size-4' - }, e('figure', { - className: `ffz-i-${expanded ? 'down' : 'right'}-dir tw-pd-y-1` - })) : null - ]), + }, target), sub_list - ], - [ - e('button', { - className: 'tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-button-icon ffz-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip ffz-tooltip--no-mouse', - 'data-title': 'Test' - }, e('span', { - className: 'tw-button-icon__icon' - }, e('figure', {className: 'ffz-i-threads'}))), - e('button', { - className: 'tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-button-icon ffz-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip ffz-tooltip--no-mouse', - 'data-title': 'Thing' - }, e('span', { - className: 'tw-button-icon__icon' - }, e('figure', {className: 'ffz-i-cog'}))) - ], - null - ); - }*/ + ]; + + if ( ! this.chat.context.get('chat.subs.compact') ) + out.ffz_icon = e('span', { + className: `ffz-i-star${msg.sub_anon ? '-empty' : ''} tw-mg-r-05` + }); + + out.ffz_target = target; + return out; + } + }; this.ChatLine = this.fine.define( 'chat-line', @@ -172,6 +344,21 @@ export default class ChatLine extends Module { this.on('chat:update-line-badges', this.updateLineBadges, this); this.on('i18n:update', this.rerenderLines, this); + this.on('experiments:changed:line_renderer', () => { + const value = this.experiments.get('line_renderer'), + cls = this.ChatLine._class; + + this.log.debug('Changing line renderer:', value ? 'new' : 'old'); + + if (cls) { + cls.prototype.render = this.experiments.get('line_renderer') + ? cls.prototype.ffzNewRender + : cls.prototype.ffzOldRender; + + this.rerenderLines(); + } + }); + for(const setting of RERENDER_SETTINGS) this.chat.context.on(`changed:${setting}`, this.rerenderLines, this); @@ -441,43 +628,40 @@ export default class ChatLine extends Module { ]); } - cls.prototype.ffzDrawLine = function(msg, cls, out, hover_actions, bg_css) { - const anim_hover = t.chat.context.get('chat.emotes.animated') === 2; - - if (hover_actions) { - cls = `${cls} tw-relative`; - out = [ - e('div', { - className: 'chat-line__message-highlight tw-absolute tw-border-radius-medium tw-top-0 tw-bottom-0 tw-right-0 tw-left-0', - 'data-test-selector': 'chat-message-highlight' - }), - e('div', { - className: 'chat-line__message-container tw-relative' - }, out), - e('div', { - className: 'chat-line__reply-icon tw-absolute tw-border-radius-medium tw-c-background-base tw-elevation-1' - }, hover_actions) - ]; - } - - return e('div', { - className: `${cls}${msg.mentioned ? ' ffz-mentioned' : ''}${bg_css ? ' ffz-custom-color' : ''}`, - style: {backgroundColor: bg_css}, - 'data-room-id': msg.roomId, - 'data-room': msg.roomLogin, - 'data-user-id': msg.user.userID, - 'data-user': msg.user.userLogin && msg.user.userLogin.toLowerCase(), - onMouseOver: anim_hover ? t.chat.emotes.animHover : null, - onMouseOut: anim_hover ? t.chat.emotes.animLeave : null - }, out); - } - - /*cls.prototype.new_render = function() { try { + cls.prototype.ffzNewRender = function() { try { this._ffz_no_scan = true; const msg = t.chat.standardizeMessage(this.props.message), - reply_mode = t.chat.context.get('chat.replies.style'); + override_mode = t.chat.context.get('chat.filtering.display-deleted'); + // Before anything else, check to see if the deleted message view is set + // to BRIEF and the message is deleted. In that case we can exit very + // early. + let mod_mode = this.props.deletedMessageDisplay; + if ( override_mode ) + mod_mode = override_mode; + else if ( ! this.props.isCurrentUserModerator && mod_mode === 'DETAILED' ) + mod_mode = 'LEGACY'; + + if ( mod_mode === 'BRIEF' && msg.deleted ) { + const deleted_count = this.props.deletedCount; + if ( deleted_count == null ) + return null; + + return e( + 'div', { + className: 'chat-line__status' + }, + t.i18n.t('chat.deleted-messages', `{count,plural, +one {One message was deleted by a moderator.} +other {# messages were deleted by a moderator.} +}`, { + count: deleted_count + }) + ); + } + + // Get the current room id and login. We might need to look these up. let room = msg.roomLogin ? msg.roomLogin : msg.channel ? msg.channel.slice(1) : undefined, room_id = msg.roomId ? msg.roomId : this.props.channelID; @@ -493,21 +677,23 @@ export default class ChatLine extends Module { room_id = msg.roomId = r.id; } - const u = t.site.getUser(), - r = {id: room_id, login: room}; + // Construct the current room and current user objects. + const current_user = t.site.getUser(), + current_room = {id: room_id, login: room}; - const has_replies = this.props && !!(this.props.hasReply || this.props.reply || ! this.props.replyRestrictedReason), + const reply_mode = t.chat.context.get('chat.replies.style'), + has_replies = this.props && !!(this.props.hasReply || this.props.reply || ! this.props.replyRestrictedReason), can_replies = has_replies && msg.message && ! msg.deleted && ! this.props.disableReplyClick, - can_reply = can_replies && u && u.login !== msg.user?.login && ! msg.reply, - twitch_clickable = reply_mode === 1 && can_replies && (!!msg.reply || can_reply); + can_reply = can_replies && (has_replies || (current_user && current_user.login !== msg.user?.login)); - if ( u ) { - u.moderator = this.props.isCurrentUserModerator; - u.staff = this.props.isCurrentUserStaff; - u.reply_mode = reply_mode; - u.can_reply = can_reply; + if ( current_user ) { + current_user.moderator = this.props.isCurrentUserModerator; + current_user.staff = this.props.isCurrentUserStaff; + current_user.reply_mode = reply_mode; + current_user.can_reply = can_reply; } + // Set up our click handlers as necessary. if ( ! this.ffz_open_reply ) this.ffz_open_reply = this.ffzOpenReply.bind(this); @@ -524,7 +710,7 @@ export default class ChatLine extends Module { if ( ds && ds.user ) { try { target_user = JSON.parse(ds.user); - } catch(err) { /* nothing~! * / } + } catch(err) { /* nothing~! */ } } const fe = new FFZEvent({ @@ -532,7 +718,7 @@ export default class ChatLine extends Module { event, message: msg, user: target_user, - room: r + room: current_room }); t.emit('chat:user-click', fe); @@ -546,11 +732,316 @@ export default class ChatLine extends Module { this.ffz_user_click_handler = this.openViewerCard || this.usernameClickHandler; //event => event.ctrlKey ? this.usernameClickHandler(event) : t.viewer_cards.openCard(r, user, event); } - // Do we have a special renderer? - if ( msg.ffz_type && t.line_types[msg.ffz_type] ) - return t.line_types[msg.ffz_type](msg, u, r, this, e); + let notice; + let klass; + let bg_css = null; - return this.ffz_old_render(); + // Do we have a special renderer? + let type = msg.ffz_type && t.line_types[msg.ffz_type]; + if ( ! type && msg.bits > 0 && t.chat.context.get('chat.bits.cheer-notice') ) + type = t.line_types.cheer; + + if ( type ) { + if ( type.render ) + return type.render(msg, current_user, current_room, this, e); + + if ( type.renderNotice ) + notice = type.renderNotice(msg, current_user, current_room, this, e); + + if ( type.getClass ) + klass = type.getClass(msg, current_user, current_room, this, e); + } + + // Render the line. + const user = msg.user, + anim_hover = t.chat.context.get('chat.emotes.animated') === 2; + + // Cache the lower login + if ( user && ! user.lowerLogin && user.userLogin ) + user.lowerLogin = user.userLogin.toLowerCase(); + + // Ensure we have a string for klass. + klass = klass || ''; + + // RENDERING: Start~ + + // First, check how we should handle a deleted message. + let show; + let deleted; + let mod_action = null; + + if ( mod_mode === 'BRIEF' ) { + // We already handle msg.deleted for BRIEF earlier than this. + show = true; + deleted = false; + + } else if ( mod_mode === 'DETAILED' ) { + show = true; + deleted = msg.deleted; + + } else { + show = this.state?.alwaysShowMessage || ! msg.deleted; + deleted = false; + } + + if ( msg.deleted ) { + const show_mode = t.chat.context.get('chat.filtering.display-mod-action'); + if ( show_mode === 2 || (show_mode === 1 && mod_mode === 'DETAILED') ) { + const action = msg.modActionType; + if ( action === 'timeout' ) + mod_action = t.i18n.t('chat.mod-action.timeout', + '{duration} Timeout' + , { + duration: print_duration(msg.duration || 1) + }); + else if ( action === 'ban' ) + mod_action = t.i18n.t('chat.mod-action.ban', 'Banned'); + else if ( action === 'delete' || ! action ) + mod_action = t.i18n.t('chat.mod-action.delete', 'Deleted'); + + if ( mod_action && msg.modLogin ) + mod_action = t.i18n.t('chat.mod-action.by', '{action} by {login}', { + login: msg.modLogin, + action: mod_action + }); + + if ( mod_action ) + mod_action = e('span', { + className: 'tw-pd-l-05', + 'data-test-selector': 'chat-deleted-message-attribution' + }, `(${mod_action})`); + } + } + + // Check to see if we have message content to render. + const tokens = msg.ffz_tokens = msg.ffz_tokens || t.chat.tokenizeMessage(msg, current_user), + has_message = tokens.length > 0 || ! notice; + + let message; + + if ( has_message ) { + // Let's calculate some remaining values that we need. + const reply_tokens = (reply_mode === 2 || (reply_mode === 1 && this.props.repliesAppearancePreference && this.props.repliesAppearancePreference !== 'expanded')) + ? (msg.ffz_reply = msg.ffz_reply || t.chat.tokenizeReply(this.props.reply)) + : null; + + const is_action = t.parent.message_types && t.parent.message_types.Action === msg.messageType, + action_style = is_action ? t.chat.context.get('chat.me-style') : 0, + action_italic = action_style >= 2, + action_color = action_style === 1 || action_style === 3; + + const raw_color = t.overrides.getColor(user.id) || user.color, + color = t.parent.colors.process(raw_color); + + const rich_content = show && FFZRichContent && t.chat.pluckRichContent(tokens, msg); + + // First, render the user block. + const username = t.chat.formatUser(user, e), + override_name = t.overrides.getName(user.id); + + const user_props = { + className: `chat-line__username notranslate${override_name ? ' ffz--name-override tw-relative ffz-il-tooltip__container' : ''} ${msg.ffz_user_class ?? ''}`, + role: 'button', + style: { color }, + onClick: this.ffz_user_click_handler, + onContextMenu: t.actions.handleUserContext + }; + + if ( msg.ffz_user_props ) + Object.assign(user_props, msg.ffz_user_props); + + if ( msg.ffz_user_style ) + Object.assign(user_props.style, msg.ffz_user_style); + + const user_block = e( + 'span', + user_props, + override_name + ? [ + e('span', { + className: 'chat-author__display-name' + }, override_name), + e('div', { + className: 'ffz-il-tooltip ffz-il-tooldip--down ffz-il-tooltip--align-center' + }, username) + ] + : username + ); + + // The timestamp. + const timestamp = (this.props.showTimestamps || this.props.isHistorical) + ? e('span', { className: 'chat-line__timestamp' }, t.chat.formatTime(msg.timestamp)) + : null; + + // The reply token for FFZ style. + const reply_token = show && has_replies && reply_tokens + ? t.chat.renderTokens(reply_tokens, e) + : null; + + // Check for a Twitch-style points highlight. + const twitch_highlight = msg.ffz_reward_highlight && t.chat.context.get('chat.points.allow-highlight') === 1; + + // The reply element for Twitch style. + const twitch_reply = reply_mode === 1 && this.props.reply && this.props.repliesAppearancePreference && this.props.repliesAppearancePreference === 'expanded' + ? this.renderReplyLine() + : null; + + // Now assemble the pieces. + message = [ + twitch_reply, + + // The preamble + timestamp, + t.actions.renderInline(msg, this.props.showModerationIcons, current_user, current_room, e, this), + this.renderInlineHighlight ? this.renderInlineHighlight() : null, + + // Badges + e('span', { + className: 'chat-line__message--badges' + }, t.chat.badges.render(msg, e)), + + // User + user_block, + + // The separator + e('span', {'aria-hidden': true}, is_action ? ' ' : ': '), + + // Reply Token + reply_token, + + // Message + show + ? e( + 'span', + { + className: `message ${action_italic ? 'chat-line__message-body--italicized' : ''} ${twitch_highlight ? 'chat-line__message-body--highlighted' : ''}`, + style: action_color ? { color} : null + }, + t.chat.renderTokens( + tokens, e, (reply_mode !== 0 && has_replies) ? this.props.reply : null + ) + ) + : e( + 'span', + { + className: 'chat-line__message--deleted' + }, + e('a', { + href: '', + onClick: this.alwaysShowMessage + }, t.i18n.t('chat.message-deleted', '')) + ), + + // Moderation Action + mod_action, + + // Rich Content + rich_content + ? e(FFZRichContent, rich_content) + : null + ]; + } + + // Is there a notice? + let out; + + if ( notice ) { + const is_raw = Array.isArray(notice.ffz_target); + + if ( ! message ) { + const want_ts = t.chat.context.get('chat.extra-timestamps'), + timestamp = want_ts && (this.props.showTimestamps || this.props.isHistorical) + ? e('span', { className: 'chat-line__timestamp' }, t.chat.formatTime(msg.timestamp)) + : null; + + const actions = t.actions.renderInline(msg, this.props.showModerationIcons, current_user, current_room, e, this); + + if ( is_raw ) + notice.ffz_target.unshift(notice.ffz_icon ?? null, timestamp, actions); + + else + notice = [ + notice.ffz_icon ?? null, + timestamp, + actions, + notice + ]; + + } else { + if ( notice.ffz_icon ) + notice = [ + notice.ffz_icon, + notice + ]; + + message = e( + 'div', + { + className: 'chat-line--inline chat-line__message', + 'data-room-id': msg.roomId ?? current_room.id, + 'data-room': msg.roomLogin, + 'data-user-id': user?.userID, + 'data-user': user?.lowerLogin, + }, + message + ); + } + + klass = `${klass} ffz-notice-line user-notice-line tw-pd-y-05`; + + if ( ! is_raw ) + notice = e('div', { + className: 'tw-c-text-alt-2' + }, notice); + + if ( message ) + out = [notice, message]; + else + out = notice; + + } else { + klass = `${klass} chat-line__message`; + out = message; + } + + // Check for hover actions, as those require we wrap the output in a few extra elements. + const hover_actions = (user && msg.id) + ? t.actions.renderHover(msg, this.props.showModerationIcons, current_user, current_room, e, this) + : null; + + if ( hover_actions ) { + klass = `${klass} tw-relative`; + + out = [ + e('div', { + className: 'chat-line__message-highlight tw-absolute tw-border-radius-medium tw-top-0 tw-bottom-0 tw-right-0 tw-left-0', + 'data-test-selector': 'chat-message-highlight' + }), + e('div', { + className: 'chat-line__message-container tw-relative' + }, out), + hover_actions + ]; + } + + // If we don't have an override background color, try to assign + // a value based on the mention. + if (bg_css == null) + bg_css = msg.mentioned && msg.mention_color + ? t.parent.inverse_colors.process(msg.mention_color) + : null; + + // Now, return the final chat line color. + return e('div', { + className: `${klass}${deleted ? ' ffz--deleted-message' : ''}${msg.mentioned ? ' ffz-mentioned' : ''}${bg_css ? ' ffz-custom-color' : ''}`, + style: {backgroundColor: bg_css}, + 'data-room-id': msg.roomId ?? current_room.id, + 'data-room': msg.roomLogin, + 'data-user-id': user?.userID, + 'data-user': user?.lowerLogin, + onMouseOver: anim_hover ? t.chat.emotes.animHover : null, + onMouseOut: anim_hover ? t.chat.emotes.animLeave : null + }, out); } catch(err) { t.log.error(err); @@ -573,9 +1064,9 @@ export default class ChatLine extends Module { return 'An error occurred rendering this chat line.'; } - } };*/ + } }; - cls.prototype.render = function() { try { + cls.prototype.ffzOldRender = function() { try { this._ffz_no_scan = true; const types = t.parent.message_types || {}, @@ -1123,15 +1614,6 @@ other {# messages were deleted by a moderator.} return null; if ( twitch_clickable ) { - let icon, title; - if ( can_reply ) { - icon = e('figure', {className: 'ffz-i-reply'}); - title = t.i18n.t('chat.actions.reply', 'Reply to Message'); - } else { - icon = e('figure', {className: 'ffz-i-threads'}); - title = t.i18n.t('chat.actions.reply.thread', 'Open Thread'); - } - out = [ e('div', { className: 'chat-line__message-highlight tw-absolute tw-border-radius-medium tw-top-0 tw-bottom-0 tw-right-0 tw-left-0', @@ -1182,6 +1664,10 @@ other {# messages were deleted by a moderator.} } } } + cls.prototype.render = this.experiments.get('line_renderer') + ? cls.prototype.ffzNewRender + : cls.prototype.ffzOldRender; + // Do this after a short delay to hopefully reduce the chance of React // freaking out on us. setTimeout(() => this.ChatLine.forceUpdate()); diff --git a/src/sites/twitch-twilight/styles/chat.scss b/src/sites/twitch-twilight/styles/chat.scss index 303e4cfe..613bda88 100644 --- a/src/sites/twitch-twilight/styles/chat.scss +++ b/src/sites/twitch-twilight/styles/chat.scss @@ -460,8 +460,10 @@ top: -1rem; display: none; - --ffz-chat-actions-size: 2rem; + --ffz-chat-actions-size: var(--ffz-chat-actions-hover-size); + --ffz-chat-actions-padding: var(--ffz-chat-actions-hover-padding); + .ffz-notice-line:hover &, .chat-line__message:hover & { display: block; } @@ -477,12 +479,16 @@ background-color: var(--color-background-base); border-radius: var(--border-radius-medium); + figure { + line-height: 1em; + } + &.ffz-has-modifier { display: none; } & > .ffz-mod-icon { - padding: 0.5rem; + padding: var(--ffz-chat-actions-padding); background-color: var(--color-background-button-text-default); color: var(--color-fill-button-icon); diff --git a/src/std-components/emote-picker.vue b/src/std-components/emote-picker.vue new file mode 100644 index 00000000..c6f38e19 --- /dev/null +++ b/src/std-components/emote-picker.vue @@ -0,0 +1,259 @@ + + + + {{ t('setting.emote.search', 'Search for Emote') }} + + + + + + + + + + + {{ t('setting.icon.clear', 'Clear') }} + + + + + + + + + + + + + + + + {{ t('setting.emote.none', 'unable to load emote data') }} + + {{ t('setting.emote.none-about', 'Please make sure you have the FFZ Emote Menu enabled, and that you use this from a page that loads chat.') }} + + + + {{ t('setting.actions.empty-search', 'no results') }} + + + + + + + + \ No newline at end of file diff --git a/src/utilities/logging.js b/src/utilities/logging.js index da0a3aed..e95a6dc2 100644 --- a/src/utilities/logging.js +++ b/src/utilities/logging.js @@ -69,26 +69,50 @@ export class Logger { return this.invoke(Logger.VERBOSE, args); } + verboseColor(msg, colors, ...args) { + return this.invokeColor(Logger.VERBOSE, msg, colors, args); + } + debug(...args) { return this.invoke(Logger.DEBUG, args); } + debugColor(msg, colors, ...args) { + return this.invokeColor(Logger.DEBUG, msg, colors, args); + } + info(...args) { return this.invoke(Logger.INFO, args); } + infoColor(msg, colors, ...args) { + return this.invokeColor(Logger.INFO, msg, colors, args); + } + warn(...args) { return this.invoke(Logger.WARN, args); } + warnColor(msg, colors, ...args) { + return this.invokeColor(Logger.WARN, msg, colors, args); + } + warning(...args) { return this.invoke(Logger.WARN, args); } + warningColor(msg, colors, ...args) { + return this.invokeColor(Logger.WARN, msg, colors, args); + } + error(...args) { return this.invoke(Logger.ERROR, args); } + errorColor(msg, colors, ...args) { + return this.invokeColor(Logger.ERROR, msg, colors, args); + } + crumb(...args) { if ( this.raven ) return this.raven.captureBreadcrumb(...args); @@ -107,6 +131,62 @@ export class Logger { return this.error(...args); } + invokeColor(level, msg, colors, args) { + if ( ! this.enabled || level < this.level ) + return; + + if ( ! Array.isArray(colors) ) + colors = [colors]; + + const message = args ? Array.prototype.slice.call(args) : []; + + if ( level !== Logger.VERBOSE ) { + const out = msg.replace(/%c/g, '') + ' ' + message.join(' '); + + if ( this.root.init ) + this.root.captured_init.push({ + time: Date.now(), + category: this.name, + message: out, + level: RAVEN_LEVELS[level] || level + }); + + this.crumb({ + message: out, + category: this.name, + level: RAVEN_LEVELS[level] || level + }); + } + + message.unshift(msg); + + if ( this.name ) { + message[0] = `%c${this.root.label} [%c${this.name}%c]:%c ${message[0]}`; + colors.unshift('color:#755000; font-weight:bold', '', 'color:#755000; font-weight:bold', ''); + + } else { + message[0] = `%c${this.root.label}:%c ${message[0]}`; + colors.unshift('color:#755000; font-weight:bold', ''); + } + + message.splice(1, 0, ...colors); + + if ( level === Logger.DEBUG || level === Logger.VERBOSE ) + console.debug(...message); + + else if ( level === Logger.INFO ) + console.info(...message); + + else if ( level === Logger.WARN ) + console.warn(...message); + + else if ( level === Logger.ERROR ) + console.error(...message); + + else + console.log(...message); + } + /* eslint no-console: "off" */ invoke(level, args) { if ( ! this.enabled || level < this.level ) diff --git a/styles/icons.scss b/styles/icons.scss index 7418edf9..9ae31d97 100644 --- a/styles/icons.scss +++ b/styles/icons.scss @@ -43,7 +43,7 @@ /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ } -.ffz-mod-icon .ffz-i-reply { +.ffz--inline-actions .ffz-mod-icon .ffz-i-reply { font-size: 0.8em; } diff --git a/styles/widgets.scss b/styles/widgets.scss index 3d2d0760..2e4f8542 100644 --- a/styles/widgets.scss +++ b/styles/widgets.scss @@ -13,6 +13,8 @@ @import "./widgets/color-picker.scss"; @import "./widgets/icon-picker.scss"; +@import "./widgets/chat-tester.scss"; + @import "./widgets/check-box.scss"; .tw-display-inline { display: inline !important } diff --git a/styles/widgets/icon-picker.scss b/styles/widgets/icon-picker.scss index 5c87746f..146e0975 100644 --- a/styles/widgets/icon-picker.scss +++ b/styles/widgets/icon-picker.scss @@ -1,3 +1,4 @@ +.ffz--emote-picker__list, .ffz--icon-picker__list { max-height: 15rem; font-size: 1.6rem; @@ -9,4 +10,12 @@ pointer-events: none; } } +} + +.ffz--emote-picker { + .ffz-preview-emote { + width: 2rem; + height: 2rem; + object-fit: contain; + } } \ No newline at end of file