enzo418 / Twitch Chat over Video

// ==UserScript==
// @name         Twitch Chat over Video
// @name:es         Chat Twitch sobre Video
// @namespace    http://tampermonkey.net/
// @version      2.0.4
// @description  Moves the chat to the video as an overlay.
// @description:es  Mueve el chat de twitch sobre el video.
// @copyright     2022, enzo418 (https://openuserjs.org/users/enzo418)
// @downloadURL https://openuserjs.org/install/enzo418/Twitch_Chat_over_Video.user.js
// @updateURL https://openuserjs.org/meta/enzo418/Twitch_Chat_over_Video.meta.js
// @author       enzo418
// @match        https://www.twitch.tv/*
// @grant        none
// @license     Apache-2.0
// ==/UserScript==

// ==OpenUserJS==
// @author enzo418
// ==/OpenUserJS==

/* jshint esversion: 8 */

(async function () {
    'use strict';

    /*
        Updates:
            2.0.4:
                - Clear UserEvents when the window lost focus

            2.0.3:
                - Changed the selector for the vod chat container.

            2.0.2:
                - Fixed chat input wasn't working.
                - Now it moves the whole chat container, including channel points, configuration and emoji menu

            2.0.1:
                - Fixed toggle icon isn't showing

            2.0.0:
                - Fixed scroll not working
                - Fixed chat jumps when hovering the mouse over it
                - Completely reformed the code

            1.1.12:
                - Option to delete a message in the moderator menu

            1.1.11:
                - Fixed scroll issue when banning
                - Default position of modmenu is right above the username

            1.1.10:
                - Fixed create multiple times the overlay
                - Replaced text of "Resume auto scroll" with a circle

            1.1.9:
                - Mod menu added, you can ban, timeout and un-ban a user in full-screen mode
                - To quickly timeout a user for 60 seconds, hold down Control and right-click the name

            1.1.8:
                - When you click on a user in the chat it shows the built-in twitch menu to manage that user, such as timeout and ban.
                - Now you don't need to reload the page in order to see the chat in full-screen when changing between channels or steam/vod

            1.1.7:
                - Fix: input chat not displayed due to a class error again and auto scroll fixed

            1.1.6:
                - Fix: input chat not displayed due to a class error

            1.1.5:
                - Feature: Show the chat input when the mouse enters the chat

            1.1.4:
                - Fixed white box icon due to new twitch css rules.

            1.1.3:
                - Fixed a issue realted to last BTTV Fix.

            1.1.2:
                - Fixed a issue with BTTV pinned messages.
                - Now the overlay can be resized moving its corners (top and right).
                - Now can change the opacity of the overlay pressing the ALT key and rotating the mouse wheel.

            1.1.1:
                - Fixed a bug where the chat input is not displayed

            1.1.0:
                - Added scroll in chat overlay.
                - Added support to VODs.

            1.0.0:
                - Initial release
    */

    /* Details: https://openuserjs.org/scripts/enzo418/Twitch_Chat_over_Video */

    function query(str, source = document) {
        return source.querySelector(str);
    }

    function createElementFromHTML(htmlString) {
        var div = document.createElement('div');
        div.innerHTML = htmlString.trim();
        return div.firstChild;
    }

    function insertStyle(style) {
        var newStyle = document.createElement("style");
        newStyle.innerHTML = style;
        document.getElementsByTagName("head")[0].appendChild(newStyle);
    }

    /**
     * Solution to BTTV pin messages issue => Mutator observer on the pin container to
     * remove all its children when the overlay is toggled (show/hide).
     *
     * BTTV module uses a mutator on the chat so we can't really bypass that.
    **/
    function SolutionBTTVPinIssue () {
        var target = document.querySelector("#bttv-pin-container");
        var observer = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                // remove every "#bttv-pinned-highlight"
                mutation.addedNodes.forEach(node => {
                    node.remove();
                });
            });
        });

        if (target) {
            observer.observe(target, { childList: true });

            // Stop the mutator after a while because we not longer need it.
            setTimeout(() => observer.disconnect(), 1000);
        }
    }

    function selector (selector) {
        var val = null;
        let isVOD = window.location.pathname.indexOf("video") >= 0;
        switch (selector) {
            case "playerContainer":
                val = 'div[data-test-selector="video-player__video-container"]'; 	// Video Player
                break;
            case "videoPlayer":
                val = 'div[data-a-target="video-player"]';						//
                break;
            case "playerLayoutContainer":
                val = '.persistent-player'; 										// Video Player + Video layout
                break;
            case "chatColumnContainer":
                val = 'div[data-a-target="right-column-chat-bar"]';				// chat scrollable
                break;
            case "chatContainer":
                val = isVOD ? '.video-chat ul' : 'div[data-test-selector="chat-scrollable-area__message-container"]';	// chat + input
                break;
            case "chatInputAreaWithoutOverlay":
                val = ".chat-input";                                    // chat input (before overlay) - Children of the container with follower/sub restrictions
                break;
            case "playerLayoutControls":
                val = '.video-ref .player-controls__right-control-group';					//
                break;
            case "fullScreenButton":
                val = 'button[data-a-target="player-fullscreen-button"]';			// full screen button
                break;
            case "chatUserCard":
                val = ".chat-room__viewer-card";                                  //
                break;
            case "realChatInput":
                 val = "[data-a-target='chat-input']";
                break;
            default:
                val = "";
                break;
        }

        return val;
    }

    var isVOD = window.location.pathname.indexOf("video") >= 0;

    // wait until the chat loaded
    while ((isVOD || query(selector("chatContainer")) === null)
        && query(selector("fullScreenButton")) === null) {
        await new Promise(resolve => setTimeout(resolve, 100));
    }

    var checkExist = setInterval(function () {
        if (query(selector("chatContainer")) !== null
            && query(selector("fullScreenButton")) !== null) {
            clearInterval(checkExist);
            main();
        }
    }, 100);

    const getOverlayTemplate = () => `
        <div id="toggle-chat-overlay-fs" class="tw-button-icon--overlay preset-0">
            <button class="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 tw-button-icon--overlay tw-core-button tw-core-button--overlay tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative" aria-label="Toggle Chat Overlay">
                <span class="tw-button-icon__icon">
                    <div style="width: 2rem; height: 2rem;">
                        <div class="tw-align-items-center tw-full-width tw-icon tw-icon--fill tw-inline-flex">
                            <div class="tw-aspect tw-aspect--align-top">
                                <div class="tw-aspect__spacer"></div>
                                <!--- Original SVG author: Becris -->
                                <svg style="overflow: hidden; width: 90%;" class="tw-icon__svg" width="100%" version="1.1" transform="scale(1)" viewBox="0 -80 500 580" style="overflow: hidden;height: auto;width: 100%;height: fit-content;"><path id="svg_3" d="m426.667,0.002l-375.467,0c-28.277,0 -51.2,22.923 -51.2,51.2l0,273.067c0,28.277 22.923,51.2 51.2,51.2l60.587,0l-9.284,83.456c-1.035,9.369 5.721,17.802 15.09,18.837c4.838,0.534 9.674,-1.023 13.292,-4.279l108.919,-98.014l186.863,0c28.277,0 51.2,-22.923 51.2,-51.2l0,-273.067c0,-28.277 -22.923,-51.2 -51.2,-51.2zm17.066,324.267c0,9.426 -7.641,17.067 -17.067,17.067l-193.416,0c-4.217,0.001 -8.284,1.564 -11.418,4.386l-80.452,72.414l6.434,-57.839c1.046,-9.367 -5.699,-17.809 -15.067,-18.856c-0.63,-0.07 -1.263,-0.106 -1.897,-0.105l-79.65,0c-9.426,0 -17.067,-7.641 -17.067,-17.067l0,-273.067c0,-9.426 7.641,-17.067 17.067,-17.067l375.467,0c9.426,0 17.067,7.641 17.067,17.067l0,273.067l-0.001,0z" fill="#fff"/>
                                <rect id="svg_chat_input" height="122" width="369" y="200.4335" x="56.4335" stroke-width="0" stroke="#000" fill="#fff"/>
                                <line id="svg_overlay_hide" stroke="#ffffff" stroke-linecap="undefined" stroke-linejoin="undefined" id="svg_6" y2="348.43349" x2="448.43349" y1="26.43349" x1="30.4335" stroke-width="28.5" fill="none"/>
                                </svg>
                            </div>
                        </div>
                    </div>
                </span>
            </button>
        </div>
    `;

    const getAutoScrollButtonTemplate = (style, preset) => `
    ${(preset === 0 &&
            `<button style="left: 0px;bottom: 0px;position: absolute;
            background-color: rgb(0, 0, 0);z-index: 999999;${style}" id="resume-auto-scroll" class="tw-absolute tw-bottom-0 tw-pd-x-1">Resume auto scroll</button>`
        ) || ""}
    ${(preset === 1 &&
            `<button style="background-color: rgb(10 1 1 / 34%);z-index: 999999;display: block;right: 0px;padding: 5px;${style}" id="resume-auto-scroll" class="tw-absolute tw-bottom-0 tw-pd-x-1">
            <div clas="circle" style="content: '';width: 5px;height: 5px;border-radius: 5px;background-color: #8500ff;"></div>
        </button>`
        ) || ""}
    `;

    const getModeratorMenuStyle = () => `#container-chat-moderator-menu { z-index: 9999999; position: absolute; top: 18px; background-color: #18181b; padding: 0.2rem; /* border: 1px #fff solid; */ text-align: center; text-transform: uppercase; display: flex; flex-direction: column; color: #fff; border-radius: 3px; } #container-chat-moderator-menu .close-button { position: absolute; top: 0; right: 0; font-size: small; font-weight: bolder; color: #f00; background-color: #0000007d; padding: 0 0.2rem; cursor: pointer; } #container-chat-moderator-menu .mod-timeout { display: flex; flex-direction: column; } #container-chat-moderator-menu .mod-timeout span { margin-right: 0.5rem; } #container-chat-moderator-menu .mod-timeout .container-timeout-options { display: flex; flex-direction: row; } #container-chat-moderator-menu .mod-timeout .container-timeout-options .timout-time { margin-right: 0.5rem !important; border: 0 !important; padding: 0 0.2rem; border-radius: 3px; text-transform: none; } #container-chat-moderator-menu .mod-timeout .container-timeout-options .timout-time:hover { background-color: #a7bdf1e0; color: black; } #container-chat-moderator-menu .mod-option-selectable { cursor: pointer; } #container-chat-moderator-menu .mod-option-selectable:hover { background-color: #1d4a71f7; } #container-chat-moderator-menu .option-ban.mod-option-selectable:hover { background-color: #7b1717; } #chat-moderator-menu > div { border-bottom: 0.1rem solid #070708; padding: 0.2rem 0; border-radius: 3px; }`;

    const getModeratorMenuTemplate = () => `
    <div id="container-chat-moderator-menu">
        <div id="chat-moderator-menu">
            <span class="close-button">x</span>
            <!--     <div>un-timeout</div> -->
            <div class="mod-timeout">
                <span>timeout</span>
                <div class="container-timeout-options">
                <div data-timeout="60" class="timout-time mod-option-selectable">
                    1m
                </div>
                <div data-timeout="300" class="timout-time mod-option-selectable">
                    5m
                </div>
                <div data-timeout="600" class="timout-time mod-option-selectable">
                    10m
                </div>
                <div data-timeout="1800" class="timout-time mod-option-selectable">
                    30m
                </div>
                </div>
            </div>
            <div data-unban="true" class="mod-option-selectable">un-ban</div>
            <div data-deletemessage="true" class="mod-option-selectable">delete message</div>
            <div data-ban="true" class="mod-option-selectable option-ban">ban</div>
        </div>
    </div>`;

    const getStyles = () => `
        #overlay-fs {
            position: absolute; display:none; z-index: 9999;
            bottom: 10%; border-radius: 2px;right: 0 !important;
        }

        #overlay-fs #chat-container-fs {
            color:white; height: 100%; overflow-y: scroll; overflow-x:hidden;
        }

        /** hide chat scrollbar */
        #chat-container-fs::-webkit-scrollbar { display: none; }

        #change-height-overlay-fs {
            width: 100%; height: 17px; cursor: row-resize; position: absolute; top: 0; z-index: 100;
        }

        #change-width-overlay-fs {
            width: 17px; height: 100%; cursor: col-resize; position: absolute; right: 0; z-index: 100;
        }

        #toggle-chat-overlay-fs.preset-0 #svg_chat_input, .preset-2 #svg_chat_input, .preset-0 #svg_overlay_hide, .preset-1 #svg_overlay_hide {
            display: none;
        }

        #toggle-chat-overlay-fs.preset-1 #svg_chat_input {
            display: block;
        }

        #toggle-chat-overlay-fs.preset-2 #svg_overlay_hide {
            display: block;
        }

        /** this fixed the jumping thing */
        div#chat-container-fs {
            padding-bottom: 1rem;
        }
    `;

    const getNullDivElement = () => `<div style="display:none"></div>`;

    /**
     * Abstract
    **/
    class ElementController {
        constructor (elm) {
            // if element is null or undefined use an empty div hidden so it doesn't break
            this._element = elm || createElementFromHTML(getNullDivElement());
            this.initialParent = this._element.parentNode;
        }

        set element (el) {
            this._element = el;
            this.initialParent = el.parentNode;
        }

        get element() {
            return this._element;
        }

        hide () {
            this.element.style.display = "none";
        }

        show () {
            this.element.style.display = "block";
        }

        /**
         * @param {double} width
        **/
        set width(width) {
            this.element.style.width = width + "px";
        }

        /**
         * @return {double} width
        **/
        get width() {
            return parseFloat(this.element.style.width.replace("px", ""));
        }

        /**
         * @param {double} height
        **/
        set height(height) {
            this.element.style.height = height + "px";
        }

        /**
         * @return {double} height
        **/
        get height() {
            return parseFloat(this.element.style.height.replace("px", ""));
        }

        /**
         * @param {double} opacity
        **/
        set opacity (opacity) {
            this.element.style.background = `rgb(0 0 0 / ${opacity}%)`;
        }

        /**
         * @return {double} opacity
        **/
        get opacity () {
            let a = this.element.style.background;
            return parseFloat(a.substring(a.indexOf("/ ") + 2, a.indexOf("%")));
        }

        /**
         * @param {HTMLElement} opacity
        **/
        move (to) {
            to.appendChild(this.element);
        }

        /**
         * Moves the element to the initial parent
        **/
        moveToInitialParent () {
            this.initialParent.appendChild(this.element);
        }

        /**
         * Adds a event listener to the element
         * @param {string} event
         * @param {function} callback
         * @param {boolean} useCapture
        **/
        on (event, callback, useCapture = false) {
            this.element.addEventListener(event, callback.bind(this), useCapture);
        }

        /**
         * Sets the element id
         * @param {string} id
        **/
        set id(id) {
            this.element.id = id;
            this._id = "#"+id;
        }

        get id() {
            return this.element.id;
        }

        get idSelector() {
            return this._id;
        }

        setAsDraggable (condition_drag_element) {
            var startX = 0,
                startY = 0,
                lastBottom = window.innerHeight * 5 / 100;

            const maxY = window.innerHeight;
            const minY = 0;

            var maxX = window.innerWidth - this.element.getBoundingClientRect().width;
            const minX = 0;

            this.element.onmousedown = (e) => {
                e = e || window.event;
                if (condition_drag_element(e.target)) {
                    // recalculate bound
                    maxX = window.innerWidth - this.element.getBoundingClientRect().width;

                    e.preventDefault();
                    // get the mouse cursor position at startup:
                    startX = e.clientX;
                    startY = e.clientY;

                    document.onmouseup = () => {
                        // stop moving when mouse button is released:
                        document.onmouseup = null;
                        document.onmousemove = null;
                    };

                    // call a function whenever the cursor moves:
                    document.onmousemove = (e) => {
                        e = e || window.event;
                        e.preventDefault();

                        // move Y
                        lastBottom += startY - e.clientY;
                        lastBottom = lastBottom < minY ? minY : (lastBottom > maxY ? maxY : lastBottom);
                        this.element.style.bottom = lastBottom + "px";

                        // move X
                        var left = this.element.offsetLeft - (startX - e.clientX);
                        left = left < minX ? minX : (left >= maxX ? maxX : left);
                        this.element.style.left = left + "px";

                        startX = e.clientX;
                        startY = e.clientY;
                    };
                }
            };
        }
    }

    /**
     * This class represents an Element that is able to change the height of another element.
    **/
    class ElementChangeHeight extends ElementController {
        constructor(resizableElement, initialHeigth, id = "", heightChangeRate = 10, callback_onchange) {
            super(document.createElement("div"));

            this.id = id;

            this.target = resizableElement;
            this.initialHeigth = initialHeigth;
            this.heightChangeRate = heightChangeRate;

            this.callback_onchange = callback_onchange;

            this.setEventsChangeHeight();
        }

        /**
         * @param {Float32Array} heightChangeRate
        **/
        set heightChangeRate (heightChangeRate) {
            this._heightChangeRate = heightChangeRate;
        }

        get heightChangeRate () {
            return this._heightChangeRate;
        }

        /**
         * @param {HTMLElement} target
        **/
        set target (target) {
            this._target = target;
        }

        get target () {
            return this._target;
        }

        /**
         * @param {Float32Array} initialHeigth
        **/
        set initialHeigth (initialHeigth) {
            this._initialHeigth = initialHeigth;
        }

        get initialHeigth () {
            return this._initialHeigth;
        }

        setEventsChangeHeight() {
            var startY = 0, heightChange = this.initialHeigth, height = 0;

            this.element.onmousedown = (e) => {
                e.preventDefault();

                startY = e.clientY;

                document.onmouseup = () => {
                    this.callback_onchange(height);

                    // stop moving when mouse button is released:
                    document.onmouseup = null;
                    document.onmousemove = null;
                };

                // call a function whenever the cursor moves:
                document.onmousemove = (e) => {
                    e = e || window.event;
                    e.preventDefault();

                    heightChange += startY - e.clientY;

                    height = (this.heightChangeRate + heightChange);
                    this.target.style.height = height + "px";

                    this.initialHeigth = height;

                    startY = e.clientY;
                };
            };
        }
    }

    /**
     * This class represents an Element that is able to change the width of another element.
    **/
    class ElementChangeWidth extends ElementController {
        constructor (resizableElement, initialWidth, id = "", widthChangeRate = 10, callback_onchange) {
            super(document.createElement("div"));
            this.id = id;
            this.callback_onchange = callback_onchange;
            this.setEventsChangeWidth(this.element, resizableElement, initialWidth, widthChangeRate);
        }

        setEventsChangeWidth (elmnt, target, initialWidth, widthChangeRate) {
            var startX = 0, widthChange = initialWidth, width = 0;

            elmnt.onmousedown = (e) => {
                e.preventDefault();

                startX = e.clientX;

                document.onmouseup = () => {
                    this.callback_onchange(width);

                    // stop moving when mouse button is released:
                    document.onmouseup = null;
                    document.onmousemove = null;
                };

                // call a function whenever the cursor moves:
                document.onmousemove = (e) => {
                    e = e || window.event;
                    e.preventDefault();

                    widthChange += e.clientX - startX;

                    width = (widthChangeRate + widthChange);
                    target.style.width = width + "px";

                    initialWidth = width;

                    startX = e.clientX;
                };
            };
        }
    }

    class OverlayContainer extends ElementController {
        constructor (opacity, width, height, callback_onchangeheight, callback_onchangewidth) {
            super(document.createElement("div"));
            this.id = "overlay-fs";
            this.opacity = opacity;
            this.width = width;
            this.height = height;

            this.changeHeight = new ElementChangeHeight(
                this.element,
                height,
                "change-height-overlay-fs",
                10,
                callback_onchangeheight
            );

            this.changeWidth = new ElementChangeWidth(
                this.element,
                width,
                "change-width-overlay-fs",
                10,
                callback_onchangewidth
            );

            this.changeHeight.move(this.element);
            this.changeWidth.move(this.element);

            this.setAsDraggable(
                (el) => el.localName !== "textarea" // allow to select text from the input
                        && el.localName !== "span" // allow to select text from the chat
                        && el.id != this.changeHeight.id
                        && el.id != this.changeWidth.id
            );
        }

        moveToPlayer () {
            // set the overlay to be a children of the main video container
            var mainPlayer = query(selector("playerContainer"));
            this.move(mainPlayer);
        }
    }

    class ChatContainer extends ElementController {

        /**
         * @param {HTMLElement} element
         * @param {BigInteger} opacity
         * @param {UserEvents} userEvents
        **/
        constructor (element, opacity, userEvents) {
            super(element);

            this.opacity = opacity;
            this.events = userEvents;
            this.lastTimestamp = 0;
        }

        /**
         * @param {HTMLElement} el
        **/
        set element(el) {
            super.element = el;

            // since the element of the controller can change set all
            // the listeners/attributes in the setter of the element
            this.id = "chat-container-fs";
            this.on("scroll", this.onScroll);
        }

        get element() {
            return super.element;
        }

        autoScrollTick () {
            this.element.scrollTop = this.element.scrollHeight;
        }

        stopAutoScroll () {
            clearInterval(this.interval);
            this.interval = null;
            this.buttonAutoScroll.show();
        }

        startAutoScroll () {
            this.stopAutoScroll();
            this.interval = setInterval(this.autoScrollTick.bind(this), 100);
            this.events.mouseWheelEvent = false;
            this.buttonAutoScroll.hide();
        }

        get scrollPosition () {
            return this.element.scrollTop;
        }

        set scrollPosition (pos) {
            this.element.scrollTop = pos;
        }

        /**
         * @param {ToggleOverlayButton} buttonAutoScroll
        **/
        set autoScrollButton (buttonAutoScroll) {
            this.buttonAutoScroll = buttonAutoScroll;
        }

        /**
         * @return {ToggleOverlayButton} buttonAutoScroll
        **/
        get autoScrollButton () {
            return this.buttonAutoScroll;
        }

        onScroll (e) {
            if (this.interval === null ) {
                // reached bottom
                if (this.element.scrollHeight - this.element.scrollTop === this.element.clientHeight) {
                    this.startAutoScroll();
                    this.lastTimestamp = e.timeStamp;
                }
            } else {
                // remove auto-scroll
                if (!this.events.isKeyPressed("Alt")
                    && (this.events.isAnyPressed(["PageUp", "PageDown"]) || this.events.mouseWheelEvent)
                    && e.timeStamp - this.lastTimestamp > 1500) {
                    this.stopAutoScroll();
                }
            }
        }

        static getChatContainerFromDOM () {
            return query(selector("chatContainer"));
        }
    }

    class ChatInput extends ElementController {
        constructor (element, opacity) {
            super(element);
            this.opacity = opacity;
        }

        /**
         * @param {HTMLElement} el
        **/
        set element(el) {
            super.element = el;

            // since the element of the controller can change set all
            // the listeners/attributes in the setter of the element
            this.id = "chat-input-fs";
        }

        get element() {
            return super.element;
        }

        hide () {
            this.element.setAttribute('style', "display: none !important");
            this.getRealInput().removeEventListener("click", this.onClick.bind(this));
        }

        show () {
            this.element.setAttribute('style', "display: block !important");
            this.getRealInput().addEventListener("click", this.onClick.bind(this));
        }

        /** this class holds the input container
         *  and with this function returns the
         *  textarea element
        **/
        getRealInput () {
            return this.element.querySelector(selector("realChatInput"));
        }

        /**
         * Moves the element to the initial parent
        **/
        moveToInitialParent () {
            this.initialParent.insertBefore(
                this.element,
                this.initialParent.children[this.initialParent.children.length - 1]
            );
        }

        onClick() {
            const input = this.getRealInput();
            input.focus(); // triggers chat_input:focus-within
            input.setAttribute("data-focus-visible-added", "");
        }

        static getChatInputFromDOM () {
            let node = query(selector("chatInputAreaWithoutOverlay"));
            return node;
        }
    }

    class UserCard extends ElementController {
        constructor (element) {
            super(element);
        }

        static getUserCardFromDOM () {
            return query(selector("chatUserCard"));
        }
    }

    class ToggleOverlayButton extends ElementController {

        /**
         * @param {ChatContainer} chatContainer
         * @param {ChatInput} chatInput
         * @param {Configuration} config
         * @param {int} preset
        **/
        constructor (chatContainer, chatInput, config, preset) {
            super(createElementFromHTML(getOverlayTemplate(preset)));

            this.id = "toggle-chat-overlay-fs";

            this.preset = preset;
            this.chatContainer = chatContainer;
            this.chatInput = chatInput;
            this.config = config;

            this.on("click", this.onClick);
        }

        /**
         * @param {int} preset
        **/
        set preset(preset) {
            this.element.classList.replace("preset-" + this._preset, "preset-" + preset);
            this._preset = preset;
        }

        get preset () {
            return this._preset;
        }

        /**
         * Moves the element to the elemnt
         * @param {HTMLElement} to
        **/
        move (to) {
            to.insertBefore(this.element, to.children[0]);
        }

        /**
         * @param {Event} e
        **/
        onClick (e) {
            var isVOD = this.config.get("is_vod");
            var matching = e.target.closest(this.idSelector);

            if (matching) {
                var preset = this.preset + 1;
                preset = preset === 3 ? 0 : (preset === 1 && isVOD ? 2 : preset);

                switch (preset) {
                    case 0:
                        this.chatContainer.show();

                        if (!isVOD) {
                            this.chatInput.hide();
                        }
                        break;
                    case 1:
                        this.chatInput.show();
                        break;
                    case 2:
                        this.chatContainer.hide();
                        break;
                }

                this.preset = preset;
            }
        }
    }

    class AutoScrollButton extends ElementController {

        /**
         * @param {ChatContainer} chatContainerInstance
         * @param {boolean} stop_on_hover
        **/
        constructor (chatContainerInstance, stop_on_hover) {
            super(
                createElementFromHTML(
                    getAutoScrollButtonTemplate(
                        "display: none",
                        stop_on_hover ? 1 : 0
                    )
                )
            );

            this.chat = chatContainerInstance;

            this.on("click", this.onClick);
        }

        onClick () {
            this.chat.startAutoScroll();
            this.hide();
        }
    }

    class ModeratorMenu extends ElementController {

        /**
         * @param {Moderator} moderator
         * @param {ChatContainer} chatContainer
        **/
        constructor (chatContainer) {
            super(createElementFromHTML(getModeratorMenuTemplate()));
            this.chat = chatContainer;

            this.on("click", this.onClick);
            this.hide();
        }

        /**
         * @param {Moderator} moderator
        **/
        set moderator (moderator) {
            this._moderator = moderator;
        }

        get moderator () {
            return this._moderator;
        }

        onClick (e) {
            var lastScrollTop = this.chat.scrollPosition;

            this.moderator.handleMenuClickEvent(e.target);

            // fix: twitch resetting the chat scroll to the top after ban...
            this.chat.scrollPosition = lastScrollTop;
        }

        centerMenu (chat_line, author_label, scrollPosition) {
            var top = chat_line.offsetTop - scrollPosition - this.element.clientHeight / 2;
            this.element.style.top = top + "px";
            this.element.style.left = author_label.offsetLeft / 2 + "px";
        }
    }

    /**
     * Abstract
    **/
    class ReactInstance {
        constructor () { }

        // gets the property name that holds the react component instance
        static getReactInstance(element) {
            const key = Object.keys(element).find(k => k.toLowerCase().indexOf("internalinstance") >= 0);
            return key && element[key];
        }

        // searches a react component that meets the condition
        static getReactProp(element, condition, max_deep = 30) {
            var node = this.getReactInstance(element);
            while (node && !condition(node) && max_deep > 0) {
                node = node.return; // node.return is the parent instance
                max_deep--;
            }

            return max_deep >= 0 ? node : null;
        }
    }

    class Twitch {

        /**
         * @param {ChatInput} chatInputInstance This element has all the props inside that we need, e.g. if the user is mod
        **/
        constructor (chatInputInstance) {
            this.chat = chatInputInstance.element;
            this.lastUrl = window.location.href;
            this.setProps();
        }

        /**
         * To update the props change the chat
         * @param {HTMLElement} chat
        **/
        set chat (chat) {
            this._chat = chat;
            this.setProps();
        }

        get chat () {
            return this._chat;
        }

        isCurrentUserModerator () {
            return this.props?.isCurrentUserModerator;
        }

        sendMessage (msg) {
            this.props.onSendMessage(msg);
        }

        setProps () {
            if (!this.props || this.lastUrl != window.location.href) {
                this.lastUrl = window.location.href;
                this.props = ReactInstance.getReactProp(
                    this.chat,
                    (node) => node.stateNode && node.stateNode.props && node.stateNode.props.onSendMessage
                );

                if (this.props) {
                    this.props = this.props.stateNode.props;
                }
            }
        }
    }

    /** This handles all the commands to ban/timeout and un-ban users
     *
     * You can directly ban a user by calling banUser, same with timeout.
     * But to handle the difference in time between the click on a user and
     * the time that the user choices an option, first you need to set the user
     * or the messageid.
     * Then handleMenuClickEvent uses this user to apply the command.
     *
     * https://help.twitch.tv/s/article/chat-commands
    **/
    class Moderator {

        /**
         * @param {Twitch} twitch
         * @param {ModeratorMenu} moderatorMenu
        **/
        constructor (twitch, moderatorMenu) {
            this.twitch = twitch;
            this.modMenu = moderatorMenu;
        }

        ban (user) {
            this.sendCommand("/ban " + user);
        }

        unban (user) {
            this.sendCommand("/unban " + user);
        }

        timeout (user, time) {
            this.sendCommand("/timeout " + user + " " + time);
        }

        deleteMessage (messageid) {
            this.sendCommand("/delete " + messageid);
        }

        handleMenuClickEvent (element) {
            if ("ban" in element.dataset) {
                this.ban(this.user);
            } else if ("unban" in element.dataset) {
                this.unban(this.user);
            } else if ("timeout" in element.dataset) {
                this.timeout(this.user, parseInt(element.dataset.timeout));
            } else if ("deletemessage" in element.dataset) {
                this.deleteMessage(this.messageid);
            }

            this.modMenu.hide();
        }

        /**
         * @param {string} user
        **/
        set user (user) {
            this._user = user;
        }

        get user () {
            return this._user;
        }

        /**
         * @param {string} messageid
        **/
        set messageid (messageid) {
            this._messageid = messageid;
        }

        get messageid () {
            return this._messageid;
        }

        sendCommand (command) {
            this.twitch.sendMessage(command);
        }
    }

    class Configuration {
        constructor (configuration) {
            this.config = configuration;
        }

        set(key, value) {
            this.config[key] = value;
        }

        get(key) {
            return this.config[key];
        }
    }

    /**
     * This class keeps a register of the keys pressed (only the ones that you pass on observe)
     *
     * Get the key name (not key code) from this list https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values
     * or by pressing it https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
    **/
    class UserEvents {
        /**
         * @param {Array<String>} observe
        **/
        constructor (observe = []) {
            this.keysPressed = [];

            this.observe = observe;

            document.body.addEventListener('keydown', (event) => {
                if (this.observe.includes(event.key)) {
                    this.keyPressed(event.key);
                }
            });

            document.body.addEventListener('keyup', (event) => {
                if (this.observe.includes(event.key)) {
                    this.keyRealeased(event.key);
                }
            });

            this._mouseWheelEvent = false;
        }

        isKeyPressed (key) {
            return this.keysPressed.indexOf(key) >= 0;
        }

        /**
         * Checks if any of the keys is pressed
         * @param {Array<String>} keys
        **/
        isAnyPressed (keys) {
            return this.keysPressed.findIndex(k => keys.indexOf(k) >= 0) >= 0;
        }

        /**
         * @param {string} key
        **/
        keyPressed (key) {
            if (!this.isKeyPressed(key)) {
                this.keysPressed.push(key);
            }
        }

        keyRealeased (key) {
            this.keysPressed = this.keysPressed.filter(k => k != key);
        }

        /**
         * Removes all keys pressed and not released until now
        **/
        releaseAll () {
            this.keysPressed = [];
        }

        /**
         * @param {string} key
        **/
        observeKey (key) {
            this.observe.push(key);
        }

        /**
         * @param {boolean} value
        **/
        set mouseWheelEvent(value) {
            this._mouseWheelEvent = value;
        }

        /**
         * @return {boolean} is mouse wheel event
        **/
        get mouseWheelEvent() {
            return this._mouseWheelEvent;
        }
    }

    class ChatFS {
        constructor () {
            this.enabled = null;
        }

        get preset() {
            return this.toggleOverlayButton.preset;
        }

        onClick (e) {
            const { target } = e;
            if (!isVOD && this.twitch.isCurrentUserModerator()) {
                if (target.classList.value.indexOf("author") >= 0) {
                    var usermessage = ReactInstance.getReactProp(target, node => node && node.stateNode && node.stateNode.props && node.stateNode.props.message);
                    if (usermessage) {
                        const { id: messageid, user } = usermessage.stateNode.props.message;

                        this.moderator.messageid = messageid;
                        this.moderator.user = user.userLogin;

                        if (!this.events.isKeyPressed("Control")) {
                            var chat_line = e.path.find(el => el.classList.contains("chat-line__message")); // chat_line should be the the div that contains the message content
                            this.modMenu.centerMenu(chat_line, target, this.chatContainer.scrollPosition);
                            this.modMenu.show();
                        } else {
                            this.moderator.timeoutUser(user.userLogin, 60);
                        }
                    }

                    e.stopPropagation();
                }
            }
        }

        onWheel (e) {
            if (this.events.isKeyPressed("Alt")) {
                e.preventDefault();
                var bcop = this.config.get("overlay_opacity");
                bcop += 5 * e.deltaY / Math.abs(e.deltaY);
                bcop = bcop > 100 ? 100 : (bcop < 0 ? 0 : bcop);
                this.config.set("overlay_opacity", bcop);

                this.overlay.opacity = bcop;
                this.chatContainer.opacity = bcop;
                this.chatInput.opacity = bcop;
            } else {
                this.events.mouseWheelEvent = true;

                // force to change the scroll position
                this.chatContainer.scrollPosition = this.chatContainer.scrollPosition + e.deltaY;
            }
        }

        onMouseEnter (e) {
            if (!this.config.get("is_vod")) {
                this.chatInput.show();
                this.chatInput.opacity = this.config.get("overlay_opacity");
            }

            if (e.target.classList.contains("chat-line__message") && this.config.get("stop_on_hover")) {
                this.chatContainer.stopAutoScroll();
            }
        }

        onMouseLeave () {
            if (!this.config.get("is_vod") && this.preset != 1) {
                this.chatInput.hide();
            }

            if (this.config.get("stop_on_hover")) {
                this.chatContainer.startAutoScroll();
            }
        }

        hide () {
            this.overlay.hide();

            /// move chat
            this.chatContainer.moveToInitialParent();
            this.chatContainer.stopAutoScroll();
            this.chatContainer.opacity = 0;

            if (!this.config.get("is_vod")) {
                this.chatInput.moveToInitialParent();
                this.chatInput.show();
            }

            // hide scroll button
            this.autoScrollButton.hide();
            this.toggleOverlayButton.hide();

            if (!this.config.get("is_vod") && !this.twitch.isCurrentUserModerator() && this.userCard.element) {
                this.userCard.moveToInitialParent();
            }

            this.enabled = false;
        }

        show () {
            this.overlay.height = this.config.get("overlay_height");
            this.overlay.width = this.config.get("overlay_width");
            this.overlay.moveToPlayer();
            this.overlay.show();

            // update chat container element bc it changes when you change the channel
            this.chatContainer.element = query(selector("chatContainer"));
            this.chatContainer.move(this.overlay.element);

            if (!this.config.get("is_vod")) {
                this.chatInput.element = ChatInput.getChatInputFromDOM();

                this.twitch.chat = this.chatInput.element;

                if (this.preset !== 1) {
                    this.chatInput.hide();
                }

                // Set the background of the chat input to transparent
                var textarea = this.chatInput.getRealInput();
                if (textarea) {
                    textarea.style.background = "transparent";
                }

                this.chatInput.move(this.overlay.element);
                this.chatInput.opacity = this.config.get("overlay_opacity");
                console.log("opacity:", this.config.get("overlay_opacity"));
            }

            this.chatContainer.startAutoScroll();

            this.toggleOverlayButton.move(query(selector("playerLayoutControls")));
            this.toggleOverlayButton.show();

            /// set video close to the bottom. Is not relevant to the overlay but it looks nice
            // query(selector("playerContainer")).style.bottom = "0px"

            if (!this.config.get("is_vod") && !this.twitch.isCurrentUserModerator() && this.userCard.element) {
                this.userCard.move(this.overlay.element);
            }

            if (this.preset === 2) {
                this.overlay.hide();
            }

            this.enabled = true;
        }

        setEvents () {
            this.overlay.on("click", this.onClick.bind(this));
            this.overlay.on("wheel", this.onWheel.bind(this));
            this.overlay.on("mouseenter", this.onMouseEnter.bind(this));
            this.overlay.on("mouseleave", this.onMouseLeave.bind(this));
        }

        create () {
            let styles = getModeratorMenuStyle() + getStyles() /*add all the others styles*/;

            this.config = new Configuration({
                // chat
                stop_on_hover: false,

                // overlay
                overlay_width: window.innerWidth * 0.23,
                overlay_height: window.innerHeight * 0.5,
                overlay_opacity: 23,
                initial_preset: 0,

                is_vod: window.location.pathname.indexOf("video") >= 0
            });

            this.events = new UserEvents(["Alt", "PageUp", "PageDown", "Control"]);

            this.overlay = new OverlayContainer(
                this.config.get("overlay_opacity"),
                this.config.get("overlay_width"),
                this.config.get("overlay_height"),
                this.onChangeOverlayHeight.bind(this),
                this.onChangeOverlayWidth.bind(this)
            );

            this.chatContainer = new ChatContainer(
                ChatContainer.getChatContainerFromDOM(),
                this.config.get("overlay_opacity"),
                this.events
            );

            this.chatInput = new ChatInput(
                ChatInput.getChatInputFromDOM(),
                this.config.get("overlay_opacity")
            );

            this.userCard = new UserCard(
                UserCard.getUserCardFromDOM()
            );

            this.toggleOverlayButton = new ToggleOverlayButton(
                this.overlay,
                this.chatInput,
                this.config,
                this.config.get("initial_preset")
            );

            this.autoScrollButton = new AutoScrollButton(
                this.chatContainer,
                this.config.get("stop_on_hover")
            );

            this.modMenu = new ModeratorMenu(this.chatContainer);

            this.twitch = new Twitch(this.chatInput);
            this.moderator = new Moderator(this.twitch, this.modMenu);

            this.modMenu.moderator = this.moderator;
            this.chatContainer.buttonAutoScroll = this.autoScrollButton;

            this.modMenu.move(this.overlay.element);
            this.autoScrollButton.move(this.overlay.element);

            this.setEvents();

            // Clear UserEvents when the window lost focus
            window.addEventListener('blur', () => {
                this.events.releaseAll();
            });

            insertStyle(styles);
        }

        toggle () {
            // if enabled is null then is the first time it's toggled.
            if (this.enabled === null) {
                this.create();
            }

            // is vod?
            this.config.set("is_vod", window.location.pathname.indexOf("video") >= 0);

            // restart key press events
            this.events.releaseAll();

            SolutionBTTVPinIssue();

            // Handle toggle overlay when user enters fullscreen mode
            if (!this.enabled) {
                this.show();
            } else {
                this.hide();
            }
        }

        onChangeOverlayHeight(height) {
            this.config.set("overlay_height", height);
        }

        onChangeOverlayWidth(width) {
            this.config.set("overlay_width", width);
        }
    }

    function main() {
        var isMouseEvent = null;
        var overlay = new ChatFS();

        // allow changes from console for testing
        window.overlay = overlay;
        window.selector = selector;
        window.query = query;

        //// TOGGLE OVERLAY ON FULL SCREEN:
        // If the button is pressed, the "click" event is triggered first and the isMouseEvent is set to
        // true so that when the fullscreen event is triggered it doesnt toggle the overlay again.
        query(selector("fullScreenButton")).addEventListener("click", (e) => {
            e.preventDefault();

            isMouseEvent = true;
            overlay.toggle();
        });

        document.onfullscreenchange = () => {
            if (!isMouseEvent) {
                overlay.toggle();
            }

            isMouseEvent = false;
        };
    }
})();