diff --git a/package.json b/package.json index 1463ef2d..4e6b8c75 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frankerfacez", "author": "Dan Salvato LLC", - "version": "4.20.20", + "version": "4.20.21", "description": "FrankerFaceZ is a Twitch enhancement suite.", "license": "Apache-2.0", "scripts": { diff --git a/src/modules/chat/components/chat-rich.vue b/src/modules/chat/components/chat-rich.vue index 64a5fb48..eee31bc3 100644 --- a/src/modules/chat/components/chat-rich.vue +++ b/src/modules/chat/components/chat-rich.vue @@ -6,13 +6,12 @@ import {ALLOWED_ATTRIBUTES, ALLOWED_TAGS} from 'utilities/constants'; const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0'; export default { - props: ['data', 'url'], + props: ['data', 'url', 'events'], data() { return { loaded: false, - error: false, - html: null, + error: null, title: this.t('card.loading', 'Loading...'), title_tokens: null, desc_1: null, @@ -26,58 +25,96 @@ export default { } }, - async mounted() { - let data; - try { - data = this.data.getData(); - if ( data instanceof Promise ) { - const to_wait = has(this.data, 'timeout') ? this.data.timeout : 1000; - if ( to_wait ) - data = await timeout(data, to_wait); - else - data = await data; - } + watch: { + data() { + this.reset(); + this.load(); + } + }, - if ( ! data ) - data = { - error: true, - title: this.t('card.error', 'An error occured.'), - desc_1: this.t('card.empty', 'No data was returned.') - } - } catch(err) { - data = { - error: true, - title: this.t('card.error', 'An error occured.'), - desc_1: String(err) - } + created() { + if ( this.events ) { + this._events = this.events; + this._events.on('chat:update-link-resolver', this.checkRefresh, this); } - this.loaded = true; - this.error = data.error; - this.html = data.html; - this.title = data.title; - this.title_tokens = data.title_tokens; - this.desc_1 = data.desc_1; - this.desc_1_tokens = data.desc_1_tokens; - this.desc_2 = data.desc_2; - this.desc_2_tokens = data.desc_2_tokens; - this.image = data.image; - this.image_square = data.image_square; - this.image_title = data.image_title; + this.load(); + }, + + beforeDestroy() { + if ( this._events ) { + this._events.off('chat:update-link-resolver', this.checkRefresh, this); + this._events = null; + } }, methods: { + checkRefresh(url) { + if ( ! url || (url && url === this.url) ) { + this.reset(); + this.load(); + } + }, + + reset() { + this.loaded = false; + this.error = null; + this.title = this.t('card.loading', 'Loading...'); + this.title_tokens = null; + this.desc_1 = null; + this.desc_1_tokens = null; + this.desc_2 = null; + this.desc_2_tokens = null; + this.image = null; + this.image_title = null; + this.image_square = null; + this.accent = null; + }, + + async load() { + let data; + try { + data = this.data.getData(); + if ( data instanceof Promise ) { + const to_wait = has(this.data, 'timeout') ? this.data.timeout : 1000; + if ( to_wait ) + data = await timeout(data, to_wait); + else + data = await data; + } + + if ( ! data ) + data = { + error: true, + title: this.t('card.error', 'An error occured.'), + desc_1: this.t('card.empty', 'No data was returned.') + } + } catch(err) { + data = { + error: true, + title: this.t('card.error', 'An error occured.'), + desc_1: String(err) + } + } + + this.loaded = true; + this.error = data.error; + this.title = data.title; + this.title_tokens = data.title_tokens; + this.desc_1 = data.desc_1; + this.desc_1_tokens = data.desc_1_tokens; + this.desc_2 = data.desc_2; + this.desc_2_tokens = data.desc_2_tokens; + this.image = data.image; + this.image_square = data.image_square; + this.image_title = data.image_title; + this.accent = data.accent; + }, + renderCard(h) { if ( this.data.renderBody ) return [this.data.renderBody(h)]; - if ( this.html ) - return [h('div', { - domProps: { - innerHTML: this.html - } - })]; - return [ this.renderImage(h), this.renderDescription(h) diff --git a/src/modules/chat/index.js b/src/modules/chat/index.js index 8c611328..24fa9af9 100644 --- a/src/modules/chat/index.js +++ b/src/modules/chat/index.js @@ -71,6 +71,32 @@ export default class Chat extends Module { // Settings // ======================================================================== + this.settings.add('debug.link-resolver.source', { + default: null, + ui: { + path: 'Debugging > Data Sources >> Links', + title: 'Link Resolver', + component: 'setting-select-box', + force_seen: true, + data: [ + {value: null, title: 'Automatic'}, + {value: 'dev', title: 'localhost'}, + {value: 'test', title: 'API Test'}, + {value: 'prod', title: 'API Production' }, + {value: 'socket', title: 'Socket Cluster (Deprecated)'} + ] + }, + + changed: () => this.clearLinkCache() + }); + + this.settings.addUI('debug.link-resolver.test', { + path: 'Debugging > Data Sources >> Links', + component: 'link-tester', + getChat: () => this, + force_seen: true + }); + this.settings.add('chat.font-size', { default: 12, ui: { @@ -1506,6 +1532,33 @@ export default class Chat extends Module { // Twitch Crap // ==== + clearLinkCache(url) { + if ( url ) { + const info = this._link_info[url]; + if ( ! info[0] ) { + for(const pair of info[2]) + pair[1](); + } + + this._link_info[url] = null; + this.emit(':update-link-resolver', url); + return; + } + + const old = this._link_info; + this._link_info = {}; + + for(const info of Object.values(old)) { + if ( ! info[0] ) { + for(const pair of info[2]) + pair[1](); + } + } + + this.emit(':update-link-resolver'); + } + + get_link_info(url, no_promises) { let info = this._link_info[url]; const expires = info && info[1]; @@ -1536,15 +1589,23 @@ export default class Chat extends Module { cbs[success ? 0 : 1](data); } - if ( this.experiments.getAssignment('api_links') ) - timeout(fetch(`https://api-test.frankerfacez.com/v2/link?url=${encodeURIComponent(url)}`).then(r => r.json()), 15000) - .then(data => handle(true, data)) - .catch(err => handle(false, err)); + let provider = this.settings.get('debug.link-resolver.source'); + if ( provider == null ) + provider = this.experiments.getAssignment('api_links') ? 'test' : 'socket'; - else + if ( provider === 'socket' ) { timeout(this.socket.call('get_link', url), 15000) .then(data => handle(true, data)) .catch(err => handle(false, err)); + } else { + const host = provider === 'dev' ? 'https://localhost:8002/' : + provider === 'test' ? 'https://api-test.frankerfacez.com/v2/link' : + 'https://api.frankerfacez.com/v2/link'; + + timeout(fetch(`${host}?url=${encodeURIComponent(url)}`).then(r => r.json()), 15000) + .then(data => handle(true, data)) + .catch(err => handle(false, err)); + } }); } } \ No newline at end of file diff --git a/src/modules/chat/rich_providers.js b/src/modules/chat/rich_providers.js index 3f3cbb8f..b5db9f30 100644 --- a/src/modules/chat/rich_providers.js +++ b/src/modules/chat/rich_providers.js @@ -63,10 +63,14 @@ export const Links = { url: token.url, accent: data.accent, image: this.context.get('tooltip.link-images') ? (data.image_safe || this.context.get('tooltip.link-nsfw-images') ) ? data.preview || data.image : null : null, + image_title: data.image_title, image_square: data.image_square, title: data.title, + title_tokens: data.title_tokens, desc_1: data.desc_1, - desc_2: data.desc_2 + desc_1_tokens: data.desc_1_tokens, + desc_2: data.desc_2, + desc_2_tokens: data.desc_2_tokens } } } @@ -227,7 +231,7 @@ export const Clips = { } else if ( game ) { desc_1_tokens = this.i18n.tList('clip.desc.1.playing', '{user} playing {game}', { user: {class: 'tw-semibold', content: user}, - game: {class: 'tw-semibold', game_display} + game: {class: 'tw-semibold', content: game_display} }); desc_1 = this.i18n.t('clip.desc.1.playing', '{user} playing {game}', { user, diff --git a/src/modules/chat/tokenizers.jsx b/src/modules/chat/tokenizers.jsx index dad2cbf2..7bfa94f6 100644 --- a/src/modules/chat/tokenizers.jsx +++ b/src/modules/chat/tokenizers.jsx @@ -47,7 +47,9 @@ export const Links = { if ( target.dataset.isMail === 'true' ) return [this.i18n.t('tooltip.email-link', 'E-Mail {address}', {address: target.textContent})]; - return this.get_link_info(target.dataset.url).then(data => { + const url = target.dataset.url || target.href; + + return this.get_link_info(url).then(data => { if ( ! data || (data.v || 1) > TOOLTIP_VERSION ) return ''; diff --git a/src/modules/main_menu/components/link-tester.vue b/src/modules/main_menu/components/link-tester.vue new file mode 100644 index 00000000..087cfb65 --- /dev/null +++ b/src/modules/main_menu/components/link-tester.vue @@ -0,0 +1,230 @@ + + + \ No newline at end of file diff --git a/src/modules/tooltips.js b/src/modules/tooltips.js index a16a3a34..6e59fada 100644 --- a/src/modules/tooltips.js +++ b/src/modules/tooltips.js @@ -78,22 +78,22 @@ export default class TooltipProvider extends Module { } - _createInstance(container) { - return new Tooltip(container, 'ffz-tooltip', { + _createInstance(container, klass = 'ffz-tooltip', default_type) { + return new Tooltip(container, klass, { html: true, i18n: this.i18n, live: true, - delayHide: this.checkDelayHide.bind(this), - delayShow: this.checkDelayShow.bind(this), - content: this.process.bind(this), - interactive: this.checkInteractive.bind(this), - hover_events: this.checkHoverEvents.bind(this), + delayHide: this.checkDelayHide.bind(this, default_type), + delayShow: this.checkDelayShow.bind(this, default_type), + content: this.process.bind(this, default_type), + interactive: this.checkInteractive.bind(this, default_type), + hover_events: this.checkHoverEvents.bind(this, default_type), - onShow: this.delegateOnShow.bind(this), - onHide: this.delegateOnHide.bind(this), + onShow: this.delegateOnShow.bind(this, default_type), + onHide: this.delegateOnHide.bind(this, default_type), - popperConfig: this.delegatePopperConfig.bind(this), + popperConfig: this.delegatePopperConfig.bind(this, default_type), popper: { placement: 'top', modifiers: { @@ -132,8 +132,8 @@ export default class TooltipProvider extends Module { this.tips.cleanup(); } - delegatePopperConfig(target, tip, pop_opts) { - const type = target.dataset.tooltipType, + delegatePopperConfig(default_type, target, tip, pop_opts) { + const type = target.dataset.tooltipType || default_type, handler = this.types[type]; if ( handler && handler.popperConfig ) @@ -142,24 +142,24 @@ export default class TooltipProvider extends Module { return pop_opts; } - delegateOnShow(target, tip) { - const type = target.dataset.tooltipType, + delegateOnShow(default_type, target, tip) { + const type = target.dataset.tooltipType || default_type, handler = this.types[type]; if ( handler && handler.onShow ) handler.onShow(target, tip); } - delegateOnHide(target, tip) { - const type = target.dataset.tooltipType, + delegateOnHide(default_type, target, tip) { + const type = target.dataset.tooltipType || default_type, handler = this.types[type]; if ( handler && handler.onHide ) handler.onHide(target, tip); } - checkDelayShow(target, tip) { - const type = target.dataset.tooltipType, + checkDelayShow(default_type, target, tip) { + const type = target.dataset.tooltipType || default_type, handler = this.types[type]; if ( has(handler, 'delayShow') ) @@ -168,8 +168,8 @@ export default class TooltipProvider extends Module { return 0; } - checkDelayHide(target, tip) { - const type = target.dataset.tooltipType, + checkDelayHide(default_type, target, tip) { + const type = target.dataset.tooltipType || default_type, handler = this.types[type]; if ( has(handler, 'delayHide') ) @@ -178,8 +178,8 @@ export default class TooltipProvider extends Module { return 0; } - checkInteractive(target, tip) { - const type = target.dataset.tooltipType, + checkInteractive(default_type, target, tip) { + const type = target.dataset.tooltipType || default_type, handler = this.types[type]; if ( has(handler, 'interactive') ) @@ -188,8 +188,8 @@ export default class TooltipProvider extends Module { return false; } - checkHoverEvents(target, tip) { - const type = target.dataset.tooltipType, + checkHoverEvents(default_type, target, tip) { + const type = target.dataset.tooltipType || default_type, handler = this.types[type]; if ( has(handler, 'hover_events') ) @@ -198,8 +198,8 @@ export default class TooltipProvider extends Module { return false; } - process(target, tip) { - const type = target.dataset.tooltipType || 'text', + process(default_type, target, tip) { + const type = target.dataset.tooltipType || default_type || 'text', handler = this.types[type]; if ( ! handler ) diff --git a/src/sites/twitch-twilight/modules/channel.jsx b/src/sites/twitch-twilight/modules/channel.jsx index c7fd5a6b..db54cdf0 100644 --- a/src/sites/twitch-twilight/modules/channel.jsx +++ b/src/sites/twitch-twilight/modules/channel.jsx @@ -10,7 +10,7 @@ import {debounce} from 'utilities/object'; import { createElement, setChildren } from 'utilities/dom'; -const USER_PAGES = ['user', 'user-home', 'video', 'user-video', 'user-clip', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following']; +const USER_PAGES = ['user', 'user-home', 'user-about', 'video', 'user-video', 'user-clip', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following']; export default class Channel extends Module { @@ -31,6 +31,17 @@ export default class Channel extends Module { this.inject('metadata'); this.inject('socket'); + this.settings.add('channel.panel-tips', { + default: true, + ui: { + path: 'Channel > Behavior >> Panels', + title: 'Display rich tool-tips for links in channel panels.', + component: 'setting-check-box' + }, + + changed: () => this.updatePanelTips() + }); + this.settings.add('channel.auto-click-chat', { default: false, ui: { @@ -61,6 +72,13 @@ export default class Channel extends Module { }); + this.ChannelPanels = this.fine.define( + 'channel-panels', + n => n.layoutMasonry && n.updatePanelOrder && n.onExtensionPoppedOut, + USER_PAGES + ); + + this.ChannelRoot = this.elemental.define( 'channel-root', '.channel-root', USER_PAGES, @@ -91,6 +109,14 @@ export default class Channel extends Module { this.on('i18n:update', this.updateLinks, this); + this.ChannelPanels.on('mount', this.updatePanelTips, this); + this.ChannelPanels.on('update', this.updatePanelTips, this); + this.ChannelPanels.on('unmount', this.removePanelTips, this); + this.ChannelPanels.ready((cls, instances) => { + for(const inst of instances) + this.updatePanelTips(inst); + }); + this.ChannelRoot.on('mount', this.updateRoot, this); this.ChannelRoot.on('mutate', this.updateRoot, this); this.ChannelRoot.on('unmount', this.removeRoot, this); @@ -107,6 +133,35 @@ export default class Channel extends Module { this.checkNavigation(); } + updatePanelTips(inst) { + if ( ! inst ) { + for(const inst of this.ChannelPanels.instances) { + if ( inst ) + this.updatePanelTips(inst); + } + } + + const el = this.fine.getChildNode(inst); + if ( ! el || ! this.settings.get('channel.panel-tips') ) + return this.removePanelTips(inst); + + if ( inst._ffz_tips && inst._ffz_tip_el !== el ) + this.removePanelTips(inst); + + if ( ! inst._ffz_tips ) { + inst._ffz_tips = this.resolve('tooltips')._createInstance(el, 'tw-link', 'link'); + inst._ffz_tip_el = el; + } + } + + removePanelTips(inst) { // eslint-disable-line class-methods-use-this + if ( inst._ffz_tips ) { + inst._ffz_tips.destroy(); + inst._ffz_tips = null; + inst._ffz_tip_el = null; + } + } + checkNavigation() { if ( ! this.settings.get('channel.auto-click-chat') || this.router.current_name !== 'user-home' ) return; diff --git a/src/sites/twitch-twilight/modules/chat/rich_content.jsx b/src/sites/twitch-twilight/modules/chat/rich_content.jsx index f4b454fe..bdefb6d4 100644 --- a/src/sites/twitch-twilight/modules/chat/rich_content.jsx +++ b/src/sites/twitch-twilight/modules/chat/rich_content.jsx @@ -38,7 +38,7 @@ export default class RichContent extends Module { } } - async componentDidMount() { + async load() { try { let data = this.props.getData(); if ( data instanceof Promise ) { @@ -75,6 +75,28 @@ export default class RichContent extends Module { } } + checkReload(url) { + if ( ! url || (url && this.props.url === url) ) + this.reload(); + } + + reload() { + this.setState({ + loaded: false, + error: false + }, () => this.load()); + } + + componentDidMount() { + t.on('chat:update-link-resolver', this.checkReload, this); + + this.load(); + } + + componentWillUnmount() { + t.off('chat:update-link-resolver', this.checkReload, this); + } + renderCardImage() { return (
{this.state.error ? diff --git a/src/sites/twitch-twilight/modules/css_tweaks/index.js b/src/sites/twitch-twilight/modules/css_tweaks/index.js index e8114ecc..da8eb502 100644 --- a/src/sites/twitch-twilight/modules/css_tweaks/index.js +++ b/src/sites/twitch-twilight/modules/css_tweaks/index.js @@ -11,7 +11,7 @@ import {has} from 'utilities/object'; const STYLE_VALIDATOR = document.createElement('span'); const CLASSES = { - 'unfollow': '.follow-btn__follow-btn--following', + 'unfollow': '.follow-btn__follow-btn--following,.follow-btn--following', 'top-discover': '.navigation-link[data-a-target="discover-link"]', 'side-nav': '.side-nav', 'side-rec-channels': '.side-nav .recommended-channels,.side-nav .side-nav-section + .side-nav-section:not(.online-friends)', diff --git a/src/sites/twitch-twilight/modules/featured_follow_query.gql b/src/sites/twitch-twilight/modules/featured_follow_query.gql index 39b689b2..b0dc279d 100644 --- a/src/sites/twitch-twilight/modules/featured_follow_query.gql +++ b/src/sites/twitch-twilight/modules/featured_follow_query.gql @@ -1,4 +1,4 @@ -query($logins: [String!]) { +query FFZ_FollowsList($logins: [String!]) { users(logins: $logins) { id login diff --git a/src/sites/twitch-twilight/modules/player.jsx b/src/sites/twitch-twilight/modules/player.jsx index 452877e6..2332a124 100644 --- a/src/sites/twitch-twilight/modules/player.jsx +++ b/src/sites/twitch-twilight/modules/player.jsx @@ -1511,7 +1511,7 @@ export default class Player extends Module { tryTheatreMode(inst) { if ( ! inst._ffz_theater_timer ) - inst._ffz_theater_timer = requestAnimationFrame(() => { + inst._ffz_theater_timer = setTimeout(() => { inst._ffz_theater_timer = null; if ( ! this.settings.get('player.theatre.auto-enter') || ! inst._ffz_mounted ) @@ -1525,7 +1525,7 @@ export default class Player extends Module { if ( inst.props.onTheatreModeEnabled ) inst.props.onTheatreModeEnabled(); - }); + }, 250); } diff --git a/src/sites/twitch-twilight/styles/channel.scss b/src/sites/twitch-twilight/styles/channel.scss index b8b298b9..517792ca 100644 --- a/src/sites/twitch-twilight/styles/channel.scss +++ b/src/sites/twitch-twilight/styles/channel.scss @@ -34,6 +34,18 @@ } } +.channel-panels { + .default-panel { + & > .tw-link { + display: block; + + & > * { + pointer-events: none; + } + } + } +} + .tw-root--theme-ffz, .tw-root--theme-ffz.tw-root--theme-dark, .tw-root--theme-dark, body { .ffz-stat > .tw-button--text, .ffz-stat.tw-button--text { diff --git a/styles/widgets.scss b/styles/widgets.scss index e3537073..e38e9764 100644 --- a/styles/widgets.scss +++ b/styles/widgets.scss @@ -59,6 +59,16 @@ textarea.tw-input { } } +.ffz--link-tester { + code { + tab-size: 4; + } + + label { + min-width: 15rem; + } +} + .ffz-min-width-unset { min-width: unset !important;