Twitch Pinned Streamers - twitch.tv

Pin Twitch streamers on sidebar without being logged in.

// ==UserScript==
// @name        Twitch Pinned Streamers - twitch.tv
// @description Pin Twitch streamers on sidebar without being logged in.
// @namespace   https://github.com/vekvoid/UserScripts
// @homepageURL https://github.com/vekvoid/UserScripts/
// @supportURL  https://github.com/vekvoid/UserScripts/issues
// @match        *://*.twitch.tv/*
// @grant       none
// @icon https://www.google.com/s2/favicons?domain=twitch.com
// @version     1.5.5
// ==/UserScript==

const logLevels = {
  trace: 10,
  debug: 20,
  info: 30,
  warn: 40,
  error: 50,
  fatal: 60,
};

const NAME = 'Twitch Pinned Streamers';
const CURRENT_LOG_LEVEL = logLevels.info;
const MINUTES_SINCE_FOCUS_LOST_FOR_REFRESH = 1;
const REFRESH_DISPLAYED_DATA_DELAY_MINUTES = 5;

const ALL_RELEVANT_CONTENT_SELECTOR = '.dShujj';
const HEADER_CLONE_SELECTOR =
  '.side-nav-header[data-a-target="side-nav-header-expanded"]';
const BTN_CLONE_SELECTOR =
  '.side-nav.side-nav--expanded[data-a-target="side-nav-bar"]';
const BTN_INNER_CLONE_SELECTOR =
  'button[data-a-target="side-nav-arrow"]';
const NAV_CARD_CLONE_SELECTOR =
  '.side-nav-section .side-nav-card:has(a[data-a-id="recommended-channel-0"] .side-nav-card__avatar)';

const FOLLOW_BUTTON_CONTAINER_SELECTOR =
  '#live-channel-stream-information div[data-target="channel-header-right"] div:first-child';
const FOLLOW_BUTTON_OFFLINE_CONTAINER_SELECTOR =
  '#offline-channel-main-content div[data-target="channel-header-right"] div:first-child';

const TWITCH_GRAPHQL = 'https://gql.twitch.tv/gql';
const CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'; // From Alternate Player for Twitch.tv

const logger = {
  trace: (...args) =>
    logLevels.trace >= CURRENT_LOG_LEVEL && console.trace(`${NAME}:`, ...args),
  debug: (...args) =>
    logLevels.debug >= CURRENT_LOG_LEVEL && console.log(`${NAME}:`, ...args),
  info: (...args) =>
    logLevels.info >= CURRENT_LOG_LEVEL && console.info(`${NAME}:`, ...args),
  warn: (...args) =>
    logLevels.warn >= CURRENT_LOG_LEVEL && console.warn(`${NAME}:`, ...args),
  error: (...args) =>
    logLevels.error >= CURRENT_LOG_LEVEL && console.error(`${NAME}:`, ...args),
  fatal: (...args) =>
    logLevels.fatal >= CURRENT_LOG_LEVEL && console.fatal(`${NAME}:`, ...args),
};

const css = `
  .tps-pinned-container {
    min-height: 0;
    overflow: hidden;
    transition: all 250ms ease 0ms;
  }

   .tps-pinned-container div .tps-remove-pinned-streamer {
    opacity: 0;
  }

  .tps-pinned-container div :hover .tps-remove-pinned-streamer {
    opacity: 0.3;
  }

  .tps-remove-pinned-streamer {
    transition: all 150ms ease 0ms;
    opacity: 0.3;
  }

  .tps-remove-pinned-streamer:hover {
    opacity: 1 !important;
  }

  #tps-pin-current-streamer-button[data-a-target="pin-button"]:hover {
    background-color: var(--color-background-button-primary-hover) !important;
  }

  #tps-pin-current-streamer-button[data-a-target="unpin-button"]:hover {
    background-color: var(--color-background-button-secondary-hover) !important;
  }


  /* Start Menu Styles */

  .tps-menu-container {
    padding: 0px;
    display: inline-block;
    position: absolute;
    transform: translate(10px, -5px);
    z-index: 99;
  }

  .tps-menu-container button {
    border: 0;
    border-radius: 0.4rem;
    margin: 0;
    padding: 0;
    height: 3rem;
    width: 3rem;
    background-color: #1f1f23;
    cursor: pointer;

    display: inline-flex;
    -moz-box-align: center;
    align-items: center;
    -moz-box-pack: center;
    justify-content: center;
    user-select: none;
  }
  .tw-root--theme-light .tps-menu-container button {
    background-color: rgba(0, 0, 0, 0) !important;
  }

  .tps-menu-container button:hover {
    background-color: #393940;
  }
  .tw-root--theme-dark .tps-menu-container button:hover {
    background-color: rgba(173, 173, 184, 1.35) !important;
  }

  .tps-menu-icon svg {
    fill: white;
  }
  .tw-root--theme-light .tps-menu-icon svg {
    fill: #0e0e10 !important;
  }

  .tps-menu-dropdown {
    display: none;
    position: absolute;
    top: 60px;
    left: 20px;
    width: 200px;
    overflow: hidden;
    opacity: 0;

    border-radius: 0.6rem !important;
    background-color: #1f1f23 !important;
    box-shadow: 0 4px 8px rgba(0,0,0,0.4), 0 0px 4px rgba(0,0,0,0.4) !important;
    color: inherit !important;
  }

  .tps-menu-dropdown.show {
    display: block;
    opacity: 1;
    transform: translateY(-30px) translateX(-124px);
  }

  .tps-menu-dropdown ul {
    list-style: none;
    padding: 0;
    margin: 10px;
  }

  .tps-menu-dropdown li:last-child {
    border-bottom: none;
  }

  .tps-menu-dropdown a {
    display: block;
    padding: 5px;
    border-radius: 0.4rem;
    color: white;
    text-decoration: none;
  }

  .tps-menu-dropdown a:hover {
    background-color: #393940;
  }

  /* End Menu Styles*/

  /* Current Streamer Pin Button */

  .tw-root--theme-light #tps-pin-current-streamer-button[data-a-target="unpin-button"] {
    background-color: var(--color-background-button-secondary-default);
    color: var(--color-text-button-secondary);
  }

  /* End Current Streamer Pin Button */
`;

let isWorking = false;
let isWorkingPinCurrentStreamer = false;

let isTabVisible = false;

let waitForMainContainer;

const main = () => {
  let relevantContent;

  if (waitForMainContainer) {
    clearInterval(waitForMainContainer);
  }

  waitForMainContainer = setInterval(async () => {
    relevantContent = document.querySelector(ALL_RELEVANT_CONTENT_SELECTOR);
    logger.debug('Searching main conten...')

    if (!relevantContent) {
      return;
    }

    if (relevantContent.childElementCount < 2) {
      return;
    }

    if (!relevantContent.querySelector(HEADER_CLONE_SELECTOR)) {
      return;
    }

    if (
      !relevantContent.querySelector(
        `${BTN_CLONE_SELECTOR} ${BTN_INNER_CLONE_SELECTOR}`
      )
    ) {
      return;
    }

    clearInterval(waitForMainContainer);

    logger.debug('Main content found.');

    // Tab visibility handler

    isTabVisible = !document.hidden;
    document.addEventListener('visibilitychange', async () => {
      if (document.hidden) {
        logger.debug('Tab hidden.');
        isTabVisible = false;
        return;
      }

      logger.debug('Tab visible.');
      isTabVisible = true;

      // Refresh if change to visible
      const lastRefreshedAt = localStorageGetPinnedRefreshedAt();

      if (requireDataRefresh(lastRefreshedAt)) {
        logger.info('Refreshing pinned streamers.');

        await execRefresh();
      }
    });

    // End Tab visibility handler

    injectCSS();

    // Menu

    const observer = new MutationObserver(async () => {
      if (isWorking) {
        return;
      }

      if (document.getElementById('anon-followed')) {
        return;
      }

      const sidebar = relevantContent.querySelector(
        `.side-nav.side-nav--expanded`
      );
      logger.debug(sidebar);
      if (!sidebar) {
        return;
      }

      if (!sidebar.querySelector(`${NAV_CARD_CLONE_SELECTOR}`)) {
        return;
      }

      isWorking = true;

      // '.simplebar-content .side-bar-contents nav div > div > div'
      const sidebarContent = sidebar.querySelector('#side-nav div > div');

      const anonFollowedElement = document.createElement('div');
      anonFollowedElement.id = 'anon-followed';

      anonFollowedElement.innerHTML += pinnedHeader();
      anonFollowedElement.innerHTML +=
        '<div class="tps-pinned-container"></div>';
      sidebarContent.insertBefore(
        anonFollowedElement,
        sidebarContent.childNodes[0]
      );
      pinnedHeaderBehavior();

      await renderPinnedStreamers();

      setInterval(
        async () => {
          if (!isTabVisible) {
            return;
          }

          await renderPinnedStreamers();
          logger.info('Refreshed pinned streamers displayed data');
        },
        REFRESH_DISPLAYED_DATA_DELAY_MINUTES * 60 * 1000
      );

      // Menu link onclick
      document.getElementById('tps-add-streamer').onclick = promptAddStreamer;
      document.getElementById('tps-export').onclick = () => {
        promptExportData(
          localStorageGetAllPinned().map(({ user, pinnedAt }) => ({
            user,
            pinnedAt,
          }))
        );
      };
      document.getElementById('tps-import').onclick = () => {
        promptImportData(async (data) => {
          const isValid = validateLocalStoragePinnedData(data);
          if (isValid) {
            localStorageSetPinned(data);
            await execRefresh();
          }

          return isValid;
        });
      };

      const mainSection = relevantContent.querySelector('main');

      logger.debug(sidebar, mainSection);
      isWorking = false;
      observer.disconnect();
    });
    observer.observe(document.body, { childList: true, subtree: true });

    // Pin current streamer button

    const pinCurrentStreamerObserver = new MutationObserver(async () => {
      if (isWorkingPinCurrentStreamer) {
        return;
      }
      if (document.getElementById('tps-pin-current-streamer-container')) {
        return;
      }

      const contentFound = document.querySelector(`
        ${ALL_RELEVANT_CONTENT_SELECTOR} ${FOLLOW_BUTTON_CONTAINER_SELECTOR} button[data-a-target*="follow-button"],
        ${ALL_RELEVANT_CONTENT_SELECTOR} ${FOLLOW_BUTTON_OFFLINE_CONTAINER_SELECTOR} button[data-a-target*="follow-button"]
      `);
      logger.debug(contentFound);
      if (!contentFound) {
        return;
      }

      isWorkingPinCurrentStreamer = true;

      renderPinCurrentStreamer();

      isWorkingPinCurrentStreamer = false;
      pinCurrentStreamerObserver.disconnect();
    });
    pinCurrentStreamerObserver.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }, 500);
};

(() => {
  logger.info('Started');

  // Modify "locationchange" event
  // From https://stackoverflow.com/a/52809105

  let oldPushState = history.pushState;
  history.pushState = function pushState() {
    let ret = oldPushState.apply(this, arguments);
    window.dispatchEvent(new Event('pushstate'));
    window.dispatchEvent(new Event('locationchange'));
    return ret;
  };

  let oldReplaceState = history.replaceState;
  history.replaceState = function replaceState() {
    let ret = oldReplaceState.apply(this, arguments);
    window.dispatchEvent(new Event('replacestate'));
    window.dispatchEvent(new Event('locationchange'));
    return ret;
  };

  window.addEventListener('popstate', () => {
    window.dispatchEvent(new Event('locationchange'));
  });

  window.addEventListener('locationchange', function () {
    logger.debug('Location changed');
    main();
  });

  main();
})();

const requireDataRefresh = (lastRefreshDate) => {
  if (!lastRefreshDate) {
    return true;
  }

  const now = new Date();

  const differenceMs = now - lastRefreshDate;
  const SECONDS = 1000;
  const MINUTES = 60;
  const differenceMinutes = differenceMs / SECONDS / MINUTES;

  if (differenceMinutes < MINUTES_SINCE_FOCUS_LOST_FOR_REFRESH) {
    return false;
  }

  return true;
};

const refreshPinnedData = async () => {
  const pinned = localStorageGetAllPinned();
  const userNames = pinned.map((p) => p.user);

  const fetchedPinned = await batchGetTwitchUsers(userNames);

  fetchedPinned.forEach((fetched) => {
    const foundIndex = pinned.findIndex(
      (user) => user.user.toLowerCase() === fetched?.user?.toLowerCase()
    );
    if (foundIndex < 0) {
      return;
    }

    pinned[foundIndex] = fetched;
  });

  localStorageSetPinned(pinned);
  localStorageSetPinnedRefreshedAt(new Date());
  logger.debug('Pinned data refreshed.');
};

const execRefresh = async () => {
  try {
    await refreshPinnedData();
    await renderPinnedStreamers();
  } catch (error) {
    logger.warn(`Could not refresh pinned streamers. ${error?.message}`);
  }
};

const injectCSS = () => {
  const style = document.createElement('style');
  document.head.appendChild(style);
  style.appendChild(document.createTextNode(css));
};

const promptAddStreamer = async () => {
  const streamerUser = prompt('Streamer username:');
  if (!streamerUser) {
    return;
  }

  await addStreamer(streamerUser);
};

const addStreamer = async (streamerUser) => {
  const pinned = localStorageGetAllPinned();

  const found = pinned.find(
    (user) => user.user.toLowerCase() === streamerUser.toLowerCase()
  );
  if (found) {
    logger.info(`Streamer '${streamerUser}' already pinned.`);
    return;
  }

  const [user] = await batchGetTwitchUsers([streamerUser]);
  logger.debug(user);
  if (!user.id) {
    const message = `Streamer '${streamerUser}' not found.`;
    logger.warn(message);

    alert(message);
    return;
  }

  user.pinnedAt = new Date().toISOString();
  pinned.push(user);

  localStorageSetPinned(pinned);
  logger.debug(localStorage['tps:pinned']);

  const prevHeight = document
    .querySelector('.tps-pinned-container')
    ?.getBoundingClientRect()?.height;
  const nextHeight =
    prevHeight +
    document
      .querySelector('.tps-pinned-container > div')
      ?.getBoundingClientRect()?.height;
  document.querySelector('.tps-pinned-container').style.height =
    `${prevHeight}px`;

  await renderPinnedStreamers();

  document.querySelector('.tps-pinned-container').style.height =
    `${nextHeight}px`;
  setTimeout(() => {
    document.querySelector('.tps-pinned-container').style.height = '';
  }, 500);
};

const removeStreamer = async (id) => {
  const filtered = localStorageGetAllPinned().filter(
    (p) => p.id !== id && p.id
  );
  localStorageSetPinned(filtered);

  const prevHeight = document
    .querySelector('.tps-pinned-container')
    .getBoundingClientRect().height;
  const nextHeight =
    prevHeight -
    document
      .querySelector('.tps-pinned-container > div')
      .getBoundingClientRect().height;
  document.querySelector('.tps-pinned-container').style.height =
    `${prevHeight}px`;

  await renderPinnedStreamers();

  document.querySelector('.tps-pinned-container').style.height =
    `${nextHeight}px`;
  setTimeout(() => {
    document.querySelector('.tps-pinned-container').style.height = '';
  }, 500);
};

const promptExportData = async (jsonData, _callback) => {
  const overlay = document.createElement('div');
  overlay.style.position = 'fixed';
  overlay.style.top = '0';
  overlay.style.left = '0';
  overlay.style.width = '100vw';
  overlay.style.height = '100vh';
  overlay.style.background = 'rgba(0, 0, 0, 0.8)';
  overlay.style.display = 'flex';
  overlay.style.justifyContent = 'center';
  overlay.style.alignItems = 'center';
  overlay.style.zIndex = '1000';

  const modal = document.createElement('div');
  modal.style.background = '#18181b';
  modal.style.padding = '20px';
  modal.style.borderRadius = '10px';
  modal.style.boxShadow = '0 0 15px rgba(0, 0, 0, 0.3)';
  modal.style.width = '500px';
  modal.style.maxWidth = '90%';
  modal.style.display = 'flex';
  modal.style.flexDirection = 'column';
  modal.style.color = '#efeff1';
  modal.style.fontFamily = "'Inter', sans-serif";
  modal.style.position = 'relative';

  overlay.appendChild(modal);

  const closeButton = document.createElement('button');
  closeButton.textContent = '×';
  closeButton.style.position = 'absolute';
  closeButton.style.top = '10px';
  closeButton.style.right = '15px';
  closeButton.style.background = 'transparent';
  closeButton.style.border = 'none';
  closeButton.style.color = '#efeff1';
  closeButton.style.fontSize = '20px';
  closeButton.style.cursor = 'pointer';
  closeButton.style.fontWeight = 'bold';
  closeButton.onmouseover = () => (closeButton.style.color = '#9147ff');
  closeButton.onmouseleave = () => (closeButton.style.color = '#efeff1');
  closeButton.onclick = function () {
    document.body.removeChild(overlay);
  };

  modal.appendChild(closeButton);

  const title = document.createElement('h2');
  title.textContent = 'Twitch Pinned Streamers - Export';
  title.style.margin = '0 0 10px 0';
  title.style.fontSize = '18px';
  title.style.fontWeight = 'bold';
  title.style.textAlign = 'center';

  modal.appendChild(title);

  const textarea = document.createElement('textarea');
  textarea.style.width = '100%';
  textarea.style.height = '200px';
  textarea.style.background = '#0e0e10';
  textarea.style.border = '1px solid #9147ff';
  textarea.style.color = '#efeff1';
  textarea.style.borderRadius = '5px';
  textarea.style.padding = '10px';
  textarea.style.fontSize = '14px';
  textarea.style.resize = 'none';
  textarea.value = JSON.stringify(jsonData, null, 2);
  textarea.setAttribute('readonly', true);

  modal.appendChild(textarea);

  const buttonContainer = document.createElement('div');
  buttonContainer.style.display = 'flex';
  buttonContainer.style.justifyContent = 'space-between';
  buttonContainer.style.marginTop = '15px';

  modal.appendChild(buttonContainer);

  const copyButton = document.createElement('button');
  copyButton.textContent = 'Copy';
  copyButton.style.background = '#9147ff';
  copyButton.style.color = 'white';
  copyButton.style.border = 'none';
  copyButton.style.padding = '10px 15px';
  copyButton.style.borderRadius = '5px';
  copyButton.style.cursor = 'pointer';
  copyButton.style.fontWeight = 'bold';
  copyButton.style.flex = '1';
  copyButton.style.marginRight = '10px';
  copyButton.style.textAlign = 'center';
  copyButton.onmouseover = () => (copyButton.style.background = '#772ce8');
  copyButton.onmouseleave = () => (copyButton.style.background = '#9147ff');
  copyButton.onclick = function () {
    navigator.clipboard
      .writeText(textarea.value)
      .then(() => {
        copyButton.textContent = 'Copied!';
        setTimeout(() => (copyButton.textContent = 'Copy'), 2000);
      })
      .catch(() => {
        alert('Failed to copy.');
      });
  };

  buttonContainer.appendChild(copyButton);

  const cancelButton = document.createElement('button');
  cancelButton.textContent = 'Cancel';
  cancelButton.style.background = '#3a3a3d';
  cancelButton.style.color = 'white';
  cancelButton.style.border = 'none';
  cancelButton.style.padding = '10px 15px';
  cancelButton.style.borderRadius = '5px';
  cancelButton.style.cursor = 'pointer';
  cancelButton.style.fontWeight = 'bold';
  cancelButton.style.flex = '1';
  cancelButton.style.textAlign = 'center';
  cancelButton.onmouseover = () => (cancelButton.style.background = '#56565a');
  cancelButton.onmouseleave = () => (cancelButton.style.background = '#3a3a3d');
  cancelButton.onclick = function () {
    document.body.removeChild(overlay);
  };

  buttonContainer.appendChild(cancelButton);

  document.body.appendChild(overlay);
};

const promptImportData = async (callback) => {
  const overlay = document.createElement('div');
  overlay.style.position = 'fixed';
  overlay.style.top = '0';
  overlay.style.left = '0';
  overlay.style.width = '100vw';
  overlay.style.height = '100vh';
  overlay.style.background = 'rgba(0, 0, 0, 0.8)';
  overlay.style.display = 'flex';
  overlay.style.justifyContent = 'center';
  overlay.style.alignItems = 'center';
  overlay.style.zIndex = '1000';

  const modal = document.createElement('div');
  modal.style.background = '#18181b';
  modal.style.padding = '20px';
  modal.style.borderRadius = '10px';
  modal.style.boxShadow = '0 0 15px rgba(0, 0, 0, 0.3)';
  modal.style.width = '500px';
  modal.style.maxWidth = '90%';
  modal.style.display = 'flex';
  modal.style.flexDirection = 'column';
  modal.style.color = '#efeff1';
  modal.style.fontFamily = "'Inter', sans-serif";
  modal.style.position = 'relative';

  overlay.appendChild(modal);

  const closeButton = document.createElement('button');
  closeButton.textContent = '×';
  closeButton.style.position = 'absolute';
  closeButton.style.top = '10px';
  closeButton.style.right = '15px';
  closeButton.style.background = 'transparent';
  closeButton.style.border = 'none';
  closeButton.style.color = '#efeff1';
  closeButton.style.fontSize = '20px';
  closeButton.style.cursor = 'pointer';
  closeButton.style.fontWeight = 'bold';
  closeButton.onmouseover = () => (closeButton.style.color = '#9147ff');
  closeButton.onmouseleave = () => (closeButton.style.color = '#efeff1');
  closeButton.onclick = function () {
    document.body.removeChild(overlay);
  };

  modal.appendChild(closeButton);

  const title = document.createElement('h2');
  title.textContent = 'Import JSON Data';
  title.style.margin = '0 0 10px 0';
  title.style.fontSize = '18px';
  title.style.fontWeight = 'bold';
  title.style.textAlign = 'center';

  modal.appendChild(title);

  const textarea = document.createElement('textarea');
  textarea.style.width = '100%';
  textarea.style.height = '200px';
  textarea.style.background = '#0e0e10';
  textarea.style.border = '1px solid #9147ff';
  textarea.style.color = '#efeff1';
  textarea.style.borderRadius = '5px';
  textarea.style.padding = '10px';
  textarea.style.fontSize = '14px';
  textarea.style.resize = 'none';
  textarea.placeholder = 'Paste the JSON data here...';

  modal.appendChild(textarea);

  const buttonContainer = document.createElement('div');
  buttonContainer.style.display = 'flex';
  buttonContainer.style.justifyContent = 'space-between';
  buttonContainer.style.marginTop = '15px';

  modal.appendChild(buttonContainer);

  const importButton = document.createElement('button');
  importButton.textContent = 'Import';
  importButton.style.background = '#9147ff';
  importButton.style.color = 'white';
  importButton.style.border = 'none';
  importButton.style.padding = '10px 15px';
  importButton.style.borderRadius = '5px';
  importButton.style.cursor = 'pointer';
  importButton.style.fontWeight = 'bold';
  importButton.style.flex = '1';
  importButton.style.marginRight = '10px';
  importButton.style.textAlign = 'center';
  importButton.onmouseover = () => (importButton.style.background = '#772ce8');
  importButton.onmouseleave = () => (importButton.style.background = '#9147ff');

  importButton.onclick = async function () {
    if (!textarea.value) {
      alert('Please paste the data to import.');
      return;
    }

    let parsedData;

    try {
      parsedData = JSON.parse(textarea.value);
    } catch (e) {
      logger.error(e);
      alert(
        `Invalid JSON format. Please check the content and try again. \n\n Error: \n${e}`
      );
      return;
    }

    const confirmImport = confirm(
      'Current data will be overwritten with the imported data. Do you want to continue?'
    );

    if (confirmImport) {
      logger.info('Imported data:', parsedData);

      const isCallbackSet = typeof callback === 'function';
      let isCallbackSuccess = false;

      if (isCallbackSet) {
        isCallbackSuccess = await callback(parsedData);
      }

      if (!isCallbackSet || (isCallbackSet && isCallbackSuccess)) {
        document.body.removeChild(overlay);
      }
    }
  };

  buttonContainer.appendChild(importButton);

  const cancelButton = document.createElement('button');
  cancelButton.textContent = 'Cancel';
  cancelButton.style.background = '#3a3a3d';
  cancelButton.style.color = 'white';
  cancelButton.style.border = 'none';
  cancelButton.style.padding = '10px 15px';
  cancelButton.style.borderRadius = '5px';
  cancelButton.style.cursor = 'pointer';
  cancelButton.style.fontWeight = 'bold';
  cancelButton.style.flex = '1';
  cancelButton.style.textAlign = 'center';
  cancelButton.onmouseover = () => (cancelButton.style.background = '#56565a');
  cancelButton.onmouseleave = () => (cancelButton.style.background = '#3a3a3d');
  cancelButton.onclick = function () {
    document.body.removeChild(overlay);
  };

  buttonContainer.appendChild(cancelButton);

  document.body.appendChild(overlay);
};

const renderPinnedStreamers = async () => {
  const pinnedUsers = localStorageGetAllPinned().map((p) => p.user);
  const pinnedStreamers = await batchGetTwitchUsers(pinnedUsers);

  const pinnedContainer = document
    .getElementById('anon-followed')
    .querySelector('.tps-pinned-container');
  pinnedContainer.innerHTML = '';

  pinnedStreamers
    .sort((a, b) => (a.viewers < b.viewers ? 1 : -1))
    .sort((a, b) => {
      if (a.isLive === b.isLive) return 0;
      return a.isLive ? -1 : 1;
    })
    .forEach((data) => {
      pinnedContainer.innerHTML += pinnedStreamer({
        ...data,
      });
    });

  // Click

  pinnedContainer
    .querySelectorAll('.tps-pinned-streamer-anchor')
    .forEach((anchor) => {
      anchor.addEventListener('click', async (event) => {
        event.preventDefault();

        const link = event.target.closest('a');
        const streamer = link.pathname.slice(1);
        await navigateToChannel(streamer);

        renderPinCurrentStreamer();
      });
    });

  // Remove click

  pinnedContainer
    .querySelectorAll('.tps-remove-pinned-streamer')
    .forEach((btn) => {
      btn.addEventListener('click', async (event) => {
        const id = event.target.getAttribute('data-id');
        logger.debug(`Removing pinned streamer with id: ${id}`);
        await removeStreamer(id);
        logger.debug(`Removed pinned streamer with id: ${id}`);
      });
    });
};

const navigateToChannel = (channel) => {
  return new Promise((resolve) => {
    history.pushState({}, '', `/${channel}`);

    window.dispatchEvent(new Event('popstate'));

    // Fallback
    setTimeout(() => {
      if (!document.body.innerHTML.includes(channel)) {
        logger.debug('Could not load dinamically, forcing reload...');
        window.location.reload();
      }
      resolve();
    }, 500);
  });
};

const renderPinCurrentStreamer = () => {
  const currentUrl = new URL(window.location.href);
  const [_, currentStreamerName] = currentUrl.pathname.split('/');

  if (!currentStreamerName) {
    return;
  }

  // Rerender if exists
  document.getElementById('tps-pin-current-streamer-container')?.remove();

  const isPinned = localStorageIsPinned(currentStreamerName);

  const pinStreamerCurrentHtml = pinStreamer({
    user: currentStreamerName,
    isPinned,
  });

  document
    .querySelector(
      `
        ${ALL_RELEVANT_CONTENT_SELECTOR} ${FOLLOW_BUTTON_CONTAINER_SELECTOR},
        ${ALL_RELEVANT_CONTENT_SELECTOR} ${FOLLOW_BUTTON_OFFLINE_CONTAINER_SELECTOR}
      `
    )
    .insertAdjacentHTML('afterend', pinStreamerCurrentHtml);

  document
    .getElementById('tps-pin-current-streamer-button')
    .addEventListener('click', async (e) => {
      e.preventDefault();

      if (isPinned) {
        const id = localStorageGetPinned(currentStreamerName)?.id;
        if (!id) {
          logger.error('Could not find pinned streamer:', currentStreamerName);
          return;
        }

        await removeStreamer(id);
      } else {
        await addStreamer(currentStreamerName);
      }

      renderPinCurrentStreamer();
    });
};

// HTML templates

const pinnedHeader = () => {
  const clonedPinnedHeader = document
    .querySelector(ALL_RELEVANT_CONTENT_SELECTOR)
    .querySelector(HEADER_CLONE_SELECTOR)
    .cloneNode(true);
  const title = clonedPinnedHeader.querySelector('h2,h3');
  title.innerText = 'Pinned Channels';
  title.setAttribute('style', 'display:inline-block;');
  clonedPinnedHeader.innerHTML += MenuContainerRawHTML;

  return clonedPinnedHeader.outerHTML;
};

const pinnedHeaderBehavior = () => menuContainerBehavior();

const pinStreamer = ({ user, isPinned }) => {
  const pinText = isPinned ? 'Unpin' : 'Pin';
  let clonedFollowButtonContainer;
  try {
    clonedFollowButtonContainer = new DOMParser()
      .parseFromString(FollowButtonContainerRawHTML, 'text/html')
      .querySelector('div');
  } catch (error) {
    logger.error('Could not clone follow button container.', error);
    return '';
  }
  if (!clonedFollowButtonContainer) {
    logger.error('Could not clone follow button container.');
    return '';
  }

  clonedFollowButtonContainer.id = 'tps-pin-current-streamer-container';
  const styledWrapper =
    clonedFollowButtonContainer.querySelector('div div div')?.style;
  styledWrapper?.removeProperty('transform');
  styledWrapper?.setProperty('padding-left', '10px');
  const pinTextDecoration = isPinned ? '●' : '〇';
  clonedFollowButtonContainer.querySelector('span div').innerText =
    `${pinTextDecoration} ${pinText}`;
  clonedFollowButtonContainer
    .querySelector('.live-notifications__btn')
    ?.parentElement?.parentElement?.remove();

  const button = clonedFollowButtonContainer.querySelector('button');
  button.id = 'tps-pin-current-streamer-button';
  button.setAttribute('aria-label', `${pinText} ${user}`);
  button.setAttribute('data-a-target', `${pinText.toLocaleLowerCase()}-button`);
  button.setAttribute(
    'data-text-selector',
    `${pinText.toLocaleLowerCase()}-button`
  );
  button.style?.setProperty('height', '30px');
  button.style?.setProperty('font-weight', 'var(--font-weight-semibold');
  button.style?.setProperty('font-size', 'var(--button-text-default');
  if (isPinned) {
    button.style.setProperty(
      'background-color',
      'var(--color-background-button-secondary-default)'
    );
    button.parentElement.style = 'background-color: transparent !important';
  } else {
    button.style.setProperty(
      'background-color',
      'var(--color-background-button-primary-default)'
    );
  }

  // TODO: Add pin icon. Meanwhile, remove the default heart icon.
  button.querySelector('.InjectLayout-sc-1i43xsx-0')?.remove();

  return clonedFollowButtonContainer.outerHTML;
};

const pinnedStreamer = ({
  user,
  id,
  displayName,
  profileImageURL,
  isLive,
  viewers = '',
  category,
}) => {
  const removeBtn = `<button class="tps-remove-pinned-streamer" data-id="${id}" title="Remove pinned streamer" style="position:absolute;top:-6px;left:2px;z-index:1;">x</button>`;
  const prettyViewers = stylizedViewers(viewers);

  const clonedPinnedStreamer = document
    .querySelector(
      `${ALL_RELEVANT_CONTENT_SELECTOR} ${NAV_CARD_CLONE_SELECTOR}`
    )
    .parentNode.parentNode.cloneNode(true);
  if (!isLive) {
    clonedPinnedStreamer.setAttribute('style', 'opacity:0.4;');
  }
  const aElement = clonedPinnedStreamer.querySelector('a');
  aElement.setAttribute('href', `/${user}`);
  aElement.classList?.add('tps-pinned-streamer-anchor');
  const figure = clonedPinnedStreamer.querySelector('.side-nav-card__avatar');
  figure.setAttribute('aria-label', displayName);
  const img = figure.querySelector('img');
  img.setAttribute('alt', displayName);
  img.setAttribute('src', profileImageURL);
  const metadata = clonedPinnedStreamer.querySelector(
    "[data-a-target='side-nav-card-metadata'] p"
  );
  metadata.title = displayName;
  metadata.innerText = displayName;
  const streamCategory = clonedPinnedStreamer.querySelector(
    "[data-a-target='side-nav-game-title'] p"
  );
  streamCategory.title = isLive ? category : '';
  streamCategory.innerText = isLive ? category : '';
  const liveStatus = clonedPinnedStreamer.querySelector(
    "div[data-a-target='side-nav-live-status']"
  );
  if (!isLive) {
    liveStatus.innerHTML = '';
  } else {
    const liveSpan = liveStatus.querySelector('span');
    liveSpan.setAttribute('aria-label', `${prettyViewers} viewers`);
    liveSpan.innerText = prettyViewers;
  }

  clonedPinnedStreamer.querySelector('div').innerHTML =
    removeBtn + clonedPinnedStreamer.querySelector('div').innerHTML;

  return clonedPinnedStreamer.outerHTML;
};

const stylizedViewers = (viewers) => {
  if (!viewers) {
    return '';
  }

  const number = parseInt(viewers, 10);
  return nFormatter(number, 1);
};

// From https://stackoverflow.com/a/9462382
function nFormatter(num, digits) {
  const lookup = [
    { value: 1, symbol: '' },
    { value: 1e3, symbol: 'K' },
    { value: 1e6, symbol: 'M' },
    { value: 1e9, symbol: 'G' },
    { value: 1e12, symbol: 'T' },
    { value: 1e15, symbol: 'P' },
    { value: 1e18, symbol: 'E' },
  ];
  const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
  const item = lookup
    .slice()
    .reverse()
    .find((lookupItem) => num >= lookupItem.value);
  return item
    ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol
    : '0';
}

// GRAPHQL Requests

/**
 *
 * @param {string} logins
 * @returns {Promise<{
 *   user: string,
 *   displayName: string,
 *   profileImageURL: string,
 *   id: string,
 *   isLive: boolean,
 *   viewers: number,
 *   category: string,
 *   title: string,
 * }[]>} Async array of twitch users data
 */
const batchGetTwitchUsers = async (logins) => {
  if (logins.length === 0) {
    return [];
  }

  const twitchUsers = await twitchGQLRequest({
    query: `query($logins: [String!]!, $all: Boolean!, $skip: Boolean!) {
      users(logins: $logins) {
        login
        id
        broadcastSettings {
          language
          game {
            displayName
            name
          }
          title
        }
        createdAt
        description
        displayName
        followers {
          totalCount
        }
        stream {
          archiveVideo @include(if: $all) {
              id
          }
          createdAt
          id
          type
          viewersCount
        }
        lastBroadcast {
            startedAt
        }
        primaryTeam {
          displayName
          name
        }
        profileImageURL(width: 70)
        profileViewCount
        self @skip(if: $skip) {
          canFollow
          follower {
            disableNotifications
          }
        }
      }
    }`,
    variables: { logins, all: false, skip: false },
  });

  const result = twitchUsers.data.users.map((user) => {
    if (!user) {
      return {};
    }

    return {
      user: user.login,
      displayName: user.displayName,
      profileImageURL: user.profileImageURL,

      id: user.id,
      isLive: user?.stream?.type,
      viewers: user?.stream?.viewersCount,
      category: user?.broadcastSettings?.game?.displayName,
      title: user?.broadcastSettings?.title,
    };
  });

  return result;
};

const twitchGQLRequest = async ({ query, variables }) => {
  const headers = new Headers();
  headers.append('Client-ID', CLIENT_ID);
  headers.append('Content-Type', 'application/json');

  const graphql = JSON.stringify({
    query,
    variables,
  });
  const requestOptions = {
    method: 'POST',
    headers,
    body: graphql,
    redirect: 'follow',
  };

  return fetch(TWITCH_GRAPHQL, requestOptions)
    .then((response) => {
      if (!response.ok) {
        logger.warn('GraphQL request error:', query, variables);
        throw new Error(
          `HTTP-Error twitchGQLRequest. Status code: ${response.status}`
        );
      }

      return response;
    })
    .then((response) => response.text())
    .then((text) => JSON.parse(text))
    .catch((error) => {
      throw error;
    });
};

// LocalStorage

/**
 * @param {any} data
 * @returns boolean
 *
 */
const validateLocalStoragePinnedData = (data) => {
  return (
    Array.isArray(data) &&
    data.every(
      (item) =>
        typeof item.user === 'string' &&
        (!Object.prototype.hasOwnProperty.call(item, 'pinnedAt') ||
          (typeof item.pinnedAt === 'string' &&
            !isNaN(Date.parse(item.pinnedAt))))
    )
  );
};

const localStorageGetAllPinned = () => {
  const lsPinned = localStorage.getItem('tps:pinned');
  return lsPinned ? JSON.parse(lsPinned) : [];
};

const localStorageGetPinned = (user) => {
  const pinned = localStorageGetAllPinned();
  return pinned.find((p) => p.user.toLowerCase() === user.toLowerCase());
};

const localStorageSetPinned = (data) => {
  localStorage.setItem('tps:pinned', JSON.stringify(data));
  return true;
};

const localStorageIsPinned = (user) => {
  const pinned = localStorageGetAllPinned();
  return !!pinned.find((p) => p.user.toLowerCase() === user.toLowerCase());
};

const localStorageGetPinnedRefreshedAt = () => {
  const pinnedRefreshedAt = localStorage.getItem('tps:pinned:refreshed_at');
  return pinnedRefreshedAt ? new Date(pinnedRefreshedAt) : new Date();
};

const localStorageSetPinnedRefreshedAt = (date) => {
  localStorage.setItem('tps:pinned:refreshed_at', date.toISOString());
  return true;
};

// Raw HTML

const FollowButtonContainerRawHTML = `
  <div class="Layout-sc-1xcs6mc-0 cwtKyw">
      <div class="Layout-sc-1xcs6mc-0 grllUE">
          <div style="opacity: 1; transform: translateX(50px) translateZ(0px);">
              <div class="Layout-sc-1xcs6mc-0 lmNILC">
                  <div class="Layout-sc-1xcs6mc-0 bzcGMK">
                      <div class="Layout-sc-1xcs6mc-0 hkISPQ">
                          <div style="opacity: 1;">
                              <div class="Layout-sc-1xcs6mc-0 bXHHlg">
                                  <div class="Layout-sc-1xcs6mc-0 fVQeCA"><button aria-label="Follow _____" data-a-target="follow-button" data-test-selector="follow-button" class="ScCoreButton-sc-ocjdkq-0 iumXyx">
                                          <div class="ScCoreButtonLabel-sc-s7h2b7-0 gPDjGr">
                                              <div data-a-target="tw-core-button-label-text" class="Layout-sc-1xcs6mc-0 bFxzAY">
                                                  <div class="Layout-sc-1xcs6mc-0 ktLpvM">
                                                      <div class="InjectLayout-sc-1i43xsx-0 bgnKmX" style="transition: transform; opacity: 1;">
                                                          <div class="ScAnimation-sc-s60rmz-0 kCyYsz tw-animation" data-a-target="tw-animation-target">
                                                              <div class="Layout-sc-1xcs6mc-0 ktLpvM">
                                                                  <div class="InjectLayout-sc-1i43xsx-0 kBtJDm">
                                                                      <figure class="ScFigure-sc-1hrsqw6-0 btGeNA tw-svg"><svg width="20px" height="20px" version="1.1" viewBox="0 0 20 20" x="0px" y="0px" class="ScSvg-sc-1hrsqw6-1 ihOSMR">
                                                                              <g>
                                                                                  <path fill-rule="evenodd" d="M9.171 4.171A4 4 0 006.343 3H6a4 4 0 00-4 4v.343a4 4 0 001.172 2.829L10 17l6.828-6.828A4 4 0 0018 7.343V7a4 4 0 00-4-4h-.343a4 4 0 00-2.829 1.172L10 5l-.829-.829zm.829 10l5.414-5.414A2 2 0 0016 7.343V7a2 2 0 00-2-2h-.343a2 2 0 00-1.414.586L10 7.828 7.757 5.586A2 2 0 006.343 5H6a2 2 0 00-2 2v.343a2 2 0 00.586 1.414L10 14.172z" clip-rule="evenodd"></path>
                                                                              </g>
                                                                          </svg></figure>
                                                                  </div>
                                                              </div>
                                                          </div>
                                                      </div><span>
                                                          <div style="transition: all; opacity: 1;">Follow</div>
                                                      </span>
                                                  </div>
                                              </div>
                                          </div>
                                      </button></div>
                              </div>
                          </div>
                      </div>
                  </div>
              </div>
          </div>
      </div>
  </div>
`;

const MenuContainerRawHTML = `
  <div class="tps-menu-container">
    <div class="tps-menu-icon" id="tps-menu-btn">
      <button>
      <svg width="20" height="20" viewBox="0 0 20 20" focusable="false" aria-hidden="true" role="presentation"><path d="M10 18a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0-6a2 2 0 1 1 0-4 2 2 0 0 1 0 4zM8 4a2 2 0 1 0 4 0 2 2 0 0 0-4 0z"></path></svg>
      </button>
    </div>

    <div class="tps-menu-dropdown" id="tps-menu-dropdown">
      <ul>
        <li><a id="tps-add-streamer" href="#">Add Streamer</a></li>
        <li><a id="tps-export" href="#">Export</a></li>
        <li><a id="tps-import" href="#">Import</a></li>
      </ul>
    </div>

    <script>
        const menuBtn = document.getElementById("tps-menu-btn");
        const menuDropdown = document.getElementById("tps-menu-dropdown");

        menuBtn.addEventListener("click", function () {
          menuDropdown.classList.toggle("show");
        });

        document.addEventListener("click", function (event) {
          if (!menuBtn.contains(event.target) && !menuDropdown.contains(event.target)) {
            menuDropdown.classList.remove("show");
          }
        });
    </script>
  </div>
`;

const menuContainerBehavior = () => {
  const menuBtn = document.getElementById('tps-menu-btn');
  const menuDropdown = document.getElementById('tps-menu-dropdown');

  menuBtn.addEventListener('click', function () {
    menuDropdown.classList.toggle('show');
  });

  document.addEventListener('click', function (event) {
    if (
      !menuBtn.contains(event.target) &&
      !menuDropdown.contains(event.target)
    ) {
      menuDropdown.classList.remove('show');
    }
  });
};