From cebb1c0140794f9d29f9b3f49277f83127da336e Mon Sep 17 00:00:00 2001 From: SirStendec Date: Mon, 3 Jun 2019 19:47:41 -0400 Subject: [PATCH] 4.2.0 This is a fairly hefty update, behind the scenes. We've rewritten all code that deals with chat scrolling in order to integrate with Twitch's last batch of changes that made chat pausing accessible for moderators. This update also enables the Add-Ons System for all users. * Added: Setting to allow you to pause chat with a hot key, even when the mouse isn't hovering over chat. * Added: Setting to control how long chat remains paused due to move movement. * Fixed: Chat row backgrounds changing when messages are removed from chat. * Changed: Integrate with Twitch's own chat pausing code, where it makes sense. * Changed: Remove the experiment locking down access to the Add-Ons Loader. * Changed: When using the current time as a cache buster, truncate it to the nearest 5 second interval. --- src/addons.js | 11 +- src/experiments.js | 3 +- src/experiments.json | 8 - src/main.js | 2 +- src/modules/chat/emoji.js | 3 +- src/modules/main_menu/index.js | 21 +- src/sites/twitch-twilight/index.js | 3 +- .../twitch-twilight/modules/chat/index.js | 158 +++- .../twitch-twilight/modules/chat/scroller.js | 776 +++++++++--------- .../modules/chat/settings_menu.jsx | 141 +++- src/utilities/time.js | 5 + 11 files changed, 699 insertions(+), 432 deletions(-) diff --git a/src/addons.js b/src/addons.js index 17750fb0..26bd39d9 100644 --- a/src/addons.js +++ b/src/addons.js @@ -8,6 +8,7 @@ import Module from 'utilities/module'; import { SERVER } from 'utilities/constants'; import { createElement } from 'utilities/dom'; import { timeout, has } from 'utilities/object'; +import { getBuster } from 'utilities/time'; const fetchJSON = (url, options) => { return fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null); @@ -23,7 +24,6 @@ export default class AddonManager extends Module { this.should_enable = true; - this.inject('experiments'); this.inject('settings'); this.inject('i18n'); @@ -33,9 +33,6 @@ export default class AddonManager extends Module { } async onEnable() { - if ( ! this.experiments.getAssignment('addons') ) - return; - this.settings.addUI('add-ons', { path: 'Add-Ons @{"description": "Add-Ons are additional modules, often written by other people, that can be loaded automatically by FrankerFaceZ to add new capabilities and behaviors to the extension and Twitch."}', component: 'addon-list', @@ -107,9 +104,9 @@ export default class AddonManager extends Module { async loadAddonData() { const [cdn_data, local_data] = await Promise.all([ - fetchJSON(`${SERVER}/script/addons.json?_=${FrankerFaceZ.version_info}`), + fetchJSON(`${SERVER}/script/addons.json?_=${getBuster(30)}`), this.settings.get('addons.dev.server') ? - fetchJSON(`https://localhost:8001/script/addons.json?_=${Date.now()}`) : null + fetchJSON(`https://localhost:8001/script/addons.json?_=${getBuster()}`) : null ]); if ( Array.isArray(cdn_data) ) @@ -251,7 +248,7 @@ export default class AddonManager extends Module { document.head.appendChild(createElement('script', { id: `ffz-loaded-addon-${addon.id}`, type: 'text/javascript', - src: addon.src || `${addon.dev ? 'https://localhost:8001' : SERVER}/script/addons/${addon.id}/script.js`, + src: addon.src || `${addon.dev ? 'https://localhost:8001' : SERVER}/script/addons/${addon.id}/script.js?_=${getBuster(30)}`, crossorigin: 'anonymous' })); diff --git a/src/experiments.js b/src/experiments.js index ba162f99..99c9f84c 100644 --- a/src/experiments.js +++ b/src/experiments.js @@ -7,6 +7,7 @@ import {DEBUG, SERVER} from 'utilities/constants'; import Module from 'utilities/module'; import {has, deep_copy} from 'utilities/object'; +import { getBuster } from 'utilities/time'; import Cookie from 'js-cookie'; import SHA1 from 'crypto-js/sha1'; @@ -75,7 +76,7 @@ export default class ExperimentManager extends Module { let data; try { - data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${Date.now()}`).then(r => + data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${getBuster()}`).then(r => r.ok ? r.json() : null); } catch(err) { diff --git a/src/experiments.json b/src/experiments.json index 3651c042..761d91b9 100644 --- a/src/experiments.json +++ b/src/experiments.json @@ -1,12 +1,4 @@ { - "addons": { - "name": "Add-Ons Loader", - "description": "Enable the new Add-Ons system in development.", - "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/main.js b/src/main.js index 6c3c0fb7..200a706a 100644 --- a/src/main.js +++ b/src/main.js @@ -151,7 +151,7 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}` FrankerFaceZ.Logger = Logger; const VER = FrankerFaceZ.version_info = { - major: 4, minor: 1, revision: 2, + major: 4, minor: 2, revision: 0, commit: __git_commit__, build: __webpack_hash__, toString: () => diff --git a/src/modules/chat/emoji.js b/src/modules/chat/emoji.js index 603a48f0..2cec6d2c 100644 --- a/src/modules/chat/emoji.js +++ b/src/modules/chat/emoji.js @@ -7,6 +7,7 @@ import Module from 'utilities/module'; import {SERVER} from 'utilities/constants'; import {has} from 'utilities/object'; +import { getBuster } from 'utilities/time'; import splitter from 'emoji-regex/es2015/index'; @@ -74,7 +75,7 @@ export default class Emoji extends Module { async loadEmojiData(tries = 0) { let data; try { - data = await fetch(`${SERVER}/script/emoji/v2-.json?_${Date.now()}`).then(r => + data = await fetch(`${SERVER}/script/emoji/v2-.json?_${getBuster(60)}`).then(r => r.ok ? r.json() : null ); diff --git a/src/modules/main_menu/index.js b/src/modules/main_menu/index.js index 0818bdf7..54d09fd9 100644 --- a/src/modules/main_menu/index.js +++ b/src/modules/main_menu/index.js @@ -6,7 +6,7 @@ import Module from 'utilities/module'; import {createElement} from 'utilities/dom'; -import {has, deep_copy} from 'utilities/object'; +import {get, has, deep_copy} from 'utilities/object'; import Dialog from 'utilities/dialog'; @@ -44,6 +44,7 @@ export default class MainMenu extends Module { this.dialog = new Dialog(() => this.buildDialog()); this.has_update = false; this.opened = false; + this.showing = false; this.settings.addUI('profiles', { path: 'Data Management @{"sort": 1000, "profile_warning": false} > Profiles @{"profile_warning": false}', @@ -139,11 +140,13 @@ export default class MainMenu extends Module { this.on('i18n:update', this.scheduleUpdate, this); this.dialog.on('show', () => { + this.showing = true; this.opened = true; this.updateButtonUnseen(); this.emit('show') }); this.dialog.on('hide', () => { + this.showing = false; this.emit('hide'); this.destroyDialog(); }); @@ -163,6 +166,15 @@ export default class MainMenu extends Module { } + requestPage(page) { + const vue = get('_vue.$children.0', this); + if ( vue && vue.navigate ) + vue.navigate(page); + else + this._wanted_page = page; + } + + getUnseen() { const pages = this.getSettingsTree(); if ( ! Array.isArray(pages) ) @@ -229,9 +241,11 @@ export default class MainMenu extends Module { root.nav = tree; root.nav_keys = tree.keys; - root.currentItem = tree.keys[key] || (this.has_update ? + root.currentItem = tree.keys[key] || (this._wanted_page && tree.keys[this._wanted_page]) || (this.has_update ? tree.keys['home.changelog'] : tree.keys['home']); + + this._wanted_page = null; } @@ -706,8 +720,9 @@ export default class MainMenu extends Module { getData() { const settings = this.getSettingsTree(), context = this.getContext(), - current = this.has_update ? settings.keys['home.changelog'] : settings.keys['home']; + current = (this._wanted_page && settings.keys[this._wanted_page]) || (this.has_update ? settings.keys['home.changelog'] : settings.keys['home']); + this._wanted_page = null; this.markSeen(current); let has_unseen = false; diff --git a/src/sites/twitch-twilight/index.js b/src/sites/twitch-twilight/index.js index 309932a8..fd796616 100644 --- a/src/sites/twitch-twilight/index.js +++ b/src/sites/twitch-twilight/index.js @@ -161,7 +161,8 @@ Twilight.KNOWN_MODULES = { cookie: n => n && n.set && n.get && n.getJSON && n.withConverter, 'extension-service': n => n.extensionService, 'chat-types': n => n.b && has(n.b, 'Message') && has(n.b, 'RoomMods'), - 'gql-printer': n => n !== window && n.print + 'gql-printer': n => n !== window && n.print, + mousetrap: n => n.bindGlobal && n.unbind && n.handleKey } diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index 62539881..f70b4b5a 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -529,6 +529,11 @@ export default class ChatHook extends Module { addUpdateHandler: inst.addUpdateHandler, removeUpdateHandler: inst.removeUpdateHandler, getMessages: inst.getMessages, + isPaused: inst.isPaused, + setPaused: inst.setPaused, + hasNewerLeft: inst.hasNewerLeft, + loadNewer: inst.loadNewer, + loadNewest: inst.loadNewest, _ffz_inst: inst }); } @@ -656,6 +661,8 @@ export default class ChatHook extends Module { return; const t = this, + old_clear = cls.prototype.clear, + old_flush = cls.prototype.flushRawMessages, old_mount = cls.prototype.componentDidMount; cls.prototype._ffzInstall = function() { @@ -862,28 +869,41 @@ export default class ChatHook extends Module { last_msg.deletedCount = deleted_count; } + inst.setPaused = function(paused) { + if ( inst.paused === paused ) + return; + inst.paused = paused; + if ( ! paused ) { + inst.slidingWindowEnd = Math.min(inst.buffer.length, t.chat.context.get('chat.scrollback-length')); + if ( ! inst.props.isBackground ) + inst.notifySubscribers(); + } + } - inst.getMessages = function() { - const buf = inst.buffer, - size = t.chat.context.get('chat.scrollback-length'), - ct = t.chat_types || CHAT_TYPES, - target = buf.length - size; + inst.loadNewer = function() { + if ( ! inst.hasNewerLeft() ) + return; - if ( target > 0 ) { - let removed = 0, last; - for(let i=0; i < target; i++) - if ( buf[i] && ! NULL_TYPES.includes(ct[buf[i].type]) ) { - removed++; - last = i; - } + const end = Math.min(inst.buffer.length, inst.slidingWindowEnd + 40), + start = Math.max(0, end - t.chat.context.get('chat.scrollback-length')); - inst.buffer = buf.slice(removed % 2 === 0 ? target : Math.max(target - 10, last)); - } else - // Make a shallow copy of the array because other code expects it to change. - inst.buffer = buf.slice(0); + inst.clear(inst.buffer.length - start); + inst.slidingWindowEnd = end - start; + if ( ! inst.props.isBackground ) + inst.notifySubscribers(); + } - return inst.buffer; + inst.loadNewest = function() { + if ( ! inst.hasNewerLeft() ) + return; + + const max_size = t.chat.context.get('chat.scrollback-length'); + + inst.clear(max_size); + inst.slidingWindowEnd = Math.min(max_size, inst.buffer.length); + if ( ! inst.props.isBackground ) + inst.notifySubscribers(); } } @@ -897,32 +917,96 @@ export default class ChatHook extends Module { return old_mount.call(this); } - cls.prototype.flushRawMessages = function() { - const out = [], - now = Date.now(), - raw_delay = t.chat.context.get('chat.delay'), - delay = raw_delay === -1 ? this.delayDuration : raw_delay, - first = now - delay, - see_deleted = this.shouldSeeBlockedAndDeletedMessages || this.props && this.props.shouldSeeBlockedAndDeletedMessages, - do_remove = t.chat.context.get('chat.filtering.remove-deleted'); + cls.prototype.clear = function(count) { + try { + if ( count == null ) + count = 0; - let changed = false; + const max_size = t.chat.context.get('chat.scrollback-length'); + if ( ! this.isPaused() && count > max_size ) + count = max_size; - for(const msg of this.delayedMessageBuffer) { - if ( msg.time <= first || ! msg.shouldDelay ) { - if ( do_remove !== 0 && (do_remove > 1 || ! see_deleted) && this.isDeletable(msg.event) && msg.event.deleted ) - continue; + if ( count <= 0 ) { + this.buffer = []; + this.delayedMessageBuffer = []; + this.paused = false; - this.buffer.push(msg.event); - changed = true; + } else { + const buffer = this.buffer, + ct = t.chat_types || CHAT_TYPES, + target = buffer.length - count; - } else - out.push(msg); + if ( target > 0 ) { + let removed = 0, last; + for(let i=0; i < target; i++) + if ( buffer[i] && ! NULL_TYPES.includes(ct[buffer[i].type]) ) { + removed++; + last = i; + } + + this.buffer = buffer.slice(removed % 2 === 0 ? target : Math.max(target - 4, last)); + + } else + this.buffer = this.buffer.slice(0); + + if ( this.paused && this.buffer.length >= 900 ) + this.setPaused(false); + } + } catch(err) { + t.log.error('Error running clear', err); + return old_clear.call(this, count); } + } - this.delayedMessageBuffer = out; - if ( changed && ! this.props.isBackground ) - this.notifySubscribers(); + cls.prototype.flushRawMessages = function() { + try { + const out = [], + now = Date.now(), + raw_delay = t.chat.context.get('chat.delay'), + delay = raw_delay === -1 ? this.delayDuration : raw_delay, + first = now - delay, + see_deleted = this.shouldSeeBlockedAndDeletedMessages || this.props && this.props.shouldSeeBlockedAndDeletedMessages, + has_newer = this.hasNewerLeft(), + paused = this.isPaused(), + max_size = t.chat.context.get('chat.scrollback-length'), + do_remove = t.chat.context.get('chat.filtering.remove-deleted'); + + let added = 0, + buffered = this.slidingWindowEnd, + changed = false; + + for(const msg of this.delayedMessageBuffer) { + if ( msg.time <= first || ! msg.shouldDelay ) { + if ( do_remove !== 0 && (do_remove > 1 || ! see_deleted) && this.isDeletable(msg.event) && msg.event.deleted ) + continue; + + this.buffer.push(msg.event); + changed = true; + + if ( ! this.paused ) { + if ( this.buffer.length > max_size ) + added++; + else + buffered++; + } + + } else + out.push(msg); + } + + this.delayedMessageBuffer = out; + if ( changed ) { + this.clear(Math.min(900, this.buffer.length - added)); + if ( !(added === 0 && buffered === this.slidingWindowEnd && has_newer === this.hasNewerLeft() && paused === this.isPaused()) ) { + this.slidingWindowEnd = buffered; + if ( ! this.props.isBackground ) + this.notifySubscribers(); + } + } + } catch(err) { + t.log.error('Error running flush.', err); + return old_flush.call(this); + } } } diff --git a/src/sites/twitch-twilight/modules/chat/scroller.js b/src/sites/twitch-twilight/modules/chat/scroller.js index 4908f047..84f7d4fc 100644 --- a/src/sites/twitch-twilight/modules/chat/scroller.js +++ b/src/sites/twitch-twilight/modules/chat/scroller.js @@ -4,10 +4,16 @@ // Chat Scroller // ============================================================================ -import {createElement} from 'utilities/dom'; import Twilight from 'site'; import Module from 'utilities/module'; -import {IS_FIREFOX} from 'utilities/constants'; + +const SCROLL_EVENTS = [ + 'touchmove', + 'scroll', + 'wheel', + 'mousewheel', + 'DOMMouseScroll' +]; export default class Scroller extends Module { constructor(...args) { @@ -29,7 +35,7 @@ export default class Scroller extends Module { default: 0, ui: { path: 'Chat > Behavior >> Scrolling @{"description": "Please note that FrankerFaceZ is dependant on Twitch\'s own scrolling code working correctly. There are bugs with Twitch\'s scrolling code that have existed for more than six months. If you are using Firefox, Edge, or other non-Webkit browsers, expect to have issues."}', - title: 'Freeze Chat Scrolling', + title: 'Pause Chat Scrolling', description: 'Automatically stop chat from scrolling when moving the mouse over it or holding a key.', component: 'setting-select-box', data: [ @@ -47,6 +53,33 @@ export default class Scroller extends Module { } }); + this.settings.add('chat.scroller.freeze-requires-hover', { + default: true, + ui: { + path: 'Chat > Behavior >> Scrolling', + title: 'Require the mouse to be over chat to freeze with a hotkey.', + component: 'setting-check-box' + } + }); + + this.settings.add('chat.scroller.hover-delay', { + default: 750, + ui: { + path: 'Chat > Behavior >> Scrolling', + title: 'Hover Timeout', + description: 'Chat will only remain frozen due to mouse hovering for this long after the mouse stops moving.', + component: 'setting-combo-box', + data: [ + {value: 250, title: '0.25 Seconds'}, + {value: 500, title: '0.50 Seconds'}, + {value: 750, title: '0.75 Seconds'}, + {value: 1000, title: '1 Second'}, + {value: 2500, title: '2.5 Seconds'}, + {value: 5000, title: '5 Seconds'} + ] + } + }); + this.settings.add('chat.scroller.smooth-scroll', { default: 0, ui: { @@ -69,8 +102,10 @@ export default class Scroller extends Module { const old_use = this.use_keys; this.use_keys = false; for(const act of this.chat.context.get('chat.actions.inline')) - if ( act && act.display && act.display.keys ) + if ( act && act.display && act.display.keys ) { this.use_keys = true; + break; + } if ( this.use_keys !== old_use ) { for(const inst of this.ChatScroller.instances) @@ -78,37 +113,53 @@ export default class Scroller extends Module { } } - onEnable() { - this.on('i18n:update', () => { - for(const inst of this.ChatScroller.instances) - inst.ffzUpdateText(); - }); - - this.freeze = this.chat.context.get('chat.scroller.freeze'); - this.chat.context.on('changed:chat.scroller.freeze', val => { - this.freeze = val; - - for(const inst of this.ChatScroller.instances) { - inst.ffzDisableFreeze(); - if ( val !== 0 ) - inst.ffzEnableFreeze(); - } - }); + async onEnable() { + this.on('i18n:update', () => this.ChatScroller.forceUpdate()); this.chat.context.on('changed:chat.actions.inline', this.updateUseKeys, this); this.updateUseKeys(); - this.smoothScroll = this.chat.context.get('chat.scroller.smooth-scroll'); + this.pause_hover = this.chat.context.get('chat.scroller.freeze-requires-hover'); + this.chat.context.on('changed:chat.scroller.freeze-requires-hover', val => { + this.pause_hover = val; + + for(const inst of this.ChatScroller.instances) + inst.ffzMaybeUnpause(); + }) + + this.pause_delay = this.chat.context.get('chat.scroller.hover-delay'); + this.chat.context.on('changed:chat.scroller.hover-delay', val => { + this.pause_delay = val; + + for(const inst of this.ChatScroller.instances) + inst.ffzMaybeUnpause(); + }) + + this.pause = this.chat.context.get('chat.scroller.freeze'); + this.chat.context.on('changed:chat.scroller.freeze', val => { + this.pause = val; + + for(const inst of this.ChatScroller.instances) + inst.ffzMaybeUnpause(); + }); + + this.smooth_scroll = this.chat.context.get('chat.scroller.smooth-scroll'); this.chat.context.on('changed:chat.scroller.smooth-scroll', val => { - this.smoothScroll = val; + this.smooth_scroll = val; for(const inst of this.ChatScroller.instances) inst.ffzSetSmoothScroll(val); }); + const t = this, + React = await this.web_munch.findModule('react'), + createElement = React && React.createElement; + + if ( ! createElement ) + return t.log.warn(`Unable to get React.`); + this.ChatScroller.ready((cls, instances) => { - const t = this, - old_catch = cls.prototype.componentDidCatch, + const old_catch = cls.prototype.componentDidCatch, old_render = cls.prototype.render; // Try catching errors. With any luck, maybe we can @@ -138,8 +189,6 @@ export default class Scroller extends Module { if ( this.state.ffz_errors > 0 ) { let timer; const auto = this.state.ffz_total_errors < 10, - React = t.web_munch.getModule('react'), - createElement = React && React.createElement, handler = () => { clearTimeout(timer); this.ffzZeroErrors(); @@ -165,104 +214,335 @@ export default class Scroller extends Module { return old_render.call(this); } - cls.prototype.ffzShouldBeFrozen = function(since) { - if ( since === undefined ) - since = Date.now() - this.ffz_last_move; + cls.prototype.ffzInstallHandler = function() { + if ( this._ffz_installed ) + return; - const f = t.freeze; + this._ffz_installed = true; + const inst = this; - return ! this.ffz_outside && ( - (this.ffz_ctrl && (f === 2 || f === 6)) || - (this.ffz_meta && (f === 3 || f === 7)) || - (this.ffz_alt && (f === 4 || f === 8)) || - (this.ffz_shift && (f === 5 || f === 9)) || - (since < 750 && (f === 1 || f > 5)) - ); + inst.ffz_oldScrollEvent = inst.handleScrollEvent; + inst.ffz_oldScroll = inst.scrollToBottom; + + // New Scroll to Bottom + inst.ffz_doScroll = function() { + inst._ffz_scroll_frame = null; + if ( inst.state.isAutoScrolling && ! inst.state.isPaused ) { + if ( inst.ffz_smooth_scroll && ! inst._ffz_one_fast_scroll ) + inst.smoothScrollBottom(); + else { + inst._ffz_one_fast_scroll = false; + inst.ffz_oldScroll(); + } + } + } + + inst.scrollToBottom = function() { + if ( inst._ffz_scroll_frame || inst.state.isPaused ) + return; + + this._ffz_scroll_frame = requestAnimationFrame(inst.ffz_doScroll); + } + + // New Scroll Event Handling + inst.handleScrollEvent = function(event) { + if ( ! inst.scroll || ! inst.scroll.scrollContent ) + return; + + // TODO: Check for mousedown? + + if ( !(event.which > 0 || event.type === 'mousewheel' || event.type === 'wheel' || event.type === 'touchmove') ) + return; + + // How far are we scrolled up? + const scroller = inst.scroll.scrollContent, + offset = scroller.scrollHeight - scroller.scrollTop - scroller.offsetHeight; + + // If we're less than 10 pixels from the bottom and we aren't autoscrolling, resume + if ( offset <= 10 && ! inst.state.isAutoScrolling ) + inst.resume(); + + // If we are autoscrolling and we're more than 10 pixels up, then + // stop autoscrolling without setting paused. + else if ( inst.state.isAutoScrolling && offset > 10 ) { + // If we're paused, unpause. + if ( inst.state.isPaused ) { + inst.setState({ + isPaused: false + }, () => { + if ( inst.props.setPaused ) + inst.props.setPaused(false); + }); + + inst.setLoadMoreEnabled(true); + } + + inst.setState({ + isAutoScrolling: false + }); + } + } + + inst.pause = function() { + // If we already aren't scrolling, we don't want to further + // pause things. + if ( ! inst.state.isAutoScrolling ) + return; + + inst.setState({ + isPaused: true + }, () => { + if ( inst.props.setPaused ) + inst.props.setPaused(true); + }); + } + + const old_resume = inst.resume; + + inst.ffzFastResume = function() { + inst._ffz_one_fast_scroll = true; + inst.resume(); + } + + inst.resume = function() { + clearInterval(inst._ffz_hover_timer); + inst._ffz_hover_timer = null; + old_resume.call(inst); + } + + // Event Registration + + const Mousetrap = t.web_munch.getModule('mousetrap') || window.Mousetrap; + if ( Mousetrap != null ) { + Mousetrap.unbind('alt', 'keydown'); + Mousetrap.unbind('alt', 'keyup'); + } + + inst.ffzHandleKey = inst.ffzHandleKey.bind(inst); + + Mousetrap.bindGlobal('alt', inst.ffzHandleKey, 'keydown'); + Mousetrap.bindGlobal('alt', inst.ffzHandleKey, 'keyup'); + + Mousetrap.bindGlobal('shift', inst.ffzHandleKey, 'keydown'); + Mousetrap.bindGlobal('shift', inst.ffzHandleKey, 'keyup'); + + Mousetrap.bindGlobal('ctrl', inst.ffzHandleKey, 'keydown'); + Mousetrap.bindGlobal('ctrl', inst.ffzHandleKey, 'keyup'); + + Mousetrap.bindGlobal('command', inst.ffzHandleKey, 'keydown'); + Mousetrap.bindGlobal('command', inst.ffzHandleKey, 'keyup'); + + inst.hoverPause = inst.ffzMouseMove.bind(inst); + inst.hoverResume = inst.ffzMouseLeave.bind(inst); + + const node = t.fine.getChildNode(inst); + if ( node ) + node.addEventListener('mousemove', inst.hoverPause); + + const scroller = this.scroll && this.scroll.scrollContent; + if ( scroller ) { + for(const event of SCROLL_EVENTS) { + scroller.removeEventListener(event, inst.ffz_oldScrollEvent); + scroller.addEventListener(event, inst.handleScrollEvent); + } + } + + // We need to refresh the element to make sure it's using the correct + // event handlers for mouse enter / leave. + inst.forceUpdate(); } - cls.prototype.ffzMaybeUnfreeze = function() { - if ( this.ffz_frozen ) - requestAnimationFrame(() => { - if ( this.ffz_frozen && ! this.ffzShouldBeFrozen() ) - this.ffzUnfreeze(); + cls.prototype.ffzSetSmoothScroll = function(value) { + this.ffz_smooth_scroll = value; + this.ffzMaybeUnpause(); + } + + // Event Handling + + cls.prototype.ffzReadKeysFromEvent = function(event) { + if ( event.altKey === this.ffz_alt && + event.shiftKey === this.ffz_shift && + event.ctrlKey === this.ffz_ctrl && + event.metaKey === this.ffz_meta ) + return false; + + this.ffz_alt = event.altKey; + this.ffz_shift = event.shiftKey; + this.ffz_ctrl = event.ctrlKey; + this.ffz_meta = event.metaKey; + return true; + } + + cls.prototype.ffzHandleKey = function(event) { + if ( ! this.ffzReadKeysFromEvent(event) ) + return; + + this.ffzUpdateKeyTags(); + + if ( (t.pause_hover && this.ffz_outside) || t.pause < 2 ) + return; + + const should_pause = this.ffzShouldBePaused(), + changed = should_pause !== this.state.isPaused; + + if ( changed ) + if ( should_pause ) { + this.pause(); + this.setLoadMoreEnabled(false); + } else + this.resume(); + } + + cls.prototype.ffzInstallHoverTimer = function() { + if ( this._ffz_hover_timer ) + return; + + this._ffz_hover_timer = setInterval(() => { + if ( this.state.isPaused && this.ffzShouldBePaused() ) + return; + + this.ffzMaybeUnpause(); + }, 50); + } + + cls.prototype.ffzMouseMove = function(event) { + this.ffz_last_move = Date.now(); + const was_outside = this.ffz_outside; + this.ffz_outside = false; + + if ( this._ffz_outside_timer ) { + clearTimeout(this._ffz_outside_timer); + this._ffz_outside_timer = null; + } + + const keys_updated = this.ffzReadKeysFromEvent(event); + + // If nothing changed, stop processing. + if ( ! keys_updated && event.screenX === this.ffz_sx && event.screenY === this.ffz_sy ) { + if ( was_outside ) + this.ffzUpdateKeyTags(); + + return; + } + + this.ffz_sx = event.screenX; + this.ffz_sy = event.screenY; + + if ( keys_updated || was_outside ) + this.ffzUpdateKeyTags(); + + const should_pause = this.ffzShouldBePaused(), + changed = should_pause !== this.state.isPaused; + + if ( changed ) + if ( should_pause ) { + this.pause(); + this.ffzInstallHoverTimer(); + this.setLoadMoreEnabled(false); + + } else + this.resume(); + } + + cls.prototype.ffzMouseLeave = function() { + this.ffz_outside = true; + if ( this._ffz_outside_timer ) + clearTimeout(this._ffz_outside_timer); + + this._ffz_outside_timer = setTimeout(() => this.ffzMaybeUnpause(), 64); + this.ffzUpdateKeyTags(); + } + + + // Keyboard Stuff + + cls.prototype.ffzUpdateKeyTags = function() { + if ( ! this._ffz_key_frame ) + this._ffz_key_frame = requestAnimationFrame(() => this.ffz_updateKeyTags()); + } + + cls.prototype.ffz_updateKeyTags = function() { + this._ffz_key_frame = null; + + if ( ! t.use_keys && this.ffz_use_keys === t.use_keys ) + return; + + if ( ! this.scroll || ! this.scroll.root ) + return; + + this.ffz_use_keys = t.use_keys; + this.scroll.root.classList.toggle('ffz--keys', t.use_keys); + + const ds = this.scroll.root.dataset; + + if ( ! t.use_keys ) { + delete ds.alt; + delete ds.ctrl; + delete ds.shift; + delete ds.meta; + + } else { + ds.alt = ! this.ffz_outside && this.ffz_alt; + ds.ctrl = ! this.ffz_outside && this.ffz_ctrl; + ds.shift = ! this.ffz_outside && this.ffz_shift; + ds.meta = ! this.ffz_outside && this.ffz_meta; + } + } + + + // Pause Stuff + + cls.prototype.ffzShouldBePaused = function(since) { + if ( since == null ) + since = Date.now() - this.ffz_last_move; + + const mode = t.pause, + require_hover = t.pause_hover; + + return (! require_hover || ! this.ffz_outside) && this.state.isAutoScrolling && ( + (this.ffz_ctrl && (mode === 2 || mode === 6)) || + (this.ffz_meta && (mode === 3 || mode === 7)) || + (this.ffz_alt && (mode === 4 || mode === 8)) || + (this.ffz_shift && (mode === 5 || mode === 9)) || + (! this.ffz_outside && since < t.pause_delay && (mode === 1 || mode > 5)) + ); + + } + + cls.prototype.ffzMaybeUnpause = function() { + if ( this.state.isPaused && ! this._ffz_unpause_frame ) + this._ffz_unpause_frame = requestAnimationFrame(() => { + this._ffz_unpause_frame = null; + if ( this.state.isPaused && ! this.ffzShouldBePaused() ) + this.resume(); }); } - cls.prototype.ffzUpdateText = function() { - if ( ! this._ffz_freeze_indicator ) - return; + cls.prototype.listFooter = function() { + let msg; + if ( this.state.isPaused ) { + const f = t.pause, + reason = f === 2 ? t.i18n.t('key.ctrl', 'Ctrl Key') : + f === 3 ? t.i18n.t('key.meta', 'Meta Key') : + f === 4 ? t.i18n.t('key.alt', 'Alt Key') : + f === 5 ? t.i18n.t('key.shift', 'Shift Key') : + f === 6 ? t.i18n.t('key.ctrl_mouse', 'Ctrl or Mouse') : + f === 7 ? t.i18n.t('key.meta_mouse', 'Meta or Mouse') : + f === 8 ? t.i18n.t('key.alt_mouse', 'Alt or Mouse') : + f === 9 ? t.i18n.t('key.shift_mouse', 'Shift or Mouse') : + t.i18n.t('key.mouse', 'Mouse Movement'); - const f = t.freeze, - reason = f === 2 ? t.i18n.t('key.ctrl', 'Ctrl Key') : - f === 3 ? t.i18n.t('key.meta', 'Meta Key') : - f === 4 ? t.i18n.t('key.alt', 'Alt Key') : - f === 5 ? t.i18n.t('key.shift', 'Shift Key') : - f === 6 ? t.i18n.t('key.ctrl_mouse', 'Ctrl or Mouse') : - f === 7 ? t.i18n.t('key.meta_mouse', 'Meta or Mouse') : - f === 8 ? t.i18n.t('key.alt_mouse', 'Alt or Mouse') : - f === 9 ? t.i18n.t('key.shift_mouse', 'Shift or Mouse') : - t.i18n.t('key.mouse', 'Mouse Movement'); + msg = t.i18n.t('chat.paused', '(Chat Paused Due to {reason})', {reason}); - this._ffz_freeze_indicator.firstElementChild.textContent = t.i18n.t( - 'chat.paused', - '(Chat Paused Due to {reason})', - {reason} - ); - } + } else if ( this.state.isAutoScrolling ) + return null; + else + msg = t.i18n.t('chat.messages-below', 'More messages below.'); - cls.prototype.ffzShowFrozen = function() { - this._ffz_freeze_visible = true; - let el = this._ffz_freeze_indicator; - if ( ! el ) { - const node = t.fine.getChildNode(this); - if ( ! node ) - return; - - node.classList.add('tw-full-height'); - - el = this._ffz_freeze_indicator = createElement('div', { - className: 'ffz--freeze-indicator chat-list__more-messages-placeholder tw-relative tw-mg-x-2' - }, createElement('div', { - className: 'chat-list__more-messages tw-bottom-0 tw-full-width tw-align-items-center tw-flex tw-justify-content-center tw-absolute tw-pd-05' - })); - - this.ffzUpdateText(); - node.appendChild(el); - - } else - el.classList.remove('tw-hide'); - } - - cls.prototype.ffzHideFrozen = function() { - this._ffz_freeze_visible = false; - if ( this._ffz_freeze_indicator ) - this._ffz_freeze_indicator.classList.add('tw-hide'); - } - - cls.prototype.ffzFreeze = function() { - if ( ! this._ffz_interval ) - this._ffz_interval = setInterval(() => { - if ( ! this.ffzShouldBeFrozen() ) - this.ffzMaybeUnfreeze(); - }, 200); - - this.ffz_frozen = true; - this.setState({ffzFrozen: true}); - //this.ffzShowFrozen(); - } - - cls.prototype.ffzUnfreeze = function() { - if ( this._ffz_interval ) { - clearInterval(this._ffz_interval); - this._ffz_interval = null; - } - - this.ffz_frozen = false; - this.setState({ffzFrozen: false}); - if ( this.state.isAutoScrolling ) - this.scrollToBottom(); - - //this.ffzHideFrozen(); + return createElement('div', { + className: 'chat-list__list-footer tw-absolute tw-align-items-center tw-border-radius-medium tw-bottom-0 tw-flex tw-full-width tw-justify-content-center tw-pd-05', + onClick: this.ffzFastResume + }, createElement('div', null, msg)); } cls.prototype.smoothScrollBottom = function() { @@ -294,7 +574,7 @@ export default class Scroller extends Module { } const smoothAnimation = () => { - if ( this.state.ffzFrozen || ! this.state.isAutoScrolling ) + if ( this.state.isPaused || ! this.state.isAutoScrolling ) return this.ffz_is_smooth_scrolling = false; // See how much time has passed to get a step based off the delta @@ -328,238 +608,7 @@ export default class Scroller extends Module { smoothAnimation(); } - - cls.prototype.ffzInstallHandler = function() { - if ( this._ffz_handleScroll ) - return; - - const t = this; - - this._doScroll = function() { - if ( ! t.ffz_freeze_enabled || ! t.state.ffzFrozen ) { - if ( t.ffz_smooth_scroll ) - t.smoothScrollBottom(); - else - t._old_scroll(); - } - } - - this._old_scroll = this.scrollToBottom; - this.scrollToBottom = function() { - if ( this._ffz_animation ) - cancelAnimationFrame(this._ffz_animation); - - this._ffz_animation = requestAnimationFrame(t._doScroll); - } - - this._ffz_handleScroll = this.handleScrollEvent; - this.handleScrollEvent = function(e) { - // If we're frozen because of FFZ, do not allow a mouse click to update - // the auto-scrolling state. That just gets annoying. - if ( e.type === 'mousedown' && t.ffz_frozen ) - return; - - if ( t.scroll && e.type === 'touchmove' ) { - t.scroll.scrollContent.scrollHeight - t.scroll.scrollContent.scrollTop - t.scroll.scrollContent.offsetHeight <= 10 ? t.setState({ - isAutoScrolling: !0 - }) : t.setState({ - isAutoScrolling: !1 - }) - } - - return t._ffz_handleScroll(e); - } - - const scroller = this.scroll && this.scroll.scrollContent; - if ( scroller ) { - scroller.removeEventListener('mousedown', this._ffz_handleScroll); - scroller.addEventListener('mousedown', this.handleScrollEvent); - scroller.addEventListener('touchmove', this.handleScrollEvent); - } - } - - - cls.prototype.ffzEnableFreeze = function() { - const node = t.fine.getChildNode(this); - if ( ! node || this.ffz_freeze_enabled ) - return; - - this.ffz_freeze_enabled = true; - - if ( t.freeze > 1 ) { - document.body.addEventListener('keydown', - this._ffz_key = this.ffzKey.bind(this)); - - document.body.addEventListener('keyup', this._ffz_key); - } - - node.addEventListener('mousemove', - this._ffz_mousemove = this.ffzMouseMove.bind(this)); - - node.addEventListener('mouseleave', - this._ffz_mouseleave = this.ffzMouseLeave.bind(this)); - } - - - cls.prototype.ffzDisableFreeze = function() { - this.ffz_freeze_enabled = false; - - if ( this.ffz_frozen ) - this.ffzUnfreeze(); - - if ( this._ffz_outside ) { - clearTimeout(this._ffz_outside); - this._ffz_outside = null; - } - - const node = t.fine.getChildNode(this); - if ( ! node ) - return; - - this._ffz_freeze_visible = false; - - if ( this._ffz_freeze_indicator ) { - this._ffz_freeze_indicator.remove(); - this._ffz_freeze_indicator = null; - } - - if ( this._ffz_key ) { - document.body.removeEventListener('keyup', this._ffz_key); - document.body.removeEventListener('keydown', this._ffz_key); - this._ffz_key = null; - } - - if ( this._ffz_mousemove ) { - node.removeEventListener('mousemove', this._ffz_mousemove); - this._ffz_mousemove = null; - } - - if ( this._ffz_mouseleave ) { - node.removeEventListener('mouseleave', this._ffz_mouseleave); - this._ffz_mouseleave = null; - } - } - - - cls.prototype.ffzKey = function(e) { - if (e.altKey === this.ffz_alt && - e.shiftKey === this.ffz_shift && - e.ctrlKey === this.ffz_ctrl && - e.metaKey === this.ffz_meta) - return; - - this.ffz_alt = e.altKey; - this.ffz_shift = e.shiftKey; - this.ffz_ctrl = e.ctrlKey; - this.ffz_meta = e.metaKey; - - this.ffzUpdateKeys(); - - if ( this.ffz_outside || t.freeze < 2 ) - return; - - const should_freeze = this.ffzShouldBeFrozen(), - changed = should_freeze !== this.ffz_frozen; - - if ( changed ) - if ( should_freeze ) - this.ffzFreeze(); - else - this.ffzUnfreeze(); - } - - - cls.prototype.ffzUpdateKeys = function() { - if ( ! this._ffz_key_update ) - this._ffz_key_update = requestAnimationFrame(() => this.ffz_updateKeys()); - } - - cls.prototype.ffz_updateKeys = function() { - cancelAnimationFrame(this._ffz_key_update); - this._ffz_key_update = null; - - if ( ! t.use_keys && this.ffz_use_keys === t.use_keys ) - return; - - if ( ! this.scroll || ! this.scroll.root ) - return; - - this.ffz_use_keys = t.use_keys; - this.scroll.root.classList.toggle('ffz--keys', t.use_keys); - - const ds = this.scroll.root.dataset; - - if ( ! t.use_keys ) { - delete ds.alt; - delete ds.ctrl; - delete ds.shift; - delete ds.meta; - - } else { - ds.alt = ! this.ffz_outside && this.ffz_alt; - ds.ctrl = ! this.ffz_outside && this.ffz_ctrl; - ds.shift = ! this.ffz_outside && this.ffz_shift; - ds.meta = ! this.ffz_outside && this.ffz_meta; - } - } - - cls.prototype.ffzMouseMove = function(e) { - this.ffz_last_move = Date.now(); - const was_outside = this.ffz_outside; - this.ffz_outside = false; - if ( this._ffz_outside ) { - clearTimeout(this._ffz_outside); - this._ffz_outside = null; - } - - // If nothing of interest has happened, stop. - if (e.altKey === this.ffz_alt && - e.shiftKey === this.ffz_shift && - e.ctrlKey === this.ffz_ctrl && - e.metaKey === this.ffz_meta && - e.screenY === this.ffz_sy && - e.screenX === this.ffz_sx) { - - if ( was_outside ) - this.ffzUpdateKeys(); - - return; - } - - this.ffz_alt = e.altKey; - this.ffz_shift = e.shiftKey; - this.ffz_ctrl = e.ctrlKey; - this.ffz_meta = e.metaKey; - this.ffz_sy = e.screenY; - this.ffz_sx = e.screenX; - - this.ffzUpdateKeys(); - - const should_freeze = this.ffzShouldBeFrozen(), - changed = should_freeze !== this.ffz_frozen; - - if ( changed ) - if ( should_freeze ) - this.ffzFreeze(); - else - this.ffzUnfreeze(); - } - - - cls.prototype.ffzMouseLeave = function() { - this.ffz_outside = true; - if ( this._ffz_outside ) - clearTimeout(this._ffz_outside); - - this._ffz_outside = setTimeout(() => this.ffzMaybeUnfreeze(), 64); - this.ffzUpdateKeys(); - } - - - cls.prototype.ffzSetSmoothScroll = function(value) { - this.ffz_smooth_scroll = value; - } - + // Do the thing~ for(const inst of instances) this.onMount(inst); @@ -567,31 +616,26 @@ export default class Scroller extends Module { this.ChatScroller.on('mount', this.onMount, this); this.ChatScroller.on('unmount', this.onUnmount, this); - - this.ChatScroller.on('update', inst => { - const should_show = inst.ffz_freeze_enabled && inst.state.ffzFrozen && inst.state.isAutoScrolling, - changed = should_show !== inst._ffz_freeze_visible; - - if ( changed ) - if ( should_show ) - inst.ffzShowFrozen(); - else - inst.ffzHideFrozen(); - }); - } onMount(inst) { + inst.ffzSetSmoothScroll(this.smooth_scroll); inst.ffzInstallHandler(); - - if ( this.freeze !== 0 ) - inst.ffzEnableFreeze(); - - inst.ffzSetSmoothScroll(this.smoothScroll); } - onUnmount(inst) { // eslint-disable-line class-methods-use-this - inst.ffzDisableFreeze(); + onUnmount() { // eslint-disable-line class-methods-use-this + const Mousetrap = this.web_munch.getModule('mousetrap') || window.Mousetrap; + if ( Mousetrap != null ) { + Mousetrap.unbind('alt', 'keydown'); + Mousetrap.unbind('alt', 'keyup'); + Mousetrap.unbind('shift', 'keydown'); + Mousetrap.unbind('shift', 'keyup'); + Mousetrap.unbind('ctrl', 'keydown'); + Mousetrap.unbind('ctrl', 'keyup'); + Mousetrap.unbind('command', 'keydown'); + Mousetrap.unbind('command', 'keyup'); + } + } } \ No newline at end of file diff --git a/src/sites/twitch-twilight/modules/chat/settings_menu.jsx b/src/sites/twitch-twilight/modules/chat/settings_menu.jsx index 103b0d2e..8e9a6c72 100644 --- a/src/sites/twitch-twilight/modules/chat/settings_menu.jsx +++ b/src/sites/twitch-twilight/modules/chat/settings_menu.jsx @@ -7,6 +7,8 @@ import Twilight from 'site'; import Module from 'utilities/module'; +import { has } from 'utilities/object'; + export default class SettingsMenu extends Module { constructor(...args) { super(...args); @@ -22,10 +24,17 @@ export default class SettingsMenu extends Module { n => n.renderUniversalOptions && n.onBadgesChanged, Twilight.CHAT_ROUTES ); + + this.ModSettingsMenu = this.fine.define( + 'chat-mod-settings', + n => n.renderModerationSettingsLink && n.onChatClear, + Twilight.CHAT_ROUTES + ); } async onEnable() { this.on('i18n:update', () => this.SettingsMenu.forceUpdate()); + this.chat.context.on('changed:chat.scroller.freeze', () => this.SettingsMenu.forceUpdate()); const t = this, React = await this.web_munch.findModule('react'); @@ -34,12 +43,19 @@ export default class SettingsMenu extends Module { const createElement = React.createElement; - this.SettingsMenu.ready((cls, instances) => { - const old_universal = cls.prototype.renderUniversalOptions; + this.SettingsMenu.ready(cls => { + const old_render = cls.prototype.render, + old_universal = cls.prototype.renderUniversalOptions; cls.prototype.renderUniversalOptions = function() { const val = old_universal.call(this); + if ( ! this.ffzSettingsClick ) + this.ffzSettingsClick = e => t.click(this, e); + + if ( ! this.ffzPauseClick ) + this.ffzPauseClick = () => this.setState({ffzPauseMenu: ! this.state.ffzPauseMenu}); + val.props.children.push(
} ); + const f = t.chat.context.get('chat.scroller.freeze'), + reason = f === 2 ? t.i18n.t('key.ctrl', 'Ctrl Key') : + f === 3 ? t.i18n.t('key.meta', 'Meta Key') : + f === 4 ? t.i18n.t('key.alt', 'Alt Key') : + f === 5 ? t.i18n.t('key.shift', 'Shift Key') : + f === 6 ? t.i18n.t('key.ctrl_mouse', 'Ctrl or Mouse') : + f === 7 ? t.i18n.t('key.meta_mouse', 'Meta or Mouse') : + f === 8 ? t.i18n.t('key.alt_mouse', 'Alt or Mouse') : + f === 9 ? t.i18n.t('key.shift_mouse', 'Shift or Mouse') : + t.i18n.t('key.hover', 'Hover'); + + val.props.children.push(
+ +
) + return val; } - for(const inst of instances) - inst.ffzSettingsClick = e => t.click(inst, e); + cls.prototype.render = function() { + try { + if ( this.state.ffzPauseMenu ) { + if ( ! this.ffzSettingsClick ) + this.ffzSettingsClick = e => t.click(this, e); + + if ( ! this.ffzPauseClick ) + this.ffzPauseClick = () => this.setState({ffzPauseMenu: ! this.state.ffzPauseMenu}); + + return (
+
+
+
+ +
+

+ {t.i18n.t('chat.settings.pause', 'Pause Chat')} +

+
+

+ {t.i18n.t('chat.settings.pause-explain', 'FrankerFaceZ overrides the behavior of Pause Chat entirely. Please use FFZ\'s Scrolling settings within the FFZ Control Center under Chat > Behavior.')} +

+
+ + {t.cant_window &&
+ + {t.i18n.t('popup.error', 'We tried opening a pop-up window and could not. Make sure to allow pop-ups from Twitch.')} + +
} +
+
+
+
+
) + } + + } catch(err) { + t.log.error('Error rendering chat settings menu.', err); + } + + return old_render.call(this); + } this.SettingsMenu.forceUpdate(); }); - this.SettingsMenu.on('mount', inst => { - inst.ffzSettingsClick = e => t.click(inst, e) - }); + this.ModSettingsMenu.ready(cls => { + const old_render = cls.prototype.render; + + cls.prototype.render = function() { + const out = old_render.call(this); + + if ( out.props && Array.isArray(out.props.children) ) { + let i = out.props.children.length; + while(i--) { + const thing = out.props.children[i]; + if ( thing && thing.props && has(thing.props, 'chatPauseSetting') ) { + out.props.children.splice(i, 1); + break; + } + } + } + + return out; + } + + this.ModSettingsMenu.forceUpdate(); + }) this.SettingsMenu.on('unmount', inst => { inst.ffzSettingsClick = null; @@ -89,6 +205,17 @@ export default class SettingsMenu extends Module { } } else { + const target = event.currentTarget, + page = target && target.dataset && target.dataset.page, + menu = this.resolve('main_menu'); + + if ( menu ) { + if ( page ) + menu.requestPage(page); + if ( menu.showing ) + return; + } + this.emit('site.menu_button:clicked'); } diff --git a/src/utilities/time.js b/src/utilities/time.js index f5a6dd1e..cac7ca41 100644 --- a/src/utilities/time.js +++ b/src/utilities/time.js @@ -1,5 +1,10 @@ 'use strict'; +export function getBuster(resolution = 5) { + const now = Math.floor(Date.now() / 1000); + return now - (now % resolution); +} + export function duration_to_string(elapsed, separate_days, days_only, no_hours, no_seconds) { const seconds = elapsed % 60; let minutes = Math.floor(elapsed / 60),