'use strict'; // ============================================================================ // Chat Scroller // ============================================================================ import {createElement} from 'utilities/dom'; import Twilight from 'site'; import Module from 'utilities/module'; import {IS_FIREFOX} from 'utilities/constants'; export default class Scroller extends Module { constructor(...args) { super(...args); this.inject('settings'); this.inject('i18n'); this.inject('chat'); this.inject('site.fine'); this.inject('site.web_munch'); this.ChatScroller = this.fine.define( 'chat-scroller', n => n.saveScrollRef && n.handleScrollEvent, Twilight.CHAT_ROUTES ); this.settings.add('chat.scroller.freeze', { 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', description: 'Automatically stop chat from scrolling when moving the mouse over it or holding a key.', component: 'setting-select-box', data: [ {value: 0, title: 'Disabled'}, {value: 1, title: 'On Hover'}, {value: 2, title: 'When Ctrl is Held'}, {value: 3, title: 'When Meta is Held'}, {value: 4, title: 'When Alt is Held'}, {value: 5, title: 'When Shift is Held'}, {value: 6, title: 'Ctrl or Hover'}, {value: 7, title: 'Meta or Hover'}, {value: 8, title: 'Alt or Hover'}, {value: 9, title: 'Shift or Hover'} ] } }); this.settings.add('chat.scroller.smooth-scroll', { default: 0, ui: { path: 'Chat > Behavior >> Scrolling', title: 'Smooth Scrolling', description: 'Smoothly slide new chat messages into view. Speed will increase as necessary to keep up with chat.', component: 'setting-select-box', data: [ {value: 0, title: 'Disabled'}, {value: 1, title: 'Slow'}, {value: 2, title: 'Medium'}, {value: 3, title: 'Fast'}, {value: 4, title: 'Very Fast'} ] } }); } 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(); } }); this.smoothScroll = this.chat.context.get('chat.scroller.smooth-scroll'); this.chat.context.on('changed:chat.scroller.smooth-scroll', val => { this.smoothScroll = val; for(const inst of this.ChatScroller.instances) inst.ffzSetSmoothScroll(val); }); this.ChatScroller.ready((cls, instances) => { const t = this, old_catch = cls.prototype.componentDidCatch, old_render = cls.prototype.render; // Try catching errors. With any luck, maybe we can // recover from the error when we re-build? cls.prototype.componentDidCatch = function(err, info) { // Don't log infinitely if stuff gets super screwed up. const errs = this.state.ffz_errors || 0; if ( errs < 100 ) { this.setState({ ffz_errors: errs + 1, ffz_total_errors: (this.state.ffz_total_errors||0) + 1 }); t.log.capture(err, {extra: info}); t.log.info('Error within Chat', err, info, errs); } if ( old_catch ) return old_catch.call(this, err, info); } cls.prototype.ffzZeroErrors = function() { this.setState({ffz_errors: 0}); } cls.prototype.render = function() { 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(); } if ( auto ) timer = setTimeout(handler, 250); if ( ! createElement ) return null; return createElement('div', { className: 'tw-border-l tw-c-background-alt-2 tw-c-text-base tw-full-width tw-full-height tw-align-items-center tw-flex tw-flex-column tw-justify-content-center tw-relative' }, [ createElement('div', {className: 'tw-mg-b-1'}, 'There was an error displaying chat.'), ! auto && createElement('button', { className: 'tw-button', onClick: handler }, createElement('span', {className: 'tw-button__text'}, 'Try Again')) ]); } else return old_render.call(this); } cls.prototype.ffzShouldBeFrozen = function(since) { if ( since === undefined ) since = Date.now() - this.ffz_last_move; const f = t.freeze; 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)) ); } cls.prototype.ffzMaybeUnfreeze = function() { if ( this.ffz_frozen ) requestAnimationFrame(() => { if ( this.ffz_frozen && ! this.ffzShouldBeFrozen() ) this.ffzUnfreeze(); }); } cls.prototype.ffzUpdateText = function() { if ( ! this._ffz_freeze_indicator ) return; 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'); this._ffz_freeze_indicator.firstElementChild.textContent = t.i18n.t( 'chat.paused', '(Chat Paused Due to %{reason})', {reason} ); } 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(); } cls.prototype.smoothScrollBottom = function() { if ( this._ffz_smooth_animation ) cancelAnimationFrame(this._ffz_smooth_animation); this.ffz_is_smooth_scrolling = true; // Step setting value is # pixels to scroll per 10ms. // 1 is pretty slow, 2 medium, 3 fast, 4 very fast. let step = this.ffz_smooth_scroll, old_time = Date.now(); const scroll_content = this.scroll.scrollContent; if ( ! scroll_content ) return; const target_top = scroll_content.scrollHeight - scroll_content.clientHeight, difference = target_top - scroll_content.scrollTop; // If we are falling behind speed us up if ( difference > scroll_content.clientHeight ) { // we are a full scroll away, just jump there step = difference; } else if ( difference > 200 ) { // we are starting to fall behind, speed it up a bit step += step * Math.floor(difference / 200); } const smoothAnimation = () => { if ( this.state.ffzFrozen || ! this.state.isAutoScrolling ) return this.ffz_is_smooth_scrolling = false; // See how much time has passed to get a step based off the delta const current_time = Date.now(), delta = current_time - old_time, current_step = step * (delta / 10); // we need to move at least one full pixel for scrollTop to do anything in this delta. if ( current_step >= 1 ) { const scroll_top = scroll_content.scrollTop, target_top = scroll_content.scrollHeight - scroll_content.clientHeight; old_time = current_time; if ( scroll_top < target_top ) { scroll_content.scrollTop = scroll_top + current_step; this._ffz_smooth_animation = requestAnimationFrame(smoothAnimation); } else { // We've reached the bottom. scroll_content.scrollTop = target_top; this.ffz_is_smooth_scrolling = false; } } else { // The frame happened so quick since last update that we haven't moved a full pixel. // Just wait. this._ffz_smooth_animation = requestAnimationFrame(smoothAnimation); } } smoothAnimation(); } cls.prototype.ffzInstallHandler = function() { if ( this._ffz_handleScroll ) return; const t = this; this._old_scroll = this.scrollToBottom; this.scrollToBottom = function() { if ( ! this.ffz_freeze_enabled || ! this.state.ffzFrozen ) { if ( this.ffz_smooth_scroll ) this.smoothScrollBottom(); else this._old_scroll(); } } 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; 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.ffzMouseMove = function(e) { this.ffz_last_move = Date.now(); 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) 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; 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); } cls.prototype.ffzSetSmoothScroll = function(value) { this.ffz_smooth_scroll = value; } for(const inst of instances) this.onMount(inst); }); 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.ffzInstallHandler(); if ( this.freeze !== 0 ) inst.ffzEnableFreeze(); inst.ffzSetSmoothScroll(this.smoothScroll); } onUnmount(inst) { // eslint-disable-line class-methods-use-this inst.ffzDisableFreeze(); } }