Juick tweaks

Feature testing

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name        Juick tweaks
// @namespace   ForJuickCom
// @description Feature testing
// @match       *://juick.com/*
// @author      Killy
// @version     2.21.2
// @date        2016.09.02 - 2022.08.18
// @license     MIT
// @run-at      document-end
// @grant       GM_xmlhttpRequest
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_deleteValue
// @grant       GM_listValues
// @grant       GM_info
// @connect     api.juick.com
// @connect     twitter.com
// @connect     bandcamp.com
// @connect     mixcloud.com
// @connect     flickr.com
// @connect     flic.kr
// @connect     deviantart.com
// @connect     slideshare.net
// @connect     gist.github.com
// @connect     codepen.io
// @connect     arxiv.org
// @connect     pixiv.net
// @connect     konachan.net
// @connect     yande.re
// @connect     gelbooru.com
// @connect     safebooru.org
// @connect     danbooru.donmai.us
// @connect     safebooru.donmai.us
// @connect     anime-pictures.net
// @connect     api.imgur.com
// @connect     tumblr.com
// @connect     reddit.com
// @connect     wordpress.com
// @connect     lenta.ru
// @connect     meduza.io
// @connect     rbc.ru
// @connect     tjournal.ru
// @connect     *.newsru.com
// @connect     *.itar-tass.com
// @connect     tass.ru
// @connect     rublacklist.net
// @connect     mk.ru
// @connect     gazeta.ru
// @connect     republic.ru
// @connect     bash.im
// @connect     ixbt.com
// @connect     techxplore.com
// @connect     medicalxpress.com
// @connect     phys.org
// @connect     techcrunch.com
// @connect     bbc.com
// @connect     nplus1.ru
// @connect     elementy.ru
// @connect     news.tut.by
// @connect     pikabu.ru
// @connect     imdb.com
// @connect     mastodon.social
// @connect     mastodonsocial.ru
// @connect     *
// ==/UserScript==


// #region === Pages and elements ===========================================================================

const content = document.getElementById('content');
const isPost = content && content.hasAttribute('data-mid');
const isFeed = document.querySelectorAll('#content article[data-mid]').length > 0;
const isCommonFeed = /^(?:https?:)?\/\/[a-z0-9.:]+\/(?:$|tag|#post|\?.*show=(?:all|photos))/i.test(window.location.href);
const isAll = /\bshow=all\b/i.test(window.location.search);
const isNewPostPage = window.location.pathname.endsWith('/post') && document.querySelector('textarea.newmessage');
const isTagsPage = window.location.pathname.endsWith('/tags');
const isSettingsPage = window.location.pathname.endsWith('/settings');
const isUserColumn = !!(document.querySelector('aside#column div#ustats'));
const isUsersTable = !!(document.querySelector('#content > div.users'));
const hasContentArticle = !!(document.querySelector('#content article'));

// #endregion


// #region === Userscript features ==========================================================================

addStyle();

const userscriptFeatures = [
  {
    name: 'Форма нового сообщения в ленте (как старый /#post)',
    id: 'enable_post_sharp',
    enabledByDefault: true,
    pageMatch: (isFeed && isUserColumn) || isAll,
    fun: addPostSharpFormUser
  },
  {
    name: 'Сортировка и цветовое кодирование тегов на странице нового поста (/post)',
    id: 'enable_tags_on_new_post_page',
    enabledByDefault: true,
    pageMatch: isNewPostPage,
    fun: easyTagsUnderNewMessageForm
  },
  {
    name: 'Сортировка и цветовое кодирование тегов на странице /user/tags',
    id: 'enable_tags_page_coloring',
    enabledByDefault: true,
    pageMatch: isTagsPage,
    fun: sortTagsPage
  },
  {
    name: 'Min-width для тегов',
    id: 'enable_tags_min_width',
    enabledByDefault: true
  },
  {
    name: 'Копирование ссылок на посты/комментарии',
    id: 'enable_comment_share_menu',
    enabledByDefault: true,
    pageMatch: isPost,
    fun: addCommentShareMenu
  },
  {
    name: 'Ссылки для удаления комментариев',
    id: 'enable_comment_removal_links',
    enabledByDefault: true,
    pageMatch: isPost,
    fun: addCommentRemovalLinks
  },
  {
    name: 'Ссылка для редактирования тегов поста',
    id: 'enable_tags_editing_link',
    enabledByDefault: true,
    pageMatch: isPost,
    fun: addTagEditingLinkUnderPost
  },
  {
    name: 'Большая аватарка в левой колонке',
    id: 'enable_big_avatar',
    enabledByDefault: true,
    pageMatch: isUserColumn,
    fun: biggerAvatar
  },
  {
    name: 'Ссылки для перехода к постам пользователя за определённый год',
    id: 'enable_year_links',
    enabledByDefault: true,
    pageMatch: isUserColumn,
    fun: addYearLinks
  },
  {
    name: 'Сортировка подписок/подписчиков по дате последнего сообщения',
    id: 'enable_users_sorting',
    enabledByDefault: true,
    pageMatch: isUsersTable,
    fun: addUsersSortingButton
  },
  {
    name: 'Статистика рекомендаций',
    id: 'enable_irecommend',
    enabledByDefault: true,
    pageMatch: isUserColumn,
    fun: addIRecommendLink
  },
  {
    name: 'Упоминания (ссылка на поиск)',
    id: 'enable_mentions_search',
    enabledByDefault: true,
    pageMatch: isUserColumn,
    fun: addMentionsLink
  },
  {
    name: 'Посты и комментарии, на которые нельзя ответить, — более бледные',
    id: 'enable_unrepliable_styling',
    enabledByDefault: true,
    pageMatch: isPost || isFeed,
    fun: () => { if (isPost) { checkReplyPost(); } else { checkReplyArticles(); } }
  },
  {
    name: 'Для readonly поста отображать виртуальный тег (только на странице поста)',
    id: 'enable_mark_readonly_post',
    enabledByDefault: true,
    pageMatch: isPost,
    fun: markReadonlyPost
  },
  {
    name: 'Показывать комментарии при наведении на ссылку "в ответ на /x"',
    id: 'enable_move_comment_into_view',
    enabledByDefault: true,
    pageMatch: isPost,
    fun: bringCommentsIntoViewOnHover
  },
  {
    name: 'Стрелочки ("↓")',
    id: 'enable_arrows',
    enabledByDefault: true
  },
  {
    name: 'Take care of NSFW tagged posts in feed',
    id: 'enable_mark_nsfw_posts_in_feed',
    enabledByDefault: true,
    pageMatch: isFeed,
    fun: markNsfwPostsInFeed
  },
  {
    name: 'Сбросить стили для тега *code. Уменьшить шрифт взамен',
    id: 'unset_code_style',
    enabledByDefault: false
  },
  {
    name: 'Сворачивать длинные посты',
    id: 'enable_long_message_folding',
    enabledByDefault: true,
    pageMatch: isFeed,
    fun: limitArticlesHeight
  },
  {
    id: 'filter_comments_too', // not in the main feature list
    enabledByDefault: false,
    pageMatch: isPost,
    fun: filterPostComments
  },
  {
    pageMatch: isPost,
    fun: embedLinksToPost
  },
  {
    pageMatch: isFeed,
    fun: embedLinksToArticles
  },
  {
    pageMatch: isFeed && isCommonFeed,
    fun: filterArticles
  },
  {
    pageMatch: isSettingsPage,
    fun: addTweaksSettingsButton
  },
  {
    pageMatch: hasContentArticle,
    fun: addTweaksSettingsFooterLink
  }
];

userscriptFeatures.forEach(feature => {
  let runnable = feature.pageMatch && !!feature.fun;
  let enabled = !feature.id || GM_getValue(feature.id, feature.enabledByDefault);
  if (runnable && enabled) {
    try { feature.fun(); } catch (e) {
      console.warn(`Failed to run ${feature.fun.name}()`);
      console.warn(e);
    }
  }
});

// #endregion


// #region === Helpers ======================================================================================

Object.values = Object.values || (obj => Object.keys(obj).map(key => obj[key]));

String.prototype.count = function(s1) {
  return (this.length - this.replace(new RegExp(s1, 'g'), '').length) / s1.length;
};

Number.prototype.pad = function(size=2) {
  let s = String(this);
  while (s.length < size) { s = '0' + s; }
  return s;
};

function longest(arr) {
  return arr.reduce((a,b) => (!a) ? b : (!b || a.length > b.length) ? a : b);
}

function intersect(a, b) {
  if (a.length > b.length) { [a, b] = [b, a]; } // filter shorter array
  return a.filter(item => (b.indexOf(item) !== -1));
}

function fitToBounds(w, h, maxW, maxH) {
  let r = h / w;
  let w1 = ((h > maxH) ? maxH : h) / r;
  let w2 = ((w1 > maxW) ? maxW : w1);
  let h2 = w2 * r;
  return { w: w2, h: h2 };
}

function insertAfter(newNode, referenceNode) {
  referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}

function setContent(containerNode, ...newNodes) {
  removeAllFrom(containerNode);
  newNodes.forEach(n => containerNode.appendChild(n));
  return containerNode;
}

function removeAllFrom(fromNode) {
  for (let c; c = fromNode.lastChild; ) { fromNode.removeChild(c); }
}

function parseRgbColor(colorStr, fallback=[0,0,0]){
  let [, r, g, b] = colorStr.replace(/ /g, '').match(/^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/i) || [, ...fallback];
  return [ +r, +g, +b ];
}

function getAllMatchesAndCaptureGroups(re, str) {
  let results = [], result;
  while ((result = re.exec(str)) !== null) { results.push(Array.from(result)); }
  return results;
}

function htmlDecode(str) {
  let doc = new DOMParser().parseFromString(str, 'text/html');
  return doc.documentElement.textContent;
}

function htmlEscape(html) {
  let textarea = document.createElement('textarea');
  textarea.textContent = html;
  return textarea.innerHTML;
}

function escapeRegExp(str) {
  return str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}

function naiveEllipsis(str, len, ellStr='...') {
  let ellLen = ellStr.length;
  if (str.length <= len) { return str; }
  let half = Math.floor((len - ellLen) / 2);
  let left = str.substring(0, half);
  let right = str.substring(str.length - (len - half - ellLen));
  return '' + left + ellStr + right;
}

function naiveEllipsisRight(str, len, ellStr='...') {
  let ellLen = ellStr.length;
  return (str.length <= len) ? str : str.substring(0, len - ellLen) + ellStr;
}

function wrapIntoTag(node, tagName, className=undefined) {
  let tag = document.createElement(tagName);
  if (className) { tag.className = className; }
  tag.appendChild(node);
  return tag;
}

function randomId() {
  return Math.random().toString(36).substr(2);
}

function matchWildcard(str, wildcard) {
  let ww = wildcard.split('*');
  let startFrom = 0;
  for (let i = 0; i < ww.length; i++) {
    let w = ww[i];
    if (w == '') { continue; }
    let wloc = str.indexOf(w, startFrom);
    if (wloc == -1) { return false; }
    let wend = wloc + w.length;
    let headCondition = (i > 0) || (wloc == 0);
    let tailCondition = (i < ww.length - 1) || ((i > 0) ? str.endsWith(w) : (str.substr(wloc) == w));
    if (!headCondition || !tailCondition) { return false; }
    startFrom = wend;
  }
  return true;
}

// rules :: [{pr: number, re: RegExp, with: string}]
// rules :: [{pr: number, re: RegExp, with: Function}]
// rules :: [{pr: number, re: RegExp, brackets: true, with: [string, string]}]
// rules :: [{pr: number, re: RegExp, brackets: true, with: [string, string, Function]}]
function formatText(txt, rules) {
  let idCounter = 0;
  function nextId() { return idCounter++; }
  function ft(txt, rules) {
    let matches = rules.map(r => { r.re.lastIndex = 0; return [r, r.re.exec(txt)]; })
                       .filter(([,m]) => m !== null)
                       .sort(([r1,m1],[r2,m2]) => (r1.pr - r2.pr) || (m1.index - m2.index));
    if (matches && matches.length > 0) {
      let [rule, match] = matches[0];
      let subsequentRules = rules.filter(r => r.pr >= rule.pr);
      let idStr = `<>(${nextId()})<>`;
      let outerStr = txt.substring(0, match.index) + idStr + txt.substring(rule.re.lastIndex);
      let innerStr = (rule.brackets)
        ? (() => { let [l ,r ,f] = rule.with; return l + ft((f ? f(match[1]) : match[1]), subsequentRules) + r; })()
        : match[0].replace(rule.re, rule.with);
      return ft(outerStr, subsequentRules).replace(idStr, innerStr);
    }
    return txt;
  }
  return ft(htmlEscape(txt), rules); // idStr above relies on the fact the text is escaped
}

function getProto() {
  return (location.protocol == 'http:') ? 'http:' : 'https:';
}

function setProto(url, proto) {
  return url.replace(
    /^(https?:)?(?=\/\/)/i,
    proto ? proto : getProto()
  );
}

function unsetProto(url) {
  return url.replace(/^(https?:)?(?=\/\/)/i, '');
}

function fixWwwLink(url) {
  return url.replace(/^(?!([a-z]+:)?\/\/)/i, '//');
}

function waitAndRunAsync(test, count, tick=100, successCallback, failCallback) {
  return new Promise((resolve, reject) => {
    function r(c){
      if (test()) { resolve(successCallback()); } else {
        if (c && (c > 0)) {
          setTimeout(() => r(c-1), tick);
        } else {
          reject(failCallback());
        }
      }
    }
    r(count);
  });
}

// predicates :: [{ msg: Response -> string, test: Response -> bool, permanent: Response -> bool }]
function xhrGetAsync(url, timeout=3000, predicates=undefined, method='GET') {
  predicates = predicates || [
    {
      msg: response => (response.statusText ? `${response.status} - ${response.statusText}` : `${response.status}`),
      test: response => response.status != 200,
      permanent: response => !([408, 500, 503].includes(response.status))
    }
  ];
  return new Promise(function(resolve, reject) {
    GM_xmlhttpRequest({
      method: method,
      url: url,
      timeout: timeout,
      onload: function(response) {
        let match = predicates && predicates.find(p => p.test(response));
        if (!match) {
          resolve(response);
        } else {
          reject({
            reason: match.msg(response),
            response: response,
            permanent: (match.permanent) ? match.permanent(response) : false,
            url: url
          });
        }
      },
      ontimeout: function(response) { reject({ reason: 'timeout', response: response, permanent: false, url: url }); },
      onerror: function(response) { reject({ reason: 'unknown error', response: response, permanent: false, url: url }); }
    });
  });
}

function xhrFirstResponse(urls, timeout) {
  return urls.reduce(
    (p, url) => p.catch(e => xhrGetAsync(url, timeout)),
    Promise.reject({reason: 'init'})
  );
}

function computeStyle(newElement) {
  if (document.body.contains(newElement)) {
    return getComputedStyle(newElement);
  }
  document.body.appendChild(newElement);
  let style = getComputedStyle(newElement);
  setTimeout(function() {
    document.body.removeChild(newElement);
  }, 1); // let getComputedStyle to do the job
  return style;
}

function autosize(el) {
  let offset = (!window.opera)
    ? (el.offsetHeight - el.clientHeight)
    : (el.offsetHeight + parseInt(window.getComputedStyle(el, null).getPropertyValue('border-top-width')));
  let resize = function (el) {
    el.style.height = 'auto';
    el.style.height = (el.scrollHeight + offset) + 'px';
  };
  el.addEventListener('input', () => resize(el));
}

function selectAndCopyElementContents(el, deselect=false) {
  if (window.getSelection && document.createRange) {
    let range = document.createRange();
    range.selectNodeContents(el);
    let sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
    try {
      let successful = document.execCommand('copy');
      if (!successful) { console.log('Copy command is not available or not enabled.'); }
      if (deselect) { sel.removeAllRanges(); }
      return successful;
    } catch (err) {
      console.log(err);
      return false;
    }
  }
  return false;
}

function keyboardClickable(el) {
  el.addEventListener('keydown', e => {
    if ((e.which === 13) || (e.which === 32)) { // 13 = Return, 32 = Space
      e.preventDefault();
      el.click();
    }
  });
}

function onClickOutsideOnce(element, callback) {
  const outsideClickListener = event => {
    if (!element.contains(event.target)) {
      callback();
      removeClickListener();
    }
  };
  const removeClickListener = () => {
    document.removeEventListener('click', outsideClickListener);
  };
  document.addEventListener('click', outsideClickListener);
}

// #endregion


// #region === Function definitions =========================================================================

function svgIconHtml(name) {
  return /*html*/`<div class="icon icon--ei-${name} icon--s"><svg class="icon__cnt"><use xlink:href="/sprite.svg#ei-${name}-icon"></use></svg></div>`;
}

function getMyAccountAsync() {
  if (getMyAccountAsync[0]) {
    return Promise.resolve(getMyAccountAsync[0]);
  } else {
    let hash = document.body.getAttribute('data-hash');
    if (!hash) { return Promise.reject('not logged in'); }
    return xhrGetAsync(setProto('//api.juick.com/me?hash=' + hash)).then(response => {
      let account = JSON.parse(response.responseText);
      getMyAccountAsync[0] = account;
      return account;
    });
  }
}

function getMyUserNameAsync() {
  return getMyAccountAsync().then(account => account.uname);
}

function getColumnUserName() {
  let columnUserIdLink = document.querySelector('#column #ctitle > a');
  if (columnUserIdLink) { return columnUserIdLink.textContent.trim(); }
  let headerUserIdLink = document.querySelector('#header #ctitle > a');
  if (headerUserIdLink) { return headerUserIdLink.textContent.trim(); }
  return null;
}

function getColumnUid() {
  let columnAvatar = document.querySelector('#column #ctitle > a > img');
  if (columnAvatar) { return columnAvatar.src.match(/\/i\/a\/(\d+)-[0-9a-fA-F]+\./i)[1]; }
  let headerAvatar = document.querySelector('#header #ctitle > a > img');
  if (headerAvatar) { return headerAvatar.src.match(/\/i\/a\/(\d+)-[0-9a-fA-F]+\./i)[1]; }
  return null;
}

function getPostUserName(element) {
  let avatar = element.querySelector('div.msg-avatar > a > img');
  return (avatar) ? avatar.alt : null;
}

function getPostUid(element) {
  let avatar = element.querySelector('div.msg-avatar > a > img');
  return (avatar) ? avatar.src.match(/\/i\/a\/(\d+)-[0-9a-fA-F]+\./i)[1] : null;
}

function markNsfwPostsInFeed() {
  [].forEach.call(document.querySelectorAll('#content article[data-mid]'), function(article) {
    let tagsDiv = article.querySelector('.msg-tags');
    let isNsfw = tagsDiv && Array.from(tagsDiv.children).some(t => t.textContent.toUpperCase() == 'NSFW');
    if (isNsfw) { article.classList.add('nsfw'); }
  });
}

function addTagEditingLinkUnderPost() {
  let post = document.querySelector('#content .msgthread');
  let postToolbar = post.querySelector('nav.l');
  let canEdit = !!postToolbar.querySelector('a[href*="?body=D+"]');
  let mid = document.getElementById('content').getAttribute('data-mid');
  if (canEdit) {
    postToolbar.insertAdjacentHTML(
      'beforeend',
      `<a href="/post?body=%23${mid}+%2ATag" class="msg-button">${svgIconHtml('tag')}<span>&nbsp;Tags</span></a>`
    );
  }
}

function addCommentRemovalLinks() {
  getMyUserNameAsync().then(uname => {
    let commentsBlock = document.querySelector('ul#replies');
    if (commentsBlock && uname) {
      [].forEach.call(commentsBlock.children, linode => {
        let postUserAvatar = linode.querySelector('div.msg-avatar > a > img');
        if (postUserAvatar) {
          let postUserId = postUserAvatar.alt;
          if (postUserId == uname) {
            let linksBlock = linode.querySelector('div.msg-links');
            let commentLink = linode.querySelector('div.msg-ts > a');
            let mid = /\/(\d+)$/.exec(commentLink.pathname)[1];
            let rid = commentLink.hash.replace('#','');

            linksBlock.insertAdjacentHTML('beforeend', /*html*/`
              <div class="clickPopup">
                <div class="clickTarget" tabindex="0">${svgIconHtml('close')}&nbsp;Delete</div>
                <div class="clickContainer">
                  <p>Click&nbsp;to&nbsp;confirm:</p>
                  <a href="#confirm_delete" class="confirmationItem">Confirm&nbsp;delete</a>
                </div>
              </div>
              `);
            let clickPopup = linksBlock.querySelector('.clickPopup');
            let clickTarget = linksBlock.querySelector('.clickTarget');
            let confirmationItem = linksBlock.querySelector('.confirmationItem');
            clickTarget.addEventListener('click', e => {
              clickPopup.classList.add('expanded');
              onClickOutsideOnce(clickPopup, () => clickPopup.classList.remove('expanded'));
            });
            clickPopup.addEventListener('blur', e => {
              if (!clickPopup.contains(e.relatedTarget)) { clickPopup.classList.remove('expanded'); }
            }, true);
            keyboardClickable(clickTarget);

            confirmationItem.onclick = e => {
              e.preventDefault();
              let hash = document.body.getAttribute('data-hash');
              let apiUrl = setProto(`//api.juick.com/messages?mid=${mid}&rid=${rid}&hash=${hash}`);
              xhrGetAsync(apiUrl, 3000, [], 'DELETE').then(response => {
                if (response.status == 200) {
                  linode.remove();
                  console.log(`Removed reply /${rid} successfully.`);
                } else {
                  console.warn('Unexpected result.');
                  console.warn(response);
                  linode.style.outline = '1px solid red';
                }
              });
              clickPopup.classList.remove('expanded');
            };
          }
        }
      });
    }
  }).catch(err => console.info(err));
}

function insertHoverMenu(container, idText, urlText) {
  container.insertAdjacentHTML('beforeend', /*html*/`
        <div class="hoverPopup">
          <div class="hoverTarget" tabindex="0">${svgIconHtml('link')}&nbsp;Links</div>
          <div class="hoverContainer">
            <p>Click to copy:</p>
        <a href="#copy_id" class="copyItem">${idText}</a>
        <a href="#copy_url" class="copyItem">${urlText}</a>
          </div>
        </div>
        `);
  let hoverPopup  = container.querySelector('.hoverPopup');
  let hoverTarget = container.querySelector('.hoverTarget');
      const copyAction = el => e => {
        e.preventDefault();
        selectAndCopyElementContents(el, true);
        el.classList.add('blinkOnce');
        setTimeout(() => {
          el.classList.remove('blinkOnce');
          hoverPopup.classList.remove('expanded');
        }, 700);
      };
  [].forEach.call(container.querySelectorAll('.copyItem'), copyItem => {
        copyItem.onclick = copyAction(copyItem);
      });
      hoverTarget.addEventListener('click', e => {
        hoverPopup.classList.add('expanded');
        onClickOutsideOnce(hoverPopup, () => hoverPopup.classList.remove('expanded'));
      });
      hoverTarget.addEventListener('mouseenter', e => hoverPopup.classList.add('expanded'));
      hoverPopup.addEventListener('mouseleave', e => hoverPopup.classList.remove('expanded'));
      hoverPopup.addEventListener('blur', e => {
        if (!hoverPopup.contains(e.relatedTarget)) { hoverPopup.classList.remove('expanded'); }
      }, true);
      keyboardClickable(hoverTarget);
}

function addCommentShareMenu() {
  let messageActionsBlock = document.querySelector('.msgthread .msg-cont > nav.l');
  let messageLink = document.querySelector('.msgthread .msg-ts > a');
  let mid = /\/(\d+)$/.exec(messageLink.pathname)[1];
  insertHoverMenu(
    messageActionsBlock,
    `#${mid}`,
    `${messageLink.href}`
    );

  let commentsBlock = document.querySelector('ul#replies');
  if (commentsBlock) {
    [].forEach.call(commentsBlock.children, linode => {
      let linksBlock = linode.querySelector('div.msg-links');
      let commentLink = linode.querySelector('div.msg-ts > a');
      if (!commentLink || !linksBlock) { return; }
      let mid = /\/(\d+)$/.exec(commentLink.pathname)[1];
      let rid = commentLink.hash.replace('#','');
      insertHoverMenu(
        linksBlock,
        `#${mid}/${rid}`,
        `${commentLink.href}`
        );
    });
  }
}

function addYearLinks() {
  let userId = getColumnUserName();
  let asideColumn = document.querySelector('aside#column > div');
  let footer = asideColumn.querySelector('#footer');
  let linksContainer = document.createElement('p');
  let years = [
    {y: (new Date()).getFullYear(), b: ''},
    {y: 2021, b: '?before=3006723'},
    {y: 2020, b: '?before=2984375'},
    {y: 2019, b: '?before=2959522'},
    {y: 2018, b: '?before=2931524'},
    {y: 2017, b: '?before=2893675'},
    {y: 2016, b: '?before=2857956'},
    {y: 2015, b: '?before=2816362'},
    {y: 2014, b: '?before=2761245'},
    {y: 2013, b: '?before=2629477'},
    {y: 2012, b: '?before=2183986'},
    {y: 2011, b: '?before=1695443'},
    {y: 2010, b: '?before=1140357'},
    {y: 2009, b: '?before=453764'},
    {y: 2008, b: '?before=20106'}
  ];
  linksContainer.innerHTML = years.map(item => `<a href="/${userId}/${item.b}">${item.y}</a>`).join(' ');
  asideColumn.insertBefore(linksContainer, footer);
}

function biggerAvatar() {
  let avatarImg = document.querySelector('#column #ctitle > a > img');
  if (avatarImg) {
    avatarImg.style.maxWidth = 'none';
    avatarImg.style.maxHeight = 'none';
  }
}

function loadTagsAsync(uid) {
  let hash = document.body.getAttribute('data-hash');
  return xhrGetAsync(setProto(`//api.juick.com/tags?user_id=${uid}&hash=${hash}`), 1000).then(response => {
    return JSON.parse(response.responseText);
  });
}

function makeTagsContainer(tags, numberLimit, sortBy='tag', uname, color=[0,0,0]) {
  const tagUrl = (uname)
    ? t => `/${uname}/?tag=${encodeURIComponent(t.tag)}`
    : t => `/tag/${encodeURIComponent(t.tag)}`;

  let [r, g, b] = color;
  let p0 = 0.7; // 70% of color range is used for color coding
  let maxC = 0.1;
  tags.forEach(t => { maxC = (t.messages > maxC) ? t.messages : maxC; });
  maxC = Math.log(maxC);

  if (numberLimit && (tags.length > numberLimit)) {
    tags = tags.sort((t1, t2) => t2.messages - t1.messages)
               .slice(0, numberLimit);
  }
  if (sortBy) {
    tags = tags.sort((t1, t2) => t1[sortBy].localeCompare(t2[sortBy]));
  }
  let aNodes = tags.map(t => {
    let p = (Math.log(t.messages) / maxC - 1) * p0 + 1; // normalize to [1-p0..1]
    return `<a title="${t.messages}" href="${tagUrl(t)}" style="color: rgba(${r},${g},${b},${p}) !important;">${t.tag}</a>`;
  });
  let tagsContainer = document.createElement('p');
  tagsContainer.classList.add('tagsContainer');
  tagsContainer.innerHTML = aNodes.join(' ');
  return tagsContainer;
}

function easyTagsUnderNewMessageForm() {
  getMyAccountAsync().then(account => {
    return loadTagsAsync(account.uid).then(tags => [account, tags]);
  }).then(([account, tags]) => {
    let color = parseRgbColor(computeStyle(document.createElement('a')).color);
    return makeTagsContainer(tags, 300, 'tag', account.uname, color);
  }).then(tagsContainer => {
    Array.from(document.querySelectorAll('section#content > a')).forEach(a => a.remove());
    let content = document.querySelector('section#content');
    let messageBox = content.querySelector('textarea.newmessage');
    content.insertAdjacentElement('beforeend', tagsContainer);
    const addTag = (box, newTag) => {
      let re = new RegExp(`(^.* |^)(\\*${escapeRegExp(newTag)})($|\\s[\\s\\S]*$)`, 'g');
      if (re.test(box.value)) {
        box.value = box.value.replace(re, '$1$3').replace(/(^.*? )( +)/, '$1').replace(/^ /, '');
      } else {
        box.value = box.value.replace(/(^.*)([\s\S]*)/g, `$1 *${newTag}$2`).replace(/(^.*? )( +)/, '$1').replace(/^ /, '');
      }
    };
    Array.from(tagsContainer.children).forEach(t => {
      let newTag = t.textContent;
      t.href = '';
      t.onclick = (e => { e.preventDefault(); addTag(messageBox, newTag); });
    });
    return;
  }
  ).catch( err => console.warn(err) );
}

function clearFileInput(inp) {
  try {
    inp.value = '';
    if (inp.value) {
      inp.type = '';
      inp.type = 'file';
    }
  } catch (e) {
    console.log(e);
    console.log('old browser having problems with cleaning file input');
  }
}

function clearImageInput(mode=undefined) {
  let form = document.querySelector('#oldNewMessage');
  if (!mode || mode == 'url') { form.querySelector('#image_url').value = ''; }
  if (!mode || mode == 'file') { clearFileInput(form.querySelector('#image_upload')); }
  form.classList.remove('withImage');
  let image = document.querySelector('#imagePreview img');
  if (image) { image.remove(); }
}

function showImagePreview(src) {
  let form = document.querySelector('#oldNewMessage');
  let preview = form.querySelector('#imagePreview');
  let image = preview.querySelector('img') || document.createElement('img');
  image.src = src;
  image.onerror = () => clearImageInput();
  preview.appendChild(image);
  form.classList.add('withImage');
}

function updateImageUrlPreview(imageUrlInput) {
  let form = document.querySelector('#oldNewMessage');
  clearFileInput(form.querySelector('#image_upload')); // clear file input
  setTimeout(() => showImagePreview(imageUrlInput.value), 0);
}

function updateImageFilePreview(imageInput) {
  let form = document.querySelector('#oldNewMessage');
  form.querySelector('#image_url').value = ''; // clear url input
  if (imageInput.files.length === 0) {
    clearImageInput();
  } else {
    let selFile = imageInput.files[0];
    let validTypes = ['image/jpeg', 'image/png'];
    if (validTypes.includes(selFile.type) && selFile.size < 10485760) {
      showImagePreview(window.URL.createObjectURL(selFile));
    } else {
      clearImageInput();
    }
  }
}

function addPostSharpFormUser() {
  getMyUserNameAsync().then(uname => {
    if (getColumnUserName() == uname) {
      addPostSharpForm();
    }
  }).catch(err => console.info(err));
}

function addPostSharpForm() {
  let content = document.querySelector('#content');
  let newMessageForm = /*html*/`
    <form id="oldNewMessage" action="/post" method="post" enctype="multipart/form-data">
      <textarea name="body" rows="1" placeholder="New message..."></textarea>
      <div id="charCounterBlock" class="empty"><div id="charCounter" style="width: 0%;"></div></div>
      <div id="bottomBlock">
        <div id="bottomLeftBlock">
          <input class="tags txt" name="tags" placeholder="Tags (space separated)">
          <div id="imgUploadBlock">
            <label for="image_upload" class="btn_like">Choose file</label>
            <input type="file" id="image_upload" name="attach" accept="image/jpeg,image/png">
            <input class="imgLink txt" id="image_url" name="img" placeholder="or paste link">
            <div class="info" title="JPG/PNG up to 10 MB">${svgIconHtml('question')}</div>
          </div>
          <div class="flexSpacer"></div>
          <input type="submit" class="subm" value="Send" disabled>
        </div>
        <div id="imagePreview">
          <a id="clear_button" href="javascript:void(0);" title="Remove attachment">${svgIconHtml('close')}</a>
        </div>
      </div>
    </form>`;
  content.insertAdjacentHTML('afterbegin', newMessageForm);
  let f = document.querySelector('#oldNewMessage');
  let ta = f.querySelector('textarea');
  let urlInput = f.querySelector('#image_url');
  let fileInput = f.querySelector('#image_upload');
  let clearButton = f.querySelector('#clear_button');
  let charCounter = f.querySelector('#charCounter');
  let charCounterBlock = f.querySelector('#charCounterBlock');
  let submitButton = f.querySelector('.subm');

  urlInput.addEventListener('paste', () => updateImageUrlPreview(urlInput));
  urlInput.addEventListener('change', () => updateImageUrlPreview(urlInput));
  fileInput.addEventListener('change', () => updateImageFilePreview(fileInput));
  clearButton.addEventListener('click', () => clearImageInput());
  ta.addEventListener('focus', () => {
    ta.parentNode.classList.add('active');
    document.querySelector('#oldNewMessage textarea').rows = 2;
  });
  ta.addEventListener('input', () => {
    const maxLen = 4096;
    let len = ta.value.length;
    submitButton.disabled = (len < 1 || len > maxLen);
    charCounterBlock.classList.toggle('invalid', len > maxLen);
    if (len <= maxLen) {
      charCounter.style.width = '' + (100.0 * len / maxLen) + '%';
      charCounter.textContent = '';
    } else {
      charCounter.style.width = '';
      charCounter.textContent = '' + len;
    }
  });
  autosize(ta);

  getMyAccountAsync().then(account => {
    return loadTagsAsync(account.uid).then(tags => [account, tags]);
  }).then(([account, tags]) => {
    let color = parseRgbColor(computeStyle(document.createElement('a')).color);
    return makeTagsContainer(tags, 60, 'tag', account.uname, color);
  }).then(tagsContainer => {
    let messageForm = document.getElementById('oldNewMessage');
    let tagsField = messageForm.querySelector('div > .tags');
    tagsField.parentNode.parentNode.parentNode.appendChild(tagsContainer);
    const addTag = (tagsField, newTag) => {
      let re = new RegExp(`(^|\\s)(${escapeRegExp(newTag)})(\\s|$)`, 'g');
      if (re.test(tagsField.value)) {
        tagsField.value = tagsField.value.replace(re, '$1$3').replace(/\s\s+/g, ' ').trim();
      } else {
        tagsField.value = (tagsField.value.trim() + ' ' + newTag).trim();
      }
    };
    Array.from(tagsContainer.children).forEach(t => {
      let newTag = t.textContent;
      t.href = '';
      t.onclick = (e => { e.preventDefault(); addTag(tagsField, newTag); });
    });
    return;
  }
  ).catch( err => console.warn(err) );
}

function sortTagsPage() {
  let uid = getColumnUid();
  let uname = getColumnUserName();
  loadTagsAsync(uid).then(tags => {
    let color = parseRgbColor(computeStyle(document.createElement('a')).color);
    return makeTagsContainer(tags, undefined, 'tag', uname, color);
  }).then(tagsContainer => {
    let contentSection = document.querySelector('section#content');
    setContent(contentSection, tagsContainer);
    return;
  }).catch( err => console.warn(err) );
}

function getLastArticleDate(html) {
  const re = /datetime\=\"([^\"]+) ([^\"]+)\"/;
  //const re = /\"timestamp\"\:\"([^\"]+) ([^\"]+)\"/;
  let [, dateStr, timeStr] = re.exec(html) || [];
  return (dateStr) ? new Date(`${dateStr}T${timeStr}`) : null;
}

function processPageAsync(url, retrievalFunction, timeout=110) {
  return new Promise(function(resolve, reject) {
    GM_xmlhttpRequest({
      method: 'GET',
      url: setProto(url),
      onload: function(response) {
        let result = null;
        if (response.status != 200) {
          console.log(`${url}: failed with ${response.status}, ${response.statusText}`);
        } else {
          result = retrievalFunction(response.responseText);
        }
        setTimeout(() => resolve(result), timeout);
      }
    });
  });
}

function loadUserDatesAsync(unprocessedUsers, processedUsers=[]) {
  return new Promise(function(resolve, reject) {
    if (unprocessedUsers.length === 0) {
      resolve(processedUsers);
    } else {
      let user = unprocessedUsers.splice(0,1)[0];
      //let postsUrl = "http://api.juick.com/messages?uname=" + user.id;
      let postsUrl = '//juick.com/' + user.id + '/';
      let recsUrl = '//juick.com/' + user.id + '/?show=recomm';

      processPageAsync(postsUrl, getLastArticleDate).then(lastPostDate => {
        processPageAsync(recsUrl, getLastArticleDate).then(lastRecDate => {
          let date = (lastPostDate > lastRecDate) ? lastPostDate : lastRecDate;
          if (date) {
            user.date = date;
            user.a.appendChild(document.createTextNode (` (${date.getFullYear()}-${(date.getMonth()+1).pad(2)}-${date.getDate().pad(2)})` ));
          } else {
            console.log(`${user.id}: no posts or recommendations found`);
          }
          processedUsers.push(user);
          loadUserDatesAsync(unprocessedUsers, processedUsers).then(rr => resolve(rr));
        });
      });
    }
  });
}

function sortUsers() {
  let contentBlock = document.getElementById('content');
  let button = document.getElementById('usersSortingButton');
  button.parentNode.removeChild(button);
  let usersTable = document.querySelector('div.users');
  let unprocessedUsers = Array.from(usersTable.querySelectorAll('span > a')).map(anode => {
    let userId = anode.pathname.replace(/\//g, '');
    return {a: anode, id: userId, date: (new Date(1970, 1, 1))};
  });
  loadUserDatesAsync(unprocessedUsers).then(
    processedUsers => {
      processedUsers.sort((b, a) => (a.date > b.date) - (a.date < b.date));
      usersTable.parentNode.removeChild(usersTable);
      let ul = document.createElement('div');
      ul.className = 'users sorted';
      processedUsers.forEach(user => {
        let li = document.createElement('span');
        li.appendChild(user.a);
        ul.appendChild(li);
      });
      contentBlock.appendChild(ul);
      return;
    }
  ).catch( err => console.warn(err) );
}

function addUsersSortingButton() {
  let contentBlock = document.getElementById('content');
  let usersTable = document.querySelector('div.users');
  let button = document.createElement('button');
  button.id = 'usersSortingButton';
  button.textContent = 'Sort by date';
  button.onclick = sortUsers;
  contentBlock.insertBefore(button, usersTable);
}

function turnIntoCts(node, makeNodeCallback) {
  node.classList.add('cts');
  node.onclick = function(e){
    e.preventDefault();
    makeNodeCallback();
    node.onclick = '';
    node.classList.remove('cts');
  };
}

function makeCts(makeNodeCallback, titleHtml) {
  let ctsNode = document.createElement('div');
  let placeholder = document.createElement('div');
  placeholder.className = 'placeholder';
  placeholder.innerHTML = titleHtml;
  ctsNode.appendChild(placeholder);
  turnIntoCts(ctsNode, makeNodeCallback);
  return ctsNode;
}

function makeTitle(embedType, reResult) {
  return (embedType.makeTitle)
    ? embedType.makeTitle(reResult)
    : naiveEllipsis(reResult[0], 55);
}

function makeNewNode(embedType, aNode, reResult, alwaysCts) {
  const withClasses = el => {
    if (embedType.className) {
      el.classList.add(...embedType.className.split(' '));
    }
    return el;
  };
  let isCts = alwaysCts
              || GM_getValue('cts_' + embedType.id, embedType.ctsDefault)
              || (embedType.ctsMatch && embedType.ctsMatch(aNode, reResult));
  if (isCts) {
    let div = makeCts(
      () => embedType.makeNode(aNode, reResult, div),
      'Click to show: ' + htmlEscape(makeTitle(embedType, reResult))
    );
    return withClasses(div);
  } else {
    return embedType.makeNode(aNode, reResult, withClasses(document.createElement('div')));
  }
}

function doFetchingEmbed(aNode, reResult, div, embedType, promiseCallback) {
  return doFetchingEmbed2(
    div,
    makeTitle(embedType, reResult),
    promiseCallback,
    () => {
      div.classList.add('loading');
      div.classList.remove('failed');
      embedType.makeNode(aNode, reResult, div);
    }
  );
}

function doFetchingEmbed2(div, title, promiseCallback, remakeCallback) {
  div.innerHTML = `<span>loading ${htmlEscape(title)}</span>`;
  div.classList.add('embed', 'loading');
  promiseCallback()
    .then(() => { div.classList.remove('loading'); div.classList.add('loaded'); })
    .catch(e => {
      let { reason, response, permanent } = e;
      console.log(
        (!!reason || !!response)
          ? { reason: reason, response: response, permanent: permanent, div: div, title: title }
          : e
      );
      if (permanent) {
        div.textContent = reason;
      } else {
        div.textContent = `Failed to load (${reason})`;
        div.classList.remove('loading');
        div.classList.add('failed');
        turnIntoCts(div, remakeCallback);
      }
    });
  return div;
}

function makeIframe(src, w, h, scrolling='no') {
  let iframe = document.createElement('iframe');
  iframe.style.width = w;
  iframe.style.height = h;
  iframe.frameBorder = 0;
  iframe.scrolling = scrolling;
  iframe.setAttribute('allowFullScreen', '');
  iframe.src = src;
  iframe.innerHTML = 'Cannot show iframes.';
  return iframe;
}

function makeResizableToRatio(element, ratio) {
  element.dataset['ratio'] = ratio;
  makeResizable(element, w => w * element.dataset['ratio']);
}

// calcHeight :: Number -> Number -- calculate element height for a given width
function makeResizable(element, calcHeight) {
  const setHeight = el => {
    if (document.body.contains(el) && (el.offsetWidth > 0)) {
      el.style.height = (calcHeight(el.offsetWidth)).toFixed(2) + 'px';
    }
  };
  window.addEventListener('resize', () => setHeight(element));
  setHeight(element);
}

function makeIframeWithHtmlAndId(myHTML) {
  let id = randomId();
  let script = `(function(html){
                  var iframe = document.createElement('iframe');
                  iframe.id='${id}';
                  iframe.onload = function(){var d = iframe.contentWindow.document; d.open(); d.write(html); d.close();};
                  document.body.appendChild(iframe);
                })(${JSON.stringify(myHTML)});`;
  window.eval(script);
  return id;
}

function makeIframeHtmlAsync(html, w, h, insertCallback, successCallback, failCallback) {
  return new Promise((resolve, reject) => {
    let iframeId = makeIframeWithHtmlAndId(html);
    let iframe = document.getElementById(iframeId);
    iframe.className = 'newIframe';
    iframe.width = w;
    iframe.height = h;
    iframe.frameBorder = 0;
    iframe.addEventListener('load', () => resolve(successCallback(iframe)), false);
    iframe.onerror = er => reject(failCallback(er));
    insertCallback(iframe);
  });
}

function loadScript(url, async=false, callback, once=false) {
  if (once && [].some.call(document.scripts, s => s.src == url)) {
    if (typeof callback == 'function') { callback(); }
    return;
  }

  let head = document.getElementsByTagName('head')[0];
  let script = document.createElement('script');
  script.type = 'text/javascript';
  script.src = url;
  if (async) { script.setAttribute('async', ''); }

  if (typeof callback == 'function') {
    script.onload = callback;
  }

  head.appendChild(script);
}

function addScript(scriptString, once=false) {
  if (once && [].some.call(document.scripts, s => s.text == scriptString)) { return; }

  let head = document.getElementsByTagName('head')[0];
  let script = document.createElement('script');
  script.type = 'text/javascript';
  script.text = scriptString;
  head.appendChild(script);
}

function splitScriptsFromHtml(html) {
  const scriptRe = /<script.*?(?:src="(.+?)".*?)?>([\s\S]*?)<\/\s?script>/gmi;
  let scripts = getAllMatchesAndCaptureGroups(scriptRe, html).map(m => {
    let [, url, s] = m;
    return (url)
      ? { call: function(){ loadScript(url, true); } }
      : { call: function(){ setTimeout(window.eval(s), 0); } };
  });
  let strippedHtml = html.replace(scriptRe, '');
  return [strippedHtml, scripts];
}

function extractDomain(url) {
  const domainRe = /^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/\n]+)/i;
  return domainRe.exec(url)[1];
}

function isDefaultLinkText(aNode) {
  return (aNode.textContent == extractDomain(aNode.href));
}

function urlReplace(match, p1, p2, p3) {
  let isBrackets = (p1 !== undefined);
  return (isBrackets)
    ? `<a href="${fixWwwLink(p2 || p3)}">${p1}</a>`
    : `<a href="${fixWwwLink(match)}">${extractDomain(match)}</a>`;
}

function urlReplaceInCode(match, p1, p2, p3) {
  let isBrackets = (p1 !== undefined);
  return (isBrackets)
    ? `<a href="${fixWwwLink(p2 || p3)}">${match}</a>`
    : `<a href="${fixWwwLink(match)}">${match}</a>`;
}

function messageReplyReplace(messageId) {
  return function(match, mid, rid) {
    let replyPart = (rid && rid != '0') ? '#' + rid : '';
    return `<a href="/m/${mid || messageId}${replyPart}">${match}</a>`;
  };
}

function juickFormat(txt, messageId, isCode) {
  const urlRe = /(?:\[([^\]\[]+)\](?:\[([^\]]+)\]|\(((?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[-\w+*&@#/%=~|$?!:;,.])*(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[\w+*&@#/%=~|$]))\))|\b(?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[-\w+*&@#/%=~|$?!:;,.])*(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[\w+*&@#/%=~|$]))/gi;
  const bqReplace = m => m.replace(/^(?:>|&gt;)\s?/gmi, '');
  return (isCode)
    ? formatText(txt, [
      { pr: 1, re: urlRe, with: urlReplaceInCode },
      { pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) },
      { pr: 1, re: /\B@([\w-]+)\b/gi, with: '<a href="/$1">@$1</a>' },
    ])
    : formatText(txt, [
      { pr: 0, re: /((?:^(?:>|&gt;)\s?[\s\S]+?$\n?)+)/gmi, brackets: true, with: ['<q>', '</q>', bqReplace] },
      { pr: 1, re: urlRe, with: urlReplace },
      { pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) },
      { pr: 1, re: /\B@([\w-]+)\b/gi, with: '<a href="/$1">@$1</a>' },
      { pr: 2, re: /\B\*([^\n]+?)\*((?=\s)|(?=$)|(?=[!\"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<b>', '</b>'] },
      { pr: 2, re: /\B\/([^\n]+?)\/((?=\s)|(?=$)|(?=[!\"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<i>', '</i>'] },
      { pr: 2, re: /\b\_([^\n]+?)\_((?=\s)|(?=$)|(?=[!\"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<span class="u">', '</span>'] },
      { pr: 3, re: /\n/g, with: '<br/>' },
    ]);
}

function juickPhotoLink(postId, ext) {
  return `//i.juick.com/p/${postId}.${ext}`;
}

function juickId([, userId, postId, replyId]) {
  let isReply = replyId && (replyId != '0');
  return '#' + postId + (isReply ? '/' + replyId : '');
}

function getEmbeddableLinkTypes() {
  return [
    {
      name: 'Juick',
      id: 'embed_juick',
      className: 'juickEmbed',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/juick\.com\/(?!tag\/)(?:m\/|([\w-]+)\/|)([\d]{6,}\b)(?:#(\d+))?/i,
      ctsMatch: function(aNode, reResult) {
        let [url, userId, msgId, replyId] = reResult;
        let thisPageMsgMatch = /\/(\d+)$/.exec(window.location.pathname);
        let isSameThread = thisPageMsgMatch && thisPageMsgMatch[1] == msgId;
        return !isSameThread && replyId && (+replyId) > 150;
      },
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, userId, msgId, replyId] = reResult;
        userId = userId || 'm';
        let apiUrl = setProto('//api.juick.com/thread?mid=' + msgId);

        let isReply = (replyId && replyId !== '0');
        let mrid = (isReply) ? parseInt(replyId, 10) : 0;
        let idStr = juickId(reResult);
        let linkStr = '/' + userId + '/' + msgId + (isReply ? '#' + mrid : '');

        if (GM_getValue('enable_move_into_view_on_same_page', true)) {
          let thisPageMsgMatch = /\/(\d+)$/.exec(window.location.pathname);
          let isSameThread = thisPageMsgMatch && thisPageMsgMatch[1] == msgId;
          if (isSameThread) {
            let linkedItem = Array.from(document.querySelectorAll('li.msg'))
                                  .find(x => x.id == replyId || (mrid == 0 && x.id == 'msg-' + msgId));
            if (linkedItem) {
              let thisMsg = aNode.closest('li.msg > div.msg-cont');
              let linkedMsg = linkedItem.querySelector('div.msg-cont');
              setMoveIntoViewOnHover(aNode, thisMsg, linkedMsg, 5, 30);
              return;
            }
          }
        }

        const callback = response => {
          let threadInfo = JSON.parse(response.responseText);
          let msg = (!isReply) ? threadInfo[0] : threadInfo.find(x => (x.rid == mrid));
          if (!msg) {
            throw { reason: `${idStr} does not exist`, response: response, permanent: true, url: apiUrl };
          }

          let withLikes = msg.likes && msg.likes > 0;
          let isReplyToOp = isReply && (!msg.replyto || msg.replyto == 0);
          let withReplies = msg.replies && msg.replies > 0;
          let isNsfw = msg.tags && msg.tags.some(t => t.toUpperCase() == 'NSFW');
          let isCode = msg.tags && msg.tags.some(t => t.toUpperCase() == 'CODE');

          if (isCode) { div.classList.add('codePost'); }

          let tagsStr = (msg.tags) ? '<div class="msg-tags">' + msg.tags.map(x => `<a href="/${msg.user.uname}/?tag=${encodeURIComponent(x)}">${x}</a>`).join('') + '</div>' : '';
          let photoStr = (msg.photo) ? `<div><a href="${juickPhotoLink(msg.mid, msg.attach)}"><img ${(isNsfw ? 'class="nsfw" ' : '')}src="${unsetProto(msg.photo.small)}"/></a></div>` : '';
          let replyStr = (isReply)
            ? ` in reply to <a class="whiteRabbit" href="/${userId}/${msg.mid}${isReplyToOp ? '' : '#' + msg.replyto}">#${msg.mid}${isReplyToOp ? '' : '/' + msg.replyto}</a>`
            : '';
          let likesTitle = (!!msg.recommendations) ? `title="${msg.recommendations.join(', ')}"` : '';
          let likesDiv = (withLikes) ? `<div class="likes" ${likesTitle}><a href="${linkStr}">${svgIconHtml('heart')}${msg.likes}</a></div>` : '';
          let commentsDiv = (withReplies) ? `<div class="replies" title="${[...new Set(threadInfo.slice(1).map(x => x.user.uname))].join(', ')}"><a href="${linkStr}">${svgIconHtml('comment')}${msg.replies}</a></div>` : '';
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="msg-avatar"><a href="/${msg.user.uname}/"><img src="//i.juick.com/a/${msg.user.uid}.png" alt="${msg.user.uname}"></a></div>
              <div class="top-right">
                <div class="top-right-1st">
                  <div class="title"><a href="/${msg.user.uname}/">@${msg.user.uname}</a></div>
                  <div class="date"><a href="${linkStr}">${msg.timestamp}</a></div>
                </div>
                <div class="top-right-2nd">${tagsStr}</div>
              </div>
            </div>
            <div class="desc">${juickFormat(msg.body, msgId, isCode)}</div>${photoStr}
            <div class="bottom">
              <div class="embedReply msg-links"><a href="${linkStr}">${idStr}</a>${replyStr}</div>
              <div class="right">${likesDiv}${commentsDiv}</div>
            </div>
            `;

          let allLinks = div.querySelectorAll('.desc a, .embedReply a.whiteRabbit');
          let embedContainer = div.parentNode;
          embedLinks(Array.from(allLinks).reverse(), embedContainer, true, div);
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      },
      makeTitle: function(reResult) {
        return juickId(reResult);
      },
      linkTextUpdate: function(aNode, reResult) {
        if (isDefaultLinkText(aNode)) {
          //var isUser = (reResult[1]);
          aNode.textContent = juickId(reResult); // + ((!isReply && isUser) ? ' (@' + reResult[1] + ')' : '');
        }
      }
    },
    {
      name: 'Jpeg and png images',
      id: 'embed_jpeg_and_png_images',
      className: 'picture compact',
      onByDefault: true,
      ctsDefault: false,
      re: /\.(jpe?g|png|svg)(:[a-zA-Z]+)?(?:\?[\w&;\?=]*)?$/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<a href="${aNode.href}"><img src="${aNode.href}"></a>`;
        return div;
      }
    },
    {
      name: 'Gif images',
      id: 'embed_gif_images',
      className: 'picture compact',
      onByDefault: true,
      ctsDefault: true,
      re: /\.gif(:[a-zA-Z]+)?(?:\?[\w&;\?=]*)?$/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<a href="${aNode.href}"><img src="${aNode.href}"></a>`;
        return div;
      }
    },
    {
      name: 'Video (webm, mp4, ogv)',
      id: 'embed_webm_and_mp4_videos',
      className: 'video compact',
      onByDefault: true,
      ctsDefault: false,
      re: /\.(webm|mp4|m4v|ogv)(?:\?[\w&;\?=]*)?$/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<video src="${aNode.href}" title="${aNode.href}" controls></video>`;
        return div;
      }
    },
    {
      name: 'Audio (mp3, ogg, weba, opus, m4a, oga, wav)',
      id: 'embed_sound_files',
      className: 'audio singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /\.(mp3|ogg|weba|opus|m4a|oga|wav)(?:\?[\w&;\?=]*)?$/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<audio src="${aNode.href}" title="${aNode.href}" controls></audio>`;
        return div;
      }
    },
    {
      name: 'YouTube videos (and playlists)',
      id: 'embed_youtube_videos',
      className: 'youtube resizableV singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.|m\.|gaming\.)?(?:youtu(?:(?:\.be\/|be\.com\/(?:v|embed)\/)([-\w]+)|be\.com\/watch)((?:(?:\?|&(?:amp;)?)(?:\w+=[-\.\w]*[-\w]))*)|youtube\.com\/playlist\?list=([-\w]*)(&(amp;)?[-\w\?=]*)?)/i,
      makeNode: function(aNode, reResult, div) {
        let [url, v, args, plist] = reResult;
        let iframeUrl;
        if (plist) {
          iframeUrl = '//www.youtube-nocookie.com/embed/videoseries?list=' + plist;
        } else {
          let pp = {}; args.replace(/^\?/, '')
                           .split('&')
                           .map(s => s.split('='))
                           .forEach(z => pp[z[0]] = z[1]);
          let embedArgs = { rel: '0' };
          if (pp.t) {
            const tre = /^(?:(\d+)|(?:(\d+)h)?(?:(\d+)m)?(\d+)s|(?:(\d+)h)?(\d+)m|(\d+)h)$/i;
            let [, t, h, m, s, h1, m1, h2] = tre.exec(pp.t);
            embedArgs['start'] = (+t) || ((+(h || h1 || h2 || 0))*60*60 + (+(m || m1 || 0))*60 + (+(s || 0)));
          }
          if (pp.list) {
            embedArgs['list'] = pp.list;
          }
          v = v || pp.v;
          let argsStr = Object.keys(embedArgs)
                              .map(k => `${k}=${embedArgs[k]}`)
                              .join('&');
          iframeUrl = `//www.youtube-nocookie.com/embed/${v}?${argsStr}`;
        }
        let iframe = makeIframe(iframeUrl, '100%', '360px');
        iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
        return setContent(div, iframe);
      }
    },
    {
      name: 'Vimeo videos',
      id: 'embed_vimeo_videos',
      className: 'vimeo resizableV',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?(?:player\.)?vimeo\.com\/(?:video\/|album\/[\d]+\/video\/)?([\d]+)/i,
      makeNode: function(aNode, reResult, div) {
        let iframe = makeIframe('//player.vimeo.com/video/' + reResult[1], '100%', '360px');
        iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
        return setContent(div, iframe);
      }
    },
    {
      name: 'Dailymotion videos',
      id: 'embed_youtube_videos',
      className: 'dailymotion resizableV',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?dailymotion\.com\/video\/([a-zA-Z\d]+)(?:_[-%\w]*)?/i,
      makeNode: function(aNode, reResult, div) {
        let iframe = makeIframe('//www.dailymotion.com/embed/video/' + reResult[1], '100%', '360px');
        iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
        return setContent(div, iframe);
      }
    },
    {
      name: 'Coub clips',
      id: 'embed_coub_clips',
      className: 'coub resizableV',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?coub\.com\/(?:view|embed)\/([a-zA-Z\d]+)/i,
      makeNode: function(aNode, reResult, div) {
        let embedUrl = '//coub.com/embed/' + reResult[1] + '?muted=false&autostart=false&originalSize=false&startWithHD=false';
        let iframe = makeIframe(embedUrl, '100%', '360px');
        iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
        return setContent(div, iframe);
      }
    },
    {
      name: 'Twitch streams',
      id: 'embed_twitch',
      className: 'twitch resizableV',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?twitch\.tv\/(\w+)(?:\/v\/(\d+))?/i,
      makeNode: function(aNode, reResult, div) {
        let [, channel, video] = reResult;
        let url = (video)
          ? `https://player.twitch.tv/?video=v${video}&parent=juick.com&autoplay=false`
          : `https://player.twitch.tv/?channel=${channel}&parent=juick.com&autoplay=false`;
        let iframe = makeIframe(url, '100%', '378px');
        iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
        return setContent(div, iframe);
      }
    },
    {
      name: 'Steam games',
      id: 'embed_steam',
      className: 'steam singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/store\.steampowered\.com\/app\/(\d+)/i,
      makeNode: function(aNode, reResult, div) {
        let iframe = makeIframe('//store.steampowered.com/widget/' + reResult[1] + '/', '100%', '190px');
        return setContent(div, iframe);
      }
    },
    {
      name: 'Bandcamp music',
      id: 'embed_bandcamp_music',
      className: 'bandcamp',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(\w+)\.bandcamp\.com\/(track|album)\/([-%\w]+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, band, pageType, pageName] = reResult;

        const callback = response => {
          let videoUrl, videoH;
          const metaRe = /<\s*meta\s+(?:property|name)\s*=\s*\"([^\"]+)\"\s+content\s*=\s*\"([^\"]*)\"\s*>/gmi;
          let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText);
          matches.forEach(m => {
            if (m[1] == 'og:video') { videoUrl = m[2]; }
            if (m[1] == 'video_height') { videoH = parseInt(m[2], 10); }
          });
          let isAlbum = pageType == 'album';
          if (isAlbum) { videoUrl = videoUrl.replace('/tracklist=false', '/tracklist=true'); }
          videoUrl = videoUrl.replace('/artwork=small', '');
          let iframe = makeIframe(videoUrl, '100%', '600px');
          setContent(div, wrapIntoTag(iframe, 'div', 'bandcamp resizableV'));
          let calcHeight = w => w + videoH + (isAlbum ? 162 : 0);
          iframe.onload = () => makeResizable(iframe, calcHeight);
          div.classList.remove('embed');
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000).then(callback));
      }
    },
    {
      name: 'SoundCloud music',
      id: 'embed_soundcloud_music',
      className: 'soundcloud',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?soundcloud\.com\/(([\w\-\_]*)\/(?:sets\/)?(?!tracks$)([-%\w]*))(?:\/)?/i,
      makeNode: function(aNode, reResult, div) {
        let embedUrl = '//w.soundcloud.com/player/?url=//soundcloud.com/' + reResult[1] + '&amp;auto_play=false&amp;hide_related=false&amp;show_comments=true&amp;show_user=true&amp;show_reposts=false&amp;visual=true';
        return setContent(div, makeIframe(embedUrl, '100%', 450));
      }
    },
    {
      name: 'Mixcloud music',
      id: 'embed_mixcloud_music',
      className: 'mixcloud singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?mixcloud\.com\/(?!discover\/)([\w]+)\/(?!playlists\/)([-%\w]+)\/?/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, author, mix] = reResult;
        let apiUrl = 'https://www.mixcloud.com/oembed/?format=json&url=' + encodeURIComponent(url);

        const callback = response => {
          let json = JSON.parse(response.responseText);
          div.innerHTML = json.html;
          div.className = div.classList.remove('embed');
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'Яндекс.Музыка',
      id: 'embed_yandex_music',
      className: 'yandexMusic singleColumn',
      onByDefault: true,
      ctsDefault: true,
      re: /^(?:https?:)?\/\/music\.yandex\.ru(?!$|\/artist\/\d+$)(?:\/album\/(\d+))?(?:\/track\/(\d+))?/i,
      makeNode: function(aNode, reResult, div) {
        let [url, album, track] = reResult;
        let isTrack = !!track;
        let embedUrl = (isTrack)
          ? `https://music.yandex.ru/iframe/#track/${track}/${album ? album + '/' : ''}`
          : `https://music.yandex.ru/iframe/#album/${album}/`;
        return setContent(div, makeIframe(embedUrl, '100%', isTrack ? '100px' : '420px'));
      }
    },
    {
      name: 'Flickr images',
      id: 'embed_flickr_images',
      className: 'flickr',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:(?:www\.)?flickr\.com\/photos\/([\w@-]+)\/(\d+)|flic.kr\/p\/(\w+))(?:\/)?/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let apiUrl = 'https://www.flickr.com/services/oembed?format=json&url=' + encodeURIComponent(reResult[0]);

        const callback = response => {
          let json = JSON.parse(response.responseText);
          let imageUrl = (json.url) ? json.url : json.thumbnail_url; //.replace('_b.', '_z.');
          let typeStr = (json.flickr_type == 'photo') ? '' : ` (${json.flickr_type})`;
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                <a href="${json.web_page}">${json.title}</a>${typeStr} by <a href="${json.author_url}">${json.author_name}</a>
              </div>
            </div>
            <a href="${aNode.href}"><img src="${imageUrl}"></a>`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'DeviantArt images',
      id: 'embed_deviantart_images',
      className: 'deviantart',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/([\w-]+)\.deviantart\.com\/art\/([-%\w]+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, userId, workId] = reResult;
        let apiUrl = 'https://backend.deviantart.com/oembed?format=json&url=' + encodeURIComponent(url);

        const callback = response => {
          let json = JSON.parse(response.responseText);
          let date = new Date(json.pubdate);
          let typeStr = (json.type == 'photo') ? '' : ` (${json.type})`;
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                <a href="${url}">${json.title}</a>${typeStr} by <a href="${json.author_url}">${json.author_name}</a>
              </div>
              <div class="date">${date.toLocaleString('ru-RU')}</div>
            </div>`;
          if ((json.type == 'rich') && json.html) {
            div.innerHTML += `<div class="desc">${json.html}...</div>`;
          } else {
            let imageClassStr = (json.safety == 'adult') ? 'class="rating_e"' : '';
            let imageUrl = json.fullsize_url || json.url || json.thumbnail_url;
            div.innerHTML += `<a href="${aNode.href}"><img ${imageClassStr} src="${imageUrl}"></a>`;
          }
        };

        // consider adding note 'Failed to load (maybe this article can\'t be embedded)'
        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'Imgur gifv videos',
      id: 'embed_imgur_gifv_videos',
      className: 'video compact',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:\w+\.)?imgur\.com\/([a-zA-Z\d]+)\.gifv/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<video src="//i.imgur.com/${reResult[1]}.mp4" title="${aNode.href}" controls loop></video>`;
        return div;
      }
    },
    {
      name: 'Imgur indirect links',
      id: 'embed_imgur_indirect_links',
      className: 'imgur singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:\w+\.)?imgur\.com\/(?:(gallery|a)\/)?(?!gallery|jobs|about|blog|apps)([a-zA-Z\d]+)(?:#\d{1,2}$|#([a-zA-Z\d]+))?(\/\w+)?$/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [, albumType, contentId, albumImageId] = reResult;

        let url = (albumType && albumImageId)
          ? 'http://imgur.com/' + albumImageId
          : 'http://imgur.com/' + (albumType ? albumType + '/' : '') + contentId;
        let apiUrl = 'https://api.imgur.com/oembed.json?url=' + encodeURIComponent(url);

        const callback = response => {
          let json = JSON.parse(response.responseText);
          let iframe = makeIframeHtmlAsync(
            json.html,
            '100%',
            '24px',
            iframe => div.appendChild(iframe),
            iframe => [iframe, iframe.contentWindow.document],
            e => ({ reason: e.message, permanent: false, url: apiUrl })
          ).then(([iframe, doc]) => {
            return waitAndRunAsync(
              () => !!doc.querySelector('iframe'),
              50,
              100,
              () => ([iframe, doc]),
              () => ({ reason: 'timeout', permanent: false, url: apiUrl })
            );
          }).then(([iframe, doc]) => {
            div.replaceChild(doc.querySelector('iframe'), iframe);
            div.querySelector('span').remove();
            div.classList.remove('embed');
          });
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'Gfycat indirect links',
      id: 'embed_gfycat_indirect_links',
      className: 'gfycat',
      onByDefault: true,
      ctsDefault: true,
      re: /^(?:https?:)?\/\/(?:\w+\.)?gfycat\.com\/([a-zA-Z\d]+)$/i,
      makeNode: function(aNode, reResult, div) {
        return setContent(div, makeIframe('//gfycat.com/ifr/' + reResult[1], '100%', 480));
      }
    },
    {
      name: 'Twitter',
      id: 'embed_twitter_status',
      className: 'twi',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?(?:mobile\.)?twitter\.com\/([\w-]+)\/status(?:es)?\/([\d]+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, userId, postId] = reResult;
        url = url.replace('mobile.','');

        const predicates = [
          {
            msg: response => (response.statusText ? `${response.status} - ${response.statusText}` : `${response.status}`),
            test: response => response.status != 200,
            permanent: response => response.status != 503
          },
          {
            msg: response => 'Account @' + userId + ' is suspended',
            test: response => response.finalUrl.endsWith('account/suspended'),
            permanent: response => true
          },
          {
            msg: response => 'Account @' + userId + ' is protected',
            test: response => response.finalUrl.indexOf('protected_redirect=true') != -1,
            permanent: response => true
          }
        ];
        const callback = response => {
          let images = [];
          let userGenImg = false;
          let isVideo = false;
          let videoUrl, videoW, videoH;
          let title, description;
          const metaRe = /<\s*meta\s+property\s*=\s*\"([^\"]+)\"\s+content\s*=\s*\"([^\"]*)\"\s*>/gmi;
          let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText);
          matches.forEach(m => {
            if (m[1] == 'og:title') { title = m[2]; }
            if (m[1] == 'og:description') {
              description = htmlDecode(m[2])
                .replace(/\n/g,'<br/>')
                .replace(/\B@(\w{1,15})\b/gmi, '<a href="//twitter.com/$1">@$1</a>')
                .replace(/#(\w+)/gmi, '<a href="//twitter.com/hashtag/$1">#$1</a>')
                .replace(/(?:https?:)?\/\/t\.co\/([\w]+)/gmi, '<a href="$&">$&</a>');
            }
            if (m[1] == 'og:image') { images.push(m[2]); }
            if (m[1] == 'og:image:user_generated') { userGenImg = true; }
            if (m[1] == 'og:video:url') { videoUrl = m[2]; isVideo = true; }
            if (m[1] == 'og:video:height') { videoH = +m[2]; }
            if (m[1] == 'og:video:width') { videoW = +m[2]; }
          });
          const timestampMsRe = /\bdata-time-ms\s*=\s*\"([^\"]+)\"/gi;
          let timestampMsResult = timestampMsRe.exec(response.responseText);
          let dateDiv = (timestampMsResult) ? `<div class="date">${new Date(+timestampMsResult[1]).toLocaleString('ru-RU')}</div>` : '';
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                ${title} (<a href="//twitter.com/${userId}">@${userId}</a>)
              </div>
              ${dateDiv}
            </div>
            <div class="desc">${description}</div>`;
          if (userGenImg) { div.innerHTML += images.map(x => { return `<a href="${x}"><img src="${x}"></a>`; }).join(''); }
          if (isVideo) {
            let { w, h } = fitToBounds(videoW, videoH, 620, 720);
            let ctsVideo = makeCts(
              () => setContent(ctsVideo, makeIframe(videoUrl, w + 'px', h + 'px')),
              `<img src="${images[0]}">${svgIconHtml('play')}`
            );
            div.appendChild(ctsVideo);
          }
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000, predicates).then(callback));
      }
    },
    {
      name: 'Facebook',
      id: 'embed_facebook',
      className: 'fbEmbed singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.|m\.)?facebook\.com\/(?:[\w.]+\/(?:posts|videos|photos)\/[\w:./]+(?:\?[\w=%&.]+)?|(?:photo|video)\.php\?[\w=%&.]+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;

        const promiseCallback = () => {
          setTimeout(loadScript('https://connect.facebook.net/en_GB/sdk.js#xfbml=1&version=v2.3', false, undefined, true), 0);
          div.insertAdjacentHTML('beforeend', `<div class="fb-post" data-href="${aNode.href}" data-width="640" />`);
          return waitAndRunAsync(
            () => !!div.querySelector('iframe[height]'),
            20,
            100,
            () => {},
            () => ({ reason: 'timeout', permanent: false })
          ).then(() => {
            div.querySelector('span').remove();
            div.classList.remove('embed');
          }).catch(e => {
            console.log('Juick tweaks: time out on facebook embedding, applying workaround.');
            let embedUrl = 'https://www.facebook.com/plugins/post.php?width=640&height=570&href=' + encodeURIComponent(reResult[0]);
            div.innerHTML = '';
            div.appendChild(makeIframe(embedUrl, '100%', 570));
            div.classList.remove('embed');
            div.classList.add('fallback');
          });
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, promiseCallback);
      }
    },
    {
      name: 'Telegram',
      id: 'embed_telegram',
      className: 'telegram singleColumn',
      onByDefault: false,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/t\.me\/(.+)\/(\d+)$/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [, channelId, postId] = reResult;

        const promiseCallback = () => {
          setTimeout(() => {
            let script = document.createElement('script');
            script.async = true;
            script.type = 'text/javascript';
            script.src = 'https://telegram.org/js/telegram-widget.js?4';
            script.dataset.telegramPost = `${channelId}/${postId}`;
            script.dataset.width = '100%';
            div.appendChild(script);
          }, 0);
          return waitAndRunAsync(
            () => !!div.querySelector('iframe'),
            30,
            100,
            () => {},
            () => ({ reason: 'timeout', permanent: false })
          ).then(() => {
            div.querySelector('span').remove();
            div.classList.remove('embed');
          });
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, promiseCallback);
      }
    },
    {
      name: 'Tumblr',
      id: 'embed_tumblr',
      className: 'tumblr singleColumn',
      onByDefault: true,
      ctsDefault: true,
      re: /^(?:https?:)?\/\/(?:([\w\-\_]+)\.)?tumblr\.com\/post\/([\d]*)(?:\/([-%\w]*))?/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let apiUrl = 'https://www.tumblr.com/oembed/1.0?url=' + reResult[0];

        const callback = response => {
          let json = JSON.parse(response.responseText);
          return makeIframeHtmlAsync(
            json.html,
            '100%',
            '24px',
            iframe => div.appendChild(iframe),
            iframe => [iframe, iframe.contentWindow.document],
            e => ({ reason: e.message, permanent: false, url: apiUrl })
          ).then(([iframe, doc]) => {
            return waitAndRunAsync(
              () => !!doc.querySelector('iframe[height]'),
              50,
              100,
              () => ([iframe, doc]),
              () => ({ reason: 'timeout', permanent: false, url: apiUrl })
            );
          }).then(([iframe, doc]) => {
            div.replaceChild(doc.querySelector('iframe[height]'), iframe);
            div.querySelector('span').remove();
            div.classList.remove('embed');
          });
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'Reddit',
      id: 'embed_reddit',
      className: 'reddit singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.|np\.|m\.)?reddit\.com\/r\/([\w]+)\/comments\/(\w+)(?:\/(?:\w+(?:\/(\w+)?)?)?)?/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let apiUrl = 'https://www.reddit.com/oembed?url=' + encodeURIComponent(reResult[0]);

        const callback = response => {
          let json = JSON.parse(response.responseText);
          let [h, ss] = splitScriptsFromHtml(json.html);
          div.innerHTML += h;
          ss.forEach(s => s.call());
          return waitAndRunAsync(
            () => { let iframe = div.querySelector('iframe'); return (iframe && (parseInt(iframe.height) > 30)); },
            30,
            100,
            () => {},
            () => ({ reason: 'timeout', permanent: false, url: apiUrl })
          ).then(iframe => {
            div.querySelector('iframe').style.margin = '0px';
            div.querySelector('span').remove();
            div.classList.remove('embed');
          });
        };

        // consider adding note 'Failed to load (maybe this article can\'t be embedded)'
        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'WordPress',
      id: 'embed_wordpress',
      className: 'wordpress singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(\w+)\.wordpress\.com\/(\d{4})\/(\d{2})\/(\d{2})\/([-\w%\u0400-\u04FF]+)(?:\/)?/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url,site,year,month,day,slug] = reResult;
        let apiUrl = `https://public-api.wordpress.com/rest/v1.1/sites/${site}.wordpress.com/posts/slug:${slug}`;

        const callback = response => {
          let json = JSON.parse(response.responseText);
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                "<a href="${url}">${json.title}</a>" by <a href="${json.author.URL}">${json.author.name}</a>
              </div>
              <div class="date">${new Date(json.date).toLocaleString('ru-RU')}</div>
            </div>
            <hr/>
            <div class="desc">${json.content}</div>`;
        };

        // consider adding note 'Failed to load (maybe this article can\'t be embedded)'
        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'SlideShare',
      id: 'embed_slideshare',
      className: 'slideshare singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:\w+\.)?slideshare\.net\/(\w+)\/([-%\w]+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, author, id] = reResult;
        let apiUrl = 'http://www.slideshare.net/api/oembed/2?format=json&url=' + url;

        const callback = response => {
          let json = JSON.parse(response.responseText);
          let baseSize = 640;
          let newH = 1.0 * baseSize / json.width * json.height;
          let iframeStr = json.html
                              .match(/<iframe[^>]+>[\s\S]*?<\/iframe>/i)[0]
                              .replace(/width="\d+"/i, `width="${baseSize}"`)
                              .replace(/height="\d+"/i, `height="${newH}"`);
          div.innerHTML = iframeStr;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'Gist',
      id: 'embed_gist',
      className: 'gistEmbed singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/gist\.github\.com\/(?:([\w-]+)\/)?([A-Fa-f0-9]+)\b/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, , id] = reResult;
        let apiUrl = 'https://gist.github.com/' + id + '.json';

        const callback = response => {
          let json = JSON.parse(response.responseText);
          let date = new Date(json.created_at).toLocaleDateString('ru-RU');
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                "${json.description}" by <a href="https://gist.github.com/${json.owner}">${json.owner}</a>
              </div>
              <div class="date">${date}</div>
            </div>
            <link rel="stylesheet" href="${htmlEscape(json.stylesheet)}"></link>
            ${json.div}`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'JSFiddle',
      id: 'embed_jsfiddle',
      className: 'jsfiddle',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?(\/\/jsfiddle\.net\/(?:(?!embedded\b)[\w]+\/?)+)/i,
      makeNode: function(aNode, reResult, div) {
        let embedUrl = reResult[1].replace(/[^\/]$/, '$&/') + 'embedded/';
        return setContent(div, makeIframe(embedUrl, '100%', 500));
      }
    },
    {
      name: 'Codepen',
      id: 'embed_codepen',
      className: 'codepen singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/codepen\.io\/(\w+)\/(?:pen|full)\/(\w+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url] = reResult;
        let apiUrl = 'https://codepen.io/api/oembed?format=json&url=' + encodeURIComponent(url.replace('/full/', '/pen/'));

        const callback = response => {
          let json = JSON.parse(response.responseText);
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">"${json.title}" by <a href="${json.author_url}">${json.author_name}</a></div>
            </div>
            ${json.html}`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      }
    },
    {
      name: 'XKCD',
      id: 'embed_xkcd',
      className: 'xkcd singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/xkcd\.com\/(\d+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, xkcdId] = reResult;

        const callback = response => {
          let [, title] = /<div id="ctitle">([\s\S]+?)<\/div>/.exec(response.responseText) || [];
          let [, comic] = /<div id="comic">([\s\S]+?)<\/div>/.exec(response.responseText) || [];
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">${title}</div>
            </div>
            <a href="${url}" class="comic">${comic}</a>`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000).then(callback));
      },
      makeTitle: function(reResult) {
        return 'xkcd.com/' + reResult[1] + '/';
      },
      linkTextUpdate: function(aNode, reResult) {
        if (isDefaultLinkText(aNode)) {
          aNode.textContent = 'xkcd.com/' + reResult[1] + '/';
        }
      }
    },
    {
      name: 'lichess',
      id: 'embed_lichess',
      className: 'lichess singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/lichess\.org\/(study|)(?:\/?embed)?\/?((?=[a-z]*[A-Z0-9])[A-Za-z0-9\/]{8})/,
      makeNode: function(aNode, reResult, div) {
        let [, mode, rest] = reResult;
        let embedUrl = ['https://lichess.org', mode, 'embed', rest].filter(a => !!a).join('/');
        let iframe = makeIframe(embedUrl, '100%', '400px');
        iframe.onload = () => makeResizableToRatio(iframe, 397.0 / 600.0);
        return setContent(div, iframe);
      }
    },
    {
      name: 'Wikipedia',
      id: 'embed_wikipedia',
      className: 'wikipedia singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/([a-z]+)\.wikipedia\.org\/wiki\/([-A-Za-z0-9À-ž_+*&@#/%=~|$\(\),]+)$/,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, lang, entity] = reResult;
        let embedUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/summary/${entity}`;

        const callback = response => {
          let json = JSON.parse(response.responseText);
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title"><a href="${url}">${json.displaytitle}</a></div>
              <div class="lang">${json.lang}</div>
            </div>
            <div>
              ${json.thumbnail ? `<img src="${json.thumbnail.source}" style="float: right;">` : ''}
              <div class="extract">${json.extract_html}</div>
            </div>`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(embedUrl, 3000).then(callback));
      },
      makeTitle: function(reResult) {
        return 'Wikipedia: ' + decodeURIComponent(reResult[2].replace(/_/g, ' '));
      },
      linkTextUpdate: function(aNode, reResult) {
        if (isDefaultLinkText(aNode)) {
          aNode.textContent = 'Wikipedia: ' + decodeURIComponent(reResult[2].replace(/_/g, ' '));
        }
      }
    },
    {
      name: 'arXiv',
      id: 'embed_arxiv',
      className: 'arxiv singleColumn',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:\w+\.)?arxiv\.org\/(?:abs|pdf)\/(\d+\.\d+)(v\d+)?/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, arxivId, rev] = reResult;
        let absUrl = 'https://arxiv.org/abs/' + arxivId + (rev || '');
        let pdfUrl = 'https://arxiv.org/pdf/' + arxivId + (rev || '');

        const callback = response => {
          const metaRe = /<\s*meta\s+name\s*=\s*\"([^\"]+)\"\s+content\s*=\s*\"([^\"]*)\"\s*\/?>/gmi;
          let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText).map(m => ({ k: m[1].toLowerCase(), v: m[2] }));
          let title = matches.find(x => x.k == 'citation_title').v;

          let [, dateline] = /<div class="dateline">\s*([\s\S]+?)<\/div>/.exec(response.responseText) || [];
          let [, abstract] = /<blockquote class="abstract\b.*?">\s*<span class="descriptor">[\s\S]*?<\/span>\s*([\s\S]+?)<\/blockquote>/.exec(response.responseText) || [];
          let authors = getAllMatchesAndCaptureGroups(/<a href="(\/find.+?)">(.+?)<\/a>/gi, response.responseText).map(m => ({ url: m[1], name: m[2] }));
          let authorsStr = authors.map(a => `<a href="${a.url}">${a.name}</a>`).join(', ');

          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title"><a href="${absUrl}">${title}</a> (<a href="${pdfUrl}">pdf</a>)</div>
              <div class="date">${dateline}</div>
            </div>
            <div class="abstract">${abstract}</div>
            <div class="bottom">
              <div class="authors">${authorsStr}</div>
            </div>`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(absUrl, 3000).then(callback));
      },
      makeTitle: function(reResult) {
        return 'arXiv:' + reResult[1] + (reResult[2] || '');
      },
      linkTextUpdate: function(aNode, reResult) {
        if (isDefaultLinkText(aNode)) {
          aNode.textContent = 'arXiv:' + reResult[1] + (reResult[2] || '');
        }
      }
    },
    {
      name: 'Pixiv',
      id: 'embed_pixiv',
      className: 'pixiv',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/www\.pixiv\.net\/member_illust\.php\?((?:\w+=\w+&)*illust_id=(\d+)(?:&\w+=\w+)*)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, , illustId] = reResult;

        const predicates = [
          {
            msg: response => 'Private work',
            test: response => (response.status != 200) && response.responseText.includes('work private'),
            permanent: response => true
          },
          {
            msg: response => (response.statusText ? `${response.status} - ${response.statusText}` : `${response.status}`),
            test: response => response.status != 200,
            permanent: response => response.status != 503
          },
          {
            msg: response => 'Deleted work',
            test: response => response.responseText.includes('This work was deleted'),
            permanent: response => true
          }
        ];
        const callback = response => {
          let isMultipage = (url.includes('mode=manga') || response.responseText.includes('member_illust.php?mode=manga'));
          const metaRe = /<\s*meta\s+(?:property|name)\s*=\s*\"([^\"]+)\"\s+content\s*=\s*\"([^\"]*)\"\s*\/?>/gmi;
          let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText).map(m => ({ k: (m[1] || m[3]).toLowerCase(), v: m[2] }));
          let meta = {}; [].forEach.call(matches, m => { meta[m.k] = m.v; });
          let title = meta['twitter:title'] || meta['og:title'];
          let image = /* meta['twitter:image'] || meta['og:image'] || */ '//embed.pixiv.net/decorate.php?illust_id=' + illustId;
          let description = meta['twitter:description'] || meta['og:description'];

          let [, dateStr] = /<span\s+class=\"date\">([^<]+)<\/span>/.exec(response.responseText) || [];
          let [, authorId, authorName] = /<a\s+href="\/?member\.php\?id=(\d+)">\s*<img\s+src="[^"]+"\s+alt="[^"]+"\s+title="([^"]+)"\s\/?>/i.exec(response.responseText) || [];

          let dateDiv = (dateStr) ? `<div class="date">${dateStr}</div>` : '';
          let authorStr = (authorId) ? ` by <a href="//www.pixiv.net/member_illust.php?id=${authorId}">${authorName}</a>` : '';
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                ${isMultipage ? '(multipage) ' : ''}<a href="${url}">${title}</a>${authorStr}
              </div>
              ${dateDiv}
            </div>
            <a href="${aNode.href}"><img src="${image}"></a>
            ${description ? '<p>' + description + '</p>' : ''}`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url.replace(/mode=\w+/, 'mode=medium'), 3000, predicates).then(callback));
      }
    },
    {
      name: 'Gelbooru',
      id: 'embed_gelbooru',
      className: 'gelbooru booru',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?(gelbooru\.com|safebooru\.org)\/index\.php\?((?:\w+=\w+&)*id=(\d+)(?:&\w+=\w+)*)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, domain, , illustId] = reResult;
        let apiUrl = `https://${domain}/index.php?page=dapi&s=post&q=index&id=${illustId}&json=1`;

        const callback = response => {
          const json = JSON.parse(response.responseText);
          if (json.count === 0) {
            throw { reason: illustId + ' is not available', response: response, permanent: true, url: apiUrl };
          }

          const post = json.post[0];
          const saucenaoUrl = `https://img3.saucenao.com/booru/${post.md5[0]}/${post.md5[1]}/${post.md5}_2.jpg`;
          let createdDateStr = (new Date(post.created_at)).toLocaleDateString('ru-RU');
          const changedDateStr = (new Date(1000 * parseInt(post.change, 10))).toLocaleDateString('ru-RU');
          if (createdDateStr != changedDateStr) { createdDateStr += ` (${changedDateStr})`; }
          const ratingStr = (post.rating == 's') ? '' : ` (${post.rating})`;
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                <a href="${url}">${post.id}</a>${ratingStr}${post.has_notes ? ' (notes)' : ''}${post.has_comments ? ' (comments)' : ''}
              </div>
              <div class="date">${createdDateStr}</div>
            </div>
            <div class="bottom-right">
              <div>
                <a href="https://www.iqdb.org/?url=${post.preview_url}">IQDB</a>, <a href="https://saucenao.com/search.php?url=${post.preview_url}">SauceNAO</a>
              </div>
            </div>
            <a href="${aNode.href}">
              <img class="rating_${post.rating}" src="${post.preview_url}" onerror="this.onerror=null;this.src='${saucenaoUrl}';">
            </a>
            `;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      },
      makeTitle: function([, domain, , illustId]) { return `${domain} (${illustId})`; },
      linkTextUpdate: function(aNode, [, , , illustId]) { aNode.textContent += ` (${illustId})`; }
    },
    {
      name: 'Danbooru',
      id: 'embed_danbooru',
      className: 'danbooru booru',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(danbooru|safebooru)\.donmai\.us\/post(?:s|\/show)\/(\d+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, domain, id] = reResult;
        url = url.replace('http:', 'https:');
        let urls = (domain == 'safebooru')
          ? [`https://${domain}.donmai.us/posts/${id}.json`]
          : [`https://${domain}.donmai.us/posts/${id}.json`, `https://safebooru.donmai.us/posts/${id}.json`];

        const callback = response => {
          let [finalUrl, finalDomain, ] = thisType.re.exec(response.finalUrl);
          let json = JSON.parse(response.responseText);
          if (!json.preview_file_url) {
            div.innerHTML = `<span>Can't show <a href="${finalUrl}">${id}</a> (<a href="${url}">${json.rating}</a>)</span>`;
            return;
          }

          let finalPreviewUrl = `https://${finalDomain}.donmai.us${json.preview_file_url}`;
          let saucenaoUrl = `https://img3.saucenao.com/booru/${json.md5[0]}/${json.md5[1]}/${json.md5}_2.jpg`;
          let tagsStr = [json.tag_string_artist, json.tag_string_character, json.tag_string_copyright]
                          .filter(s => s != '')
                          .map(s => (s.count(' ') > 1) ? naiveEllipsisRight(s, 40) : `<a href="https://${finalDomain}.donmai.us/posts?tags=${encodeURIComponent(s)}">${s}</a>`)
                          .join('<br>');
          let notesStr = (json.last_noted_at) ? ' (notes)' : '';
          let commentsStr = (json.last_commented_at) ? ' (comments)' : '';
          let ratingStr = (json.rating == 's') ? '' : ` (<a href="${url}">${json.rating}</a>)`;
          let createdDateStr = (new Date(json.created_at)).toLocaleDateString('ru-RU');
          let updatedDateStr = (new Date(json.updated_at)).toLocaleDateString('ru-RU');
          if (createdDateStr != updatedDateStr) { createdDateStr += ` (${updatedDateStr})`; }
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title"><a href="${finalUrl}">${id}</a>${ratingStr}${notesStr}${commentsStr}</div>
              <div class="date">${createdDateStr}</div>
            </div>
            <div class="bottom-right">
              <div class="booru-tags">${tagsStr}</div>
              <div>
                <a href="https://www.iqdb.org/?url=${finalPreviewUrl}">IQDB</a>, <a href="https://saucenao.com/search.php?url=${finalPreviewUrl}">SauceNAO</a>
              </div>
            </div>
            <a href="${finalUrl}">
              <img class="rating_${json.rating}" src="${finalPreviewUrl}" onerror="this.onerror=null;this.src='${saucenaoUrl}';">
            </a>
            `;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrFirstResponse(urls, 3000).then(callback));
      },
      makeTitle: function([, domain, id]) { return `${domain} (${id})`; },
      linkTextUpdate: function(aNode, [, , id]) {
        aNode.href = aNode.href.replace('http:', 'https:');
        aNode.textContent += ` (${id})`;
      }
    },
    {
      name: 'Konachan',
      id: 'embed_konachan',
      className: 'konachan booru',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/konachan\.(com|net)\/post\/show\/(\d+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, domain, id] = reResult;
        url = url.replace('.com/', '.net/');
        let unsafeUrl = url.replace('.net/', '.com/');
        let apiUrls = [ `https://konachan.net/post.json?tags=id:${id}`, `https://konachan.com/post.json?tags=id:${id}` ];

        const callback = response => {
          let json = (JSON.parse(response.responseText))[0];
          if (!json || !json.preview_url) {
            div.innerHTML = `<span>Can't show <a href="${url}">${id}</a> (<a href="${unsafeUrl}">${json.rating}</a>)</span>'`;
            return;
          }

          let saucenaoUrl = `https://img3.saucenao.com/booru/${json.md5[0]}/${json.md5[1]}/${json.md5}_2.jpg`;
          let createdDateStr = (new Date(1000 * parseInt(json.created_at, 10))).toLocaleDateString('ru-RU');
          let ratingStr = (json.rating == 's') ? '' : ` (<a href="${unsafeUrl}">${json.rating}</a>)`;
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title"><a href="${url}">${id}</a>${ratingStr}</div>
              <div class="date">${createdDateStr}</div>
            </div>
            <div class="bottom-right">
              <div>
                <a href="https://www.iqdb.org/?url=${json.preview_url}">IQDB</a>, <a href="https://saucenao.com/search.php?url=${json.preview_url}">SauceNAO</a>
              </div>
            </div>
            <a href="${url}"><img class="rating_${json.rating}" src="${json.preview_url}" onerror="this.onerror=null;this.src='${saucenaoUrl}';"></a>
            `;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrFirstResponse(apiUrls, 3000).then(callback));
      },
      makeTitle: function([, , id]) { return `konachan (${id})`; },
      linkTextUpdate: function(aNode, [, , id]) { aNode.textContent += ` (${id})`; }
    },
    {
      name: 'yande.re',
      id: 'embed_yandere',
      className: 'yandere booru',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/yande\.re\/post\/show\/(\d+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, id] = reResult;
        let apiUrl = 'https://yande.re/post.json?tags=id:' + id;

        const callback = response => {
          let json = (JSON.parse(response.responseText))[0];
          if (!json || !json.preview_url) {
            div.innerHTML = `<span>Can't show <a href="${url}">${id}</a> (${json.rating})</span>`;
            return;
          }

          let ratingStr = (json.rating == 's') ? '' : ` (${json.rating})`;
          let notesStr = (json.last_noted_at && json.last_noted_at !== 0) ? ' (notes)' : '';
          let commentsStr = (json.last_commented_at && json.last_commented_at !== 0) ? ' (comments)' : '';
          let createdDateStr = (new Date(1000 * json.created_at)).toLocaleDateString('ru-RU');
          let updatedDateStr = (new Date(1000 * json.updated_at)).toLocaleDateString('ru-RU');
          if (createdDateStr != updatedDateStr && json.updated_at != 0) { createdDateStr += ` (${updatedDateStr})`; }
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title"><a href="${url}">${id}</a>${ratingStr}${notesStr}${commentsStr}</div>
              <div class="date">${createdDateStr}</div>
            </div>
            <div class="bottom-right">
              <div>
                <a href="https://www.iqdb.org/?url=${json.preview_url}">IQDB</a>, <a href="https://saucenao.com/search.php?url=${json.preview_url}">SauceNAO</a>
              </div>
            </div>
            <a href="${url}"><img class="rating_${json.rating}" src="${json.preview_url}"></a>`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      },
      makeTitle: function([, id]) { return `yande.re (${id})`; },
      linkTextUpdate: function(aNode, [, id]) { aNode.textContent += ` (${id})`; }
    },
    {
      name: 'anime-pictures.net',
      id: 'embed_anime_pictures_net',
      className: 'anime-pictures booru',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/anime-pictures\.net\/pictures\/view_post\/(\d+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, id] = reResult;

        const predicates = [
          {
            msg: response => 'Click to show ' + thisType.makeTitle(reResult),
            test: response => response.status == 503,
            permanent: response => false
          },
          {
            msg: response => (response.statusText ? `${response.status} - ${response.statusText}` : `${response.status}`),
            test: response => response.status != 200,
            permanent: response => true
          },
          {
            msg: response => 'Login required',
            test: response => response.responseText.includes('must be logged in'),
            permanent: response => true
          }
        ];
        const callback = response => {
          const metaRe = /<\s*meta\s+(?:(?:property|name)\s*=\s*\"([^\"]+)\"\s+)?content\s*=\s*\"([^\"]*)\"(?:\s+(?:property|name)\s*=\s*\"([^\"]+)\")?\s*>/gmi;
          let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText);
          let imageUrl = matches.find(m => (m[1] || m[3]) == 'og:image')[2];

          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                <a href="${url}">${id}</a>
              </div>
            </div>
            <div class="bottom-right">
              <a href="https://www.iqdb.org/?url=${imageUrl}">IQDB</a>
              <a href="https://saucenao.com/search.php?url=${imageUrl}">SauceNAO</a>
            </div>
            <a href="${aNode.href}"><img src="${imageUrl}"></a>`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000, predicates).then(callback));
      },
      makeTitle: function([, id]) { return `anime-pictures.net (${id})`; },
      linkTextUpdate: function(aNode, [, id]) { aNode.textContent += ` (${id})`; }
    },
    {
      name: 'derpibooru.org',
      id: 'embed_derpibooru',
      className: 'derpibooru booru',
      onByDefault: false,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/derpibooru\.org\/(?:images\/)?(\d+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, id] = reResult;
        let apiUrl = 'https://derpibooru.org/api/v1/json/images/' + id;

        const callback = response => {
          let json = JSON.parse(response.responseText);
          if (!json || !json.image) {
            div.innerHTML = `<span>Can't show <a href="${url}">${id}</a></span>`;
            console.log(response);
            return;
          }
          json = json.image;

          let createdDateStr = (new Date(json.created_at)).toLocaleDateString('ru-RU');
          const tagEncode = t => t.replace(/ /g, '+').replace(/:/g, '-colon-');
          let tagsStr = json.tags
                            .map(t => `<a href="https://derpibooru.org/tags/${tagEncode(t)}">${t}</a>`)
                            .join(', ');
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title"><a href="${url}">${json.description}</a></div>
              <div class="date">${createdDateStr}</div>
            </div>
            <a href="${url}"><img src="${json.representations.medium}"></a>
            <div class="booru-tags">${tagsStr}</div>
            <div class="bottom">
              <div class="source">
                <a href="${json.source_url}">Source</a>, uploaded by <a href="https://derpibooru.org/profiles/${json.uploader}">${json.uploader}</a>
              </div>
              <div class="right">
                <div class="faves">${svgIconHtml('star')}${json.faves}</div>
                <div class="votes">${svgIconHtml('heart')}${json.score} (${json.upvotes}↑ ${json.downvotes}↓)</div>
                <div class="replies">${svgIconHtml('comment')}${json.comment_count}</div>
            </div>
            </div>`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(apiUrl, 3000).then(callback));
      },
      makeTitle: function([, id]) { return `derpibooru (${id})`; },
      linkTextUpdate: function(aNode, [, id]) { aNode.textContent += ` (${id})`; }
    },
    {
      name: 'Яндекс.Фотки',
      id: 'embed_yandex_fotki',
      className: 'picture compact',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/img-fotki\.yandex\.ru\/get\/\d+\/[\w\.]+\/[\w]+$/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<a href="${aNode.href}"><img src="${aNode.href}"></a>`;
        return div;
      }
    },
    {
      name: 'pikabu',
      id: 'embed_pikabu',
      className: 'pikabu',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/pikabu\.ru\/story\/([a-z0-9_]+)/i,
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url, ] = reResult;

        const callback = response => {
          const metaRe = /<\s*meta\s+(?:(?:property|name)\s*=\s*[\"']([^\"']+)[\"']\s+)?content\s*=\s*\"([^\"]*)\"(?:\s+(?:property|name)\s*=\s*\"([^\"]+)\")?(?:\s*(?:\w+=\"[^\"]*\"))*\s*\/?>/gmi;
          let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText).map(m => ({ k: (m[1] || m[3]).toLowerCase(), v: m[2] }));
          let meta = {}; [].forEach.call(matches, m => { meta[m.k] = m.v; });
          let title = htmlDecode(meta['twitter:title'] || meta['og:title']);
          let description = htmlDecode(longest([meta['og:description'], meta['twitter:description'], '']));
          let image = meta['twitter:image:src'];
          let imageStr = (image) ? `<a href="${url}"><img src="${image}" /></a>` : '';
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                <a href="${url}">${title}</a>
              </div>
            </div>
            ${imageStr}
            <div class="desc">${description}</div>`;
        };

        return doFetchingEmbed(aNode, reResult, div, thisType, () => xhrGetAsync(url, 3000).then(callback));
      }
    },
    {
      name: 'Use meta for other links (whitelist)',
      id: 'embed_whitelisted_domains',
      className: 'other',
      onByDefault: true,
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?!juick\.com\b).*/i,
      match: function(aNode, reResult) {
        let domain = aNode.hostname.replace(/^(?:www|m)\./, '');
        let domainsWhitelist = GM_getValue('domains_whitelist', getDefaultDomainWhitelist().join('\n')).split(/\r?\n/);
        return domainsWhitelist.some(w => matchWildcard(domain, w));
      },
      makeNode: function(aNode, reResult, div) {
        let thisType = this;
        let [url] = reResult;
        let domain = aNode.hostname;

        const checkContentType = response => {
          const headRe = /^([\w-]+): (.+)$/gmi;
          let headerMatches = getAllMatchesAndCaptureGroups(headRe, response.responseHeaders);
          let [, , contentType] = headerMatches.find(([, k, ]) => (k.toLowerCase() == 'content-type'));
          if (contentType && contentType.match(/^text\/html\b/i)) {
            return;
          } else {
            throw { reason: 'not text/html' };
          }
        };

        const callback = response => {
          const metaRe = /<\s*meta\s+(?:(?:property|name)\s*=\s*[\"']([^\"']+)[\"']\s+)?content\s*=\s*\"([^\"]*)\"(?:\s+(?:property|name)\s*=\s*\"([^\"]+)\")?(?:\s*(?:\w+=\"[^\"]*\"))*\s*\/?>/gmi;
          const titleRe = /<title>([\s\S]+?)<\/title>/gmi;
          let [, basicTitle] = titleRe.exec(response.responseText) || [];
          let matches = getAllMatchesAndCaptureGroups(metaRe, response.responseText).map(m => ({ k: (m[1] || m[3]).toLowerCase(), v: m[2] }));
          let meta = {}; [].forEach.call(matches, m => { meta[m.k] = m.v; });
          let title = meta['twitter:title'] || meta['og:title'] || meta['title'] || basicTitle || meta['sailthru.title'];
          let image = meta['twitter:image'] || meta['twitter:image:src'] || meta['og:image'] || meta['sailthru.image.full'];
          let description = longest([meta['og:description'], meta['twitter:description'], meta['description'], meta['sailthru.description']]);
          let isEnoughContent = title && description && (title.length > 0) && (description.length > 0);
          if (!isEnoughContent) {
            throw { reason: 'not enough meta content to embed' };
          }
          let imageStr = (image) ? `<a href="${url}"><img src="${image}" /></a>` : '';
          description = htmlDecode(description).replace(/\n+/g,'<br/>');
          div.innerHTML = /*html*/`
            <div class="top">
              <div class="title">
                <a href="${url}">${title}</a>
              </div>
            </div>
            ${imageStr}
            <div class="desc">${description}</div>`;
          div.classList.add(domain.replace(/\./g, '_'));
        };

        const unembed = e => {
          if (e.reason) { console.log(`${e.reason} - ${url}`); }
          div.remove();
          aNode.className = '';
        };

        return doFetchingEmbed(aNode, reResult, div, thisType,
                               () => xhrGetAsync(url, 1000, undefined, 'HEAD')
                                  .then(checkContentType)
                                  .then(() => { return xhrGetAsync(url, 1500).then(callback); })
                                  .catch(e => unembed(e))
        );
      }
    }
  ];
}

function getDefaultDomainWhitelist() {
  return [
    'lenta.ru',
    'meduza.io',
    'rbc.ru',
    'tjournal.ru',
    '*.newsru.com',
    '*.itar-tass.com',
    'tass.ru',
    'rublacklist.net',
    'mk.ru',
    'gazeta.ru',
    'republic.ru',
    'bash.im',
    'ixbt.com',
    'techxplore.com',
    'medicalxpress.com',
    'phys.org',
    'techcrunch.com',
    'bbc.com',
    'nplus1.ru',
    'elementy.ru',
    'news.tut.by',
    'imdb.com',
    'mastodon.social',
    'mastodonsocial.ru'
  ];
}

function embedLink(aNode, linkTypes, container, alwaysCts, afterNode) {
  let anyEmbed = false;
  let linkId = (aNode.href.replace(/^https?:/i, '').replace(/\'/gi,''));
  let sameEmbed = container.querySelector(`*[data-linkid='${linkId}']`); // do not embed the same thing twice
  if (sameEmbed) {
    if (GM_getValue('enable_arrows', true)) { aNode.classList.add('arrow'); }
    setHighlightOnHover(aNode, sameEmbed);
    //setMoveIntoViewOnHover(aNode, aNode, newNode, 5, 30);
  } else {
    anyEmbed = [].some.call(linkTypes, function(linkType) {
      if (GM_getValue(linkType.id, linkType.onByDefault)) {
        let reResult = linkType.re.exec(aNode.href);
        if (reResult) {
          if (linkType.match && (linkType.match(aNode, reResult) === false)) { return false; }

          let newNode = makeNewNode(linkType, aNode, reResult, alwaysCts);
          if (!newNode) { return false; }

          newNode.setAttribute('data-linkid', linkId);
          if (afterNode) {
            insertAfter(newNode, afterNode);
          } else {
            container.appendChild(newNode);
          }

          aNode.classList.add('embedLink');
          if (GM_getValue('enable_arrows', true)) { aNode.classList.add('arrow'); }
          if (GM_getValue('enable_link_text_update', true) && linkType.linkTextUpdate) { linkType.linkTextUpdate(aNode, reResult); }

          setHighlightOnHover(aNode, newNode);
          //setMoveIntoViewOnHover(aNode, aNode, newNode, 5, 30);
          return true;
        }
      }
    });
  }
  return anyEmbed;
}

function embedLinks(aNodes, container, alwaysCts, afterNode) {
  let anyEmbed = false;
  let embeddableLinkTypes = getEmbeddableLinkTypes();
  Array.from(aNodes).forEach(aNode => {
    let isEmbedded = embedLink(aNode, embeddableLinkTypes, container, alwaysCts, afterNode);
    anyEmbed = anyEmbed || isEmbedded;
  });
  return anyEmbed;
}

function splitUsersAndTagsLists(str) {
  let items = str.split(/[\s,]+/);
  let users = items.filter(x => x.startsWith('@')).map(x => x.replace('@','').toLowerCase());
  let tags = items.filter(x => x.startsWith('*')).map(x => x.replace('*','').toLowerCase());
  return [users, tags];
}

function articleInfo(article) {
  let tagNodes = article.querySelectorAll('.msg-tags > *');
  let tags = Array.from(tagNodes).map(d => d.textContent.toLowerCase());
  return { userId: getPostUserName(article), tags: tags };
}

function isFilteredX(x, filteredUsers, filteredTags) {
  let {userId, tags} = articleInfo(x);
  return (filteredUsers && userId && filteredUsers.indexOf(userId.toLowerCase()) !== -1)
         || (intersect(tags, filteredTags).length > 0);
}

function embedLinksToX(x, beforeNodeSelector, allLinksSelector, ctsUsers, ctsTags) {
  let isCtsPost = isFilteredX(x, ctsUsers, ctsTags);
  let allLinks = x.querySelectorAll(allLinksSelector);

  let existingContainer = x.querySelector('div.embedContainer');
  if (existingContainer) {
    embedLinks(allLinks, existingContainer, isCtsPost);
  } else {
    let embedContainer = document.createElement('div');
    embedContainer.className = 'embedContainer';

    let anyEmbed = embedLinks(allLinks, embedContainer, isCtsPost);
    if (anyEmbed) {
      let beforeNode = x.querySelector(beforeNodeSelector);
      x.insertBefore(embedContainer, beforeNode);
    }
  }
}

function embedLinksToArticles() {
  let [ctsUsers, ctsTags] = splitUsersAndTagsLists(GM_getValue('cts_users_and_tags', ''));
  let beforeNodeSelector = 'nav.l';
  let allLinksSelector = '.msg-txt > a, pre a';
  setTimeout(function() {
    Array.from(document.querySelectorAll('#content article[data-mid]')).forEach(article => {
      embedLinksToX(article, beforeNodeSelector, allLinksSelector, ctsUsers, ctsTags);
    });
  }, 50);
}

function embedLinksToPost() {
  let [ctsUsers, ctsTags] = splitUsersAndTagsLists(GM_getValue('cts_users_and_tags', ''));
  let beforeNodeSelector = '.msg-txt + *';
  let allLinksSelector = '.msg-txt > a, pre a';
  setTimeout(function() {
    Array.from(document.querySelectorAll('#content .msg-cont')).forEach(msg => {
      embedLinksToX(msg, beforeNodeSelector, allLinksSelector, ctsUsers, ctsTags);
    });
  }, 50);
}

function filterArticles() {
  let [filteredUsers, filteredTags] = splitUsersAndTagsLists(GM_getValue('filtered_users_and_tags', ''));
  let keepHeader = GM_getValue('filtered_posts_keep_header', true);
  Array.from(document.querySelectorAll('#content article[data-mid]'))
       .filter(article => isFilteredX(article, filteredUsers, filteredTags))
       .forEach(article => {
         if (keepHeader) {
           article.classList.add('filtered');
           while (article.children.length > 1) { article.removeChild(article.lastChild); }
         } else {
           article.remove();
         }
       });
}

function filterPostComments() {
  let [filteredUsers, filteredTags] = splitUsersAndTagsLists(GM_getValue('filtered_users_and_tags', ''));
  let keepHeader = GM_getValue('filtered_posts_keep_header', true);
  Array.from(document.querySelectorAll('#content #replies .msg-cont')).forEach(reply => {
    let isFilteredComment = isFilteredX(reply, filteredUsers, filteredTags);
    if (isFilteredComment) {
      reply.classList.add('filteredComment');
      reply.querySelector('.msg-txt').remove();
      reply.querySelector('.embedContainer')?.remove();
      reply.querySelector('.msg-media')?.remove();
      reply.querySelector('.msg-comment-target').remove();
      let linksDiv = reply.querySelector('.msg-links');
      linksDiv.querySelector('.a-thread-comment').remove();
      linksDiv.innerHTML = linksDiv.innerHTML.replace(' · ', '');
      if (!keepHeader) {
        reply.classList.add('headless');
        reply.querySelector('.msg-header').remove();
      }
    }
  });
}

function setHighlightOnHover(hoverTarget, highlightable) {
  highlightable.classList.toggle('highlightable', true);
  hoverTarget.addEventListener('mouseenter', e => highlightable.classList.toggle('hoverHighlight', true), false);
  hoverTarget.addEventListener('mouseleave', e => highlightable.classList.toggle('hoverHighlight', false), false);
}

function setMoveIntoViewOnHover(hoverTarget, avoidTarget, movable, avoidMargin=0, threshold=0) {
  if (!movable) { return; }

  function checkFullyVisible(node, threshold=0) {
    let rect = node.getBoundingClientRect();
    let viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
    let above = rect.top + threshold < 0;
    let below = rect.bottom - threshold - viewHeight >= 0;
    return !above && !below;
  }

  function resetMovementArtifacts(node) {
    node.removeEventListener('transitionend', afterBackTransition, false);
    node.classList.toggle('moved', false);
    node.classList.toggle('hoverHighlight', false);
  }

  function moveNodeIntoView(node, avoidNode, avoidMargin=0, threshold=0) {
    resetMovementArtifacts(node);
    node.classList.toggle('hoverHighlight', true);
    let onscreen = checkFullyVisible(node, threshold);
    if (!onscreen) {
      let parentNodeRect = node.parentNode.getBoundingClientRect();
      let avoidNodeRect = avoidNode.getBoundingClientRect();
      let [w, h] = [node.offsetWidth, node.offsetHeight];
      let vtop = parentNodeRect.top;
      let atop = avoidNodeRect.top - avoidMargin;
      let ah = avoidNodeRect.height + 2*avoidMargin;
      let wh = window.innerHeight;
      let isAbove = (vtop < atop);
      let moveAmount = isAbove ? (0-vtop-h+atop) : (0-vtop+atop+ah);
      let availableSpace = isAbove ? (atop - avoidMargin) : (wh - atop - ah + avoidMargin);
      if ((Math.abs(moveAmount) > threshold) && (availableSpace > threshold*2)) {
        let s = getComputedStyle(node);
        node.classList.toggle('moved', true);
        node.style.marginTop = `${moveAmount}px`;
        node.parentNode
            .querySelector('.movable + .placeholder')
            .setAttribute('style', `width: ${w}px; height: ${h}px; margin: ${s.margin};`);
      }
    }
  }

  function afterBackTransition(event) { resetMovementArtifacts(event.target); }

  function moveNodeBack(node) {
    const eventType = 'transitionend';
    if (node.classList.contains('moved')) {
      let parentNodeRect = node.parentNode.getBoundingClientRect();
      let nodeRect = node.getBoundingClientRect();
      if (Math.abs(parentNodeRect.top - nodeRect.top) > 1) {
        node.addEventListener(eventType, afterBackTransition, false);
      } else {
        resetMovementArtifacts(node);
      }
      node.style.marginTop = '';
    } else {
      node.classList.toggle('hoverHighlight', false);
    }
  }

  hoverTarget.addEventListener('mouseenter', e => { moveNodeIntoView(movable, avoidTarget, avoidMargin, threshold); }, false);
  hoverTarget.addEventListener('mouseleave', e => { moveNodeBack(movable); }, false);
  movable.parentNode.classList.toggle('movableContainer', true);
  movable.classList.toggle('movable', true);
  if (!movable.parentNode.querySelector('.movable + .placeholder')) {
    let pldr = document.createElement('div');
    pldr.className = 'placeholder';
    insertAfter(pldr, movable);
  }
}

function bringCommentsIntoViewOnHover() {
  let replies = Array.from(document.querySelectorAll('#replies li'));
  let nodes = {};
  replies.forEach(r => { nodes[r.id] = r.querySelector('div.msg-cont'); });
  replies.forEach(r => {
    let replyToLink = Array.from(r.querySelectorAll('.msg-links a')).find(a => /\d+/.test(a.hash));
    if (replyToLink) {
      let rtid = replyToLink.hash.replace(/^#/, '');
      setMoveIntoViewOnHover(replyToLink, nodes[r.id], nodes[rtid], 5, 30);
    }
  });
}

function checkReply(allPostsSelector, ...replySelectors) {
  getMyUserNameAsync().then(uname => {
    Array.from(document.querySelectorAll(allPostsSelector))
         .filter(p => (getPostUserName(p) != uname) && (!replySelectors.some(s => p.querySelector(s))))
         .forEach(p => p.classList.add('readonly'));
  }).catch(err => console.info(err));
}

function checkReplyArticles() {
  checkReply('#content article[data-mid]', 'nav.l > a.a-comment');
}

function checkReplyPost() {
  checkReply('#content div.msg-cont', 'a.a-thread-comment', '.msg-comment');
}

function markReadonlyPost() {
  if (document.title.match(/\B\*readonly\b/)) {
    document.querySelector('#content .msg-cont .msg-tags')
            .insertAdjacentHTML('beforeend', '<a class="virtualTag" href="#readonly">readonly</a>');
  }
}

function makeSettingsCheckbox(caption, id, defaultState) {
  let label = document.createElement('label');
  let cb = document.createElement('input');
  cb.type = 'checkbox';
  cb.checked = GM_getValue(id, defaultState);
  cb.onclick = (e => GM_setValue(id, cb.checked));
  label.appendChild(cb);
  label.appendChild(document.createTextNode(caption));
  return label;
}

function makeSettingsTextbox(caption, id, defaultString, placeholder) {
  let label = document.createElement('label');
  let wrapper = document.createElement('div');
  wrapper.className = 'ta-wrapper';
  let textarea = document.createElement('textarea');
  textarea.className = id;
  textarea.placeholder = placeholder;
  textarea.title = placeholder;
  textarea.value = GM_getValue(id, defaultString);
  textarea.oninput = (e => GM_setValue(id, textarea.value));
  wrapper.appendChild(textarea);
  label.appendChild(document.createTextNode('' + caption + ': '));
  label.appendChild(wrapper);
  return label;
}

function showUserscriptSettings() {
  let h1 = document.createElement('h1');
  h1.textContent = 'Tweaks';

  let uiFieldset = document.createElement('fieldset');
  { // UI
    let uiLegend = document.createElement('legend');
    uiLegend.textContent = 'UI';
    uiFieldset.appendChild(uiLegend);

    let list1 = document.createElement('ul');
    userscriptFeatures
      .filter(item => !!item.name && !!item.id)
      .map(item => makeSettingsCheckbox(item.name, item.id, item.enabledByDefault))
      .map(cb => wrapIntoTag(wrapIntoTag(cb, 'p'), 'li'))
      .forEach(item => list1.appendChild(item));
    uiFieldset.appendChild(list1);
  }

  let embeddingFieldset = document.createElement('fieldset');
  { // Embedding
    let embeddingLegend = document.createElement('legend');
    embeddingLegend.textContent = 'Embedding';

    let embeddingTable = document.createElement('table');
    embeddingTable.style.width = '100%';
    getEmbeddableLinkTypes().forEach(linkType => {
      let row = document.createElement('tr');
      row.appendChild(wrapIntoTag(makeSettingsCheckbox(linkType.name, linkType.id, linkType.onByDefault), 'td'));
      row.appendChild(wrapIntoTag(makeSettingsCheckbox('Click to show', 'cts_' + linkType.id, linkType.ctsDefault), 'td'));
      embeddingTable.appendChild(row);
    });

    let domainsWhitelist = makeSettingsTextbox('Domains whitelist ("*" wildcard is supported)', 'domains_whitelist', getDefaultDomainWhitelist().join('\n'), 'One domain per line. "*" wildcard is supported');

    let moveIntoViewOnSamePageCheckbox = makeSettingsCheckbox('Ссылки на ту же страницу не встраивать, а показывать при наведении', 'enable_move_into_view_on_same_page', true);
    let updateLinkTextCheckbox = makeSettingsCheckbox('Обновлять текст ссылок, если возможно (например, "juick.com" на #123456/7)', 'enable_link_text_update', true);
    let ctsUsersAndTags = makeSettingsTextbox('Всегда использовать "Click to show" для этих юзеров и тегов в ленте', 'cts_users_and_tags', '', '@users and *tags separated with space or comma');
    ctsUsersAndTags.style = 'display: flex; flex-direction: column; align-items: stretch;';

    setContent(
      embeddingFieldset,
      embeddingLegend,
      embeddingTable,
      wrapIntoTag(domainsWhitelist, 'p'),
      document.createElement('hr'),
      wrapIntoTag(ctsUsersAndTags, 'p'),
      wrapIntoTag(updateLinkTextCheckbox, 'p'),
      wrapIntoTag(moveIntoViewOnSamePageCheckbox, 'p')
    );
  }

  let filteringFieldset = document.createElement('fieldset');
  { // Filtering
    let filteringLegend = document.createElement('legend');
    filteringLegend.textContent = 'Filtering';

    let filteringUsersAndTags = makeSettingsTextbox('Убирать посты этих юзеров или с этими тегами из общей ленты', 'filtered_users_and_tags', '', '@users and *tags separated with space or comma');
    filteringUsersAndTags.style = 'display: flex; flex-direction: column; align-items: stretch;';
    let keepHeadersCheckbox = makeSettingsCheckbox('Оставлять заголовки постов', 'filtered_posts_keep_header', true);
    let filterCommentsCheckbox = makeSettingsCheckbox('Также фильтровать комментарии этих юзеров', 'filter_comments_too', false);

    setContent(
      filteringFieldset,
      filteringLegend,
      wrapIntoTag(filteringUsersAndTags, 'p'),
      wrapIntoTag(keepHeadersCheckbox, 'p'),
      wrapIntoTag(filterCommentsCheckbox, 'p')
    );
  }

  let resetButton = document.createElement('button');
  { // Reset button
    resetButton.textContent = 'Reset userscript settings to default';
    resetButton.onclick = function(e){
      if (!confirm('Are you sure you want to reset Tweaks settings to default?')) { return; }
      GM_listValues().slice().forEach(key => GM_deleteValue(key));
      showUserscriptSettings();
      alert('Done!');
    };
  }

  let versionInfoFieldset = document.createElement('fieldset');
  { // Version info
    let versionInfoLegend = document.createElement('legend');
    versionInfoLegend.textContent = 'Version info';
    let ver1 = document.createElement('p');
    let ver2 = document.createElement('p');
    ver1.textContent = 'Userscript version: ' + GM_info.script.version;
    ver2.textContent = 'Greasemonkey (or your script runner) version: ' + GM_info.version;
    setContent(versionInfoFieldset, versionInfoLegend, ver1, ver2);
  }

  let support = document.createElement('p');
  support.innerHTML = 'Feedback and feature requests <a href="//juick.com/killy/?tag=userscript">here</a>.';

  Array.from(document.querySelectorAll('#content article')).forEach(ar => ar.style.display = 'none');
  let article = document.createElement('article');
  let other = document.querySelector('#content article');
  other.parentNode.insertBefore(article, other);
  setContent(article, h1, uiFieldset, embeddingFieldset, filteringFieldset, resetButton, versionInfoFieldset, support);
  article.className = 'tweaksSettings';
  window.scrollTo(0, 0);
}

function addTweaksSettingsButton() {
  let tabsList = document.querySelector('#pagetabs > div');
  let aNode = document.createElement('a');
  aNode.textContent = 'Tweaks';
  aNode.href = '#tweaks';
  aNode.onclick = (e => { e.preventDefault(); showUserscriptSettings(); });
  tabsList.appendChild(aNode);
}

function addTweaksSettingsFooterLink() {
  let footerLinks = document.querySelector('#footer-right');
  let aNode = document.createElement('a');
  aNode.textContent = 'Tweaks';
  aNode.href = '#tweaks';
  aNode.onclick = (e => { e.preventDefault(); showUserscriptSettings(); });
  footerLinks.insertBefore(aNode, footerLinks.firstChild);
}

function updateUserRecommendationStats(userId, pagesPerCall) {
  let article = document.createElement('article');
  let userCounters = {};
  let totalRecs = 0;

  function recUpdate(depth, oldestMid, oldestDate) {
    if (depth <= 0) { return; }

    let beforeStr = (oldestMid) ? ('&before=' + oldestMid) : '';
    let url = `//juick.com/${userId}/?show=recomm${beforeStr}`;
    GM_xmlhttpRequest({
      method: 'GET',
      url: setProto(url),
      onload: function(response) {
        if (response.status != 200) {
          console.log(`${userId}: failed with ${response.status}, ${response.statusText}`);
          return;
        }

        const articleRe = /<article[\s\S]+?<\/article>/gmi;
        let articles = response.responseText.match(articleRe);
        if (!articles) {
          console.log('no more articles in response');
          return;
        }

        totalRecs = totalRecs + articles.length;
        let hasMore = (articles.length > 15);
        let oldestArticle = articles[articles.length - 1];

        const midRe = /data-mid="(\d+)"/i;
        const dateRe = /datetime\=\"([^\"]+) ([^\"]+)\"/i;
        let [, oldestMid] = midRe.exec(oldestArticle);
        let [, oldestDatePart, oldestTimePart] = dateRe.exec(oldestArticle);
        oldestDate = new Date(`${oldestDatePart}T${oldestTimePart}`);

        const userRe = /<span>([-\w]+)<\/span>/i;
        const userAvatarRe = /<img src="\/i\/a\/(\d+)-[0-9a-fA-F]+\.png" alt="[^\"]+"\/?>/i;
        let authors = articles.map(article => {
          let postAuthorId = (userRe.exec(article))[1];
          let postAuthorAvatar = (userAvatarRe.exec(article) || {0:''})[0];
          return {id: postAuthorId, avatar: postAuthorAvatar};
        });
        for (let i in authors) {
          let id = authors[i].id;
          let avatar = authors[i].avatar;
          if (id in userCounters) {
            userCounters[id].recs = userCounters[id].recs + 1;
          } else {
            userCounters[id] = {id: id, avatar: avatar, recs: 1};
          }
        }

        let sortedUsers = Object.values(userCounters).sort((a, b) => b.recs - a.recs);

        removeAllFrom(article);

        if (hasMore && (depth == 1)) {
          let moreButton = document.createElement('button');
          moreButton.style = 'float: right;';
          moreButton.textContent = 'Check older recommendations';
          moreButton.onclick = (e => recUpdate(pagesPerCall, oldestMid, oldestDate));
          article.appendChild(moreButton);
        }

        let datePNode = document.createElement('p');
        datePNode.textContent = `${totalRecs} recommendations since ${oldestDate.toLocaleDateString('ru-RU')}`;
        article.appendChild(datePNode);

        let avgPNode = document.createElement('p');
        let now = new Date();
        let days = ((now - oldestDate) / 1000 / 60 / 60 / 24);
        let avg = totalRecs / days;
        avgPNode.textContent = avg > 1.0
          ? '' + avg.toFixed(3) + ' recommendations per day'
          : 'one recommendation in ' + (1 / avg).toFixed(2) + ' days';
        article.appendChild(avgPNode);

        let userStrings = sortedUsers.map(x => `<li><a href="/${x.id}/">${x.avatar}${x.id}</a> / ${x.recs}</li>`);
        let ulNode = document.createElement('ul');
        ulNode.className = 'recUsers';
        ulNode.innerHTML = userStrings.join('');
        article.appendChild(ulNode);

        if (hasMore) {
          setTimeout(() => recUpdate(depth - 1, oldestMid, oldestDate), 100);
        } else {
          console.log('no more recommendations');
        }
      }
    });

  } // recUpdate

  let contentBlock = document.querySelector('section#content');
  setContent(contentBlock, article);
  recUpdate(pagesPerCall, undefined, undefined);
}

function addIRecommendLink() {
  let userId = getColumnUserName();
  let asideColumn = document.querySelector('aside#column');
  let ustatsList = asideColumn.querySelector('#ustats > ul');
  let li2 = ustatsList.querySelector('li:nth-child(2)');
  let liNode = document.createElement('li');
  let aNode = document.createElement('a');
  aNode.textContent = 'Я рекомендую';
  aNode.href = '#irecommend';
  aNode.onclick = (e => { e.preventDefault(); updateUserRecommendationStats(userId, 3); });
  liNode.appendChild(aNode);
  insertAfter(liNode, li2);
}

function addMentionsLink() {
  let userId = getColumnUserName();
  let asideColumn = document.querySelector('aside#column');
  let ustatsList = asideColumn.querySelector('#ustats > ul');
  let li2 = ustatsList.querySelector('li:nth-child(2)');
  let liNode = document.createElement('li');
  let aNode = document.createElement('a');
  aNode.textContent = 'Упоминания';
  aNode.href = '/?search=%40' + userId;
  liNode.appendChild(aNode);
  insertAfter(liNode, li2);
}

function makeElementExpandable(element) {
  let aNode = document.createElement('a');
  aNode.className = 'expandLink';
  aNode.innerHTML = '<span>Expand</span>';
  aNode.href = '#expand';
  aNode.onclick = (e => {
    e.preventDefault();
    element.classList.remove('expandable');
    element.removeChild(aNode);
  });
  element.appendChild(aNode);
  element.classList.add('expandable');
}

function limitArticlesHeight () {
  let maxHeight = window.innerHeight * 0.7;
  Array.from(document.querySelectorAll('#content article[data-mid] > .msg-txt')).forEach(p => {
    if (p.offsetHeight > maxHeight) {
      makeElementExpandable(p);
    }
  });
}

function addStyle() {
  let article = document.querySelector('#content article') || document.querySelector('#content .msg-cont');
  let [br, bg, bb] = parseRgbColor(getComputedStyle(document.documentElement).backgroundColor, [255,255,255]);
  let [tr, tg, tb] = parseRgbColor(getComputedStyle(document.body).color, [34,34,34]);
  let [ar, ag, ab] = (article) ? parseRgbColor(getComputedStyle(article).backgroundColor, [br, bg, bb]) : [br, bg, bb];
  const rgba = (r,g,b,a) => `rgba(${r},${g},${b},${a})`;
  let bg10 = rgba(br, bg, bb, 1.0);
  let abg10 = rgba(ar, ag, ab, 1.0);
  let color10 = rgba(tr, tg, tb, 1.0);
  let color07 = rgba(tr, tg, tb, 0.7);
  let color03 = rgba(tr, tg, tb, 0.3);
  let color02 = rgba(tr, tg, tb, 0.2);

  if (GM_getValue('enable_tags_min_width', true)) {
    GM_addStyle('.tagsContainer a { min-width: 25px; display: inline-block; text-align: center; }');
  }
  GM_addStyle(/*css*/`
    .embedContainer { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; padding: 0; margin: 30px -3px 15px -3px; }
    .embedContainer > * { box-sizing: border-box; flex-grow: 1; margin: 3px; min-width: 49%; }
    .embedContainer > .compact { flex-grow: 0; }
    .embedContainer > .singleColumn { min-width: 90%; }
    .embedContainer > .codePost .desc { font-family: monospace; white-space: pre-wrap; font-size: 9pt; line-height: 120%; }
    .embedContainer .picture img { display: block; }
    .embedContainer img,
    .embedContainer video { max-width: 100%; max-height: 80vh; }
    .embedContainer audio { width: 100%; }
    .embedContainer iframe { overflow:hidden; resize: vertical; display: block; }
    .embedContainer > .embed { width: 100%; border: 1px solid ${color02}; padding: 8px; display: flex; flex-direction: column; }
    .embedContainer > .embed.loading,
    .embedContainer > .embed.failed { text-align: center; color: ${color07}; padding: 0; }
    .embedContainer > .embed.failed { cursor: pointer; }
    .embedContainer .embed .cts { margin: 0; }
    .embed .top,
    .embed .bottom { display: flex; flex-shrink: 0; justify-content: space-between; }
    .embed .top { margin-bottom: 8px; }
    .embed .lang,
    .embed .date,
    .embed .date > a,
    .embed .likes > a,
    .embed .replies > a,
    .embed .title { color: ${color07}; }
    .embed .date { font-size: small; text-align: right; }
    .embed .likes,
    .embed .faves,
    .embed .votes,
    .embed .replies { font-size: small; white-space:nowrap; margin-left: 12px; }
    .embed .likes .icon,
    .embed .faves .icon,
    .embed .votes .icon,
    .embed .replies .icon { width: 20px; height: 20px; }
    .embed .desc { margin-bottom: 8px; max-height: 55vh; overflow-y: auto; }
    .twi.embed > .cts > .placeholder { display: inline-block; }
    .embedContainer > .embed.twi .cts > .placeholder { border: 0; }
    .embedContainer > .bandcamp:not(.loading):not(.cts) { max-width: 480px; }
    .juickEmbed > .top > .top-right { display: flex; flex-direction: column; flex: 1; }
    .juickEmbed > .top > .top-right > .top-right-1st { display: flex; flex-direction: row; justify-content: space-between; }
    .juickEmbed > .bottom > .right { margin-top: 5px; display: flex; flex: 0; }
    .gistEmbed .gist-file .gist-data .blob-wrapper,
    .gistEmbed .gist-file .gist-data article { max-height: 70vh; overflow-y: auto; }
    .gistEmbed.embed.loaded { border-width: 0px; padding: 0; }
    .wordpress .desc { max-height: 70vh; overflow-y: auto; line-height: 160%; }
    .xkcd .comic { display: block; margin: 0 auto; }
    .arxiv .top { flex-direction: column; }
    .arxiv .date { text-align: left; }
    .arxiv .bottom { margin-top: 8px; }
    .tumblr { max-height: 86vh; overflow-y: auto; }
    .tumblr.loading iframe { visibility: hidden; height: 0px; }
    .reddit { max-height: 75vh; overflow-y: auto; }
    .reddit iframe { resize: none; }
    .reddit.loading > blockquote,
    .reddit.loading > div { display: none; }
    .fbEmbed:not(.fallback) iframe { resize: none; }
    .fbEmbed.loading > div { visibility: hidden; height: 0px; }
    .imgur iframe { border-width: 0px; }
    .imgur.loading iframe { visibility: hidden; height: 0px; }
    .embedContainer > .gelbooru.embed,
    .embedContainer > .danbooru.embed,
    .embedContainer > .konachan.embed,
    .embedContainer > .yandere.embed { width: 49%; }
    .embedContainer > .booru.embed { position: relative; }
    .danbooru.embed.loaded { min-height: 130px; }
    .danbooru.embed .booru-tags { display: none; }
    .danbooru.embed:hover .booru-tags { display: block; }
    .booru.embed .bottom-right { position:absolute; bottom: 8px; right: 8px; font-size: small; text-align: right; color: ${color07}; display: flex; flex-direction: column; }
    .derpibooru.embed > .bottom { margin-top: 5px; }
    .derpibooru.embed > .bottom > .right { display: flex; flex: 0; }
    article.nsfw .embedContainer img,
    article.nsfw .embedContainer iframe,
    .embed .rating_e,
    .embed img.nsfw { opacity: 0.1; }
    article.nsfw .embedContainer img:hover,
    article.nsfw .embedContainer iframe:hover,
    article.nsfw .embedContainer .msg-avatar img,
    .embed .rating_e:hover,
    .embed img.nsfw:hover { opacity: 1.0; }
    .embed.notEmbed { display: none; }
    .embedLink.arrow:not(.notEmbed):after { content: '\\00a0↓' } /* &nbsp; */
    .tweaksSettings * { box-sizing: border-box; }
    .tweaksSettings table { border-collapse: collapse; }
    .tweaksSettings tr { border-bottom: 1px solid transparent; }
    .tweaksSettings tr:hover { background: rgba(127,127,127,.1) }
    .tweaksSettings td > * { display: block; width: 100%; height: 100%; }
    .tweaksSettings > button { margin-top: 25px; }
    .tweaksSettings .ta-wrapper { width: 100%; height: 100%; }
    .tweaksSettings .ta-wrapper > textarea { width: 100%; height: 100%; }
    .tweaksSettings textarea.domains_whitelist { min-height: 72pt; }
    .embedContainer > .cts { width: 100%; }
    .embedContainer .cts > .placeholder { border: 1px dotted ${color03}; color: ${color07}; text-align: center; cursor: pointer; word-wrap: break-word; }
    .cts > .placeholder { position: relative; }
    .cts > .placeholder > .icon { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; color: ${bg10}; -webkit-filter: drop-shadow( 0 0 10px ${color10} ); filter: drop-shadow( 0 0 10px ${color10} ); }
    .embed .cts .icon { display: flex; align-items: center; justify-content: center; }
    .embed .cts .icon > svg { max-width: 100px; max-height: 100px; }
    .filtered header { overflow: hidden; }
    .filtered .msg-avatar { margin-bottom: 0px; }
    .filteredComment.headless .msg-links { margin: 0px; }
    article.readonly > .msg-txt,
    div.readonly > .msg-txt { opacity: 0.55; }
    .movable { transition: all 0.2s ease-out 0.2s; transition-property: margin, margin-top; }
    .movable.moved { position: absolute; z-index: 10; }
    .movable.hoverHighlight,
    .highlightable.hoverHighlight { outline: 1px solid ${color10} !important; }
    .movableContainer { position: relative; }
    .movableContainer > .placeholder { display: none; }
    .movableContainer .moved+.placeholder { display: block; }
    .recUsers img { height: 32px; margin: 2px; margin-right: 6px; vertical-align: middle; width: 32px; }
    .users.sorted > span { width: 300px; }
    a.virtualTag { border: 1px dotted ${color07}; border-radius: 15px; }
    .expandable { max-height: 50vh; overflow-y: hidden; position: relative; }
    .expandable:before { content:''; pointer-events:none; position:absolute; left:0; top:0; width:100%; height:100%; background:linear-gradient(to top, ${abg10} 15px, transparent 120px); }
    .expandable > a.expandLink { display: block; position:absolute; width: 100%; bottom: 2px; text-align: center; font-size: 10pt; color: ${color07}; }
    .expandable > a.expandLink > span { border: 1px dotted ${color07}; border-radius: 15px; padding: 0 15px 2px; background: ${abg10}; }

    #oldNewMessage { background: ${abg10}; margin-bottom: 20px; padding: 0; display: flex; flex-direction: column; }
    #oldNewMessage * { box-sizing: border-box; }
    #oldNewMessage > textarea { resize: vertical; padding: 12px 16px; }
    #oldNewMessage.active { padding: 8px; }
    #oldNewMessage #bottomBlock { display: flex; flex-direction: row; }
    #oldNewMessage:not(.active) #bottomBlock,
    #oldNewMessage:not(.active) #charCounterBlock,
    #oldNewMessage:not(.active) .tagsContainer { display: none; }
    #oldNewMessage #bottomLeftBlock { flex-grow: 1; display: flex; flex-direction: column; max-width: 100%; }
    #oldNewMessage .tags,
    #oldNewMessage .tagsContainer,
    #oldNewMessage .subm,
    #oldNewMessage #charCounterBlock,
    #oldNewMessage #imgUploadBlock { margin-top: 6px; }
    #oldNewMessage.active textarea,
    #oldNewMessage .txt { padding: 2px 4px; }
    #oldNewMessage textarea,
    #oldNewMessage .imgLink,
    #oldNewMessage .tags { border: 1px solid #ccc; }
    #oldNewMessage .subm,
    #oldNewMessage .btn_like { background: #eeeee5; border: 1px solid #ccc; color: black; padding: 2px 4px; width: 150px; cursor: pointer; text-align: center; }
    #oldNewMessage .subm[disabled] { color: #999; }
    #imgUploadBlock > * { display: inline-block; }
    #imgUploadBlock .info { width: 25px; height: 25px; vertical-align: text-bottom; }
    #imgUploadBlock #image_upload { visibility: hidden; width: 0; position: absolute; }
    #imgUploadBlock .imgLink { width: 150px; }
    #oldNewMessage:not(.withImage) #imagePreview { display: none; }
    #oldNewMessage #imagePreview { position: relative; margin: 6px 0 0 6px; }
    #oldNewMessage #imagePreview img { display: block; max-height: 120px; max-width: 150px; }
    #clear_button { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
    #clear_button > svg { position: absolute; right: 0%; top: 0%; width: 20%; height: 20%; }
    #clear_button:hover { background: rgba(255, 255, 255, 0.25); }
    .flexSpacer { flex-grow: 1; }
    #charCounterBlock > div { height: 100; }
    #charCounterBlock:not(.invalid) > div { background: #999; height: 1px; }
    #charCounterBlock.invalid { text-align: right; }
    #charCounterBlock.invalid > div { color: #c00; }
    ul#replies li .clickPopup,
    ul#replies li .hoverPopup { display: inline-block; float: right; }
    .clickPopup,
    .hoverPopup { position: relative; }
    .clickPopup .clickContainer,
    .hoverPopup .hoverContainer { visibility: hidden; position: absolute; z-index: 1; bottom: 100%; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; background: #fff; box-shadow: 0 0 3px rgba(0,0,0,.16); }
    .clickPopup .clickContainer > *,
    .hoverPopup .hoverContainer > * { margin: 2px 4px; }
    .clickPopup.expanded .clickContainer,
    .hoverPopup.expanded .hoverContainer,
    .hoverPopup:hover .hoverContainer { visibility: visible; }
    .clickPopup,
    .hoverPopup { margin-left: 15px; }
    .msgthread .hoverPopup { margin-left: 0; margin-right: 15px; color: #88958d; margin-top: 12px; }
    .clickPopup:not(.expanded) { cursor: pointer; }
    @keyframes highlight { 0% { outline-color: rgba(127, 127, 127 , 1.0); } 100% { outline-color: rgba(127, 127, 127 , 0.0); } }
    .blinkOnce { outline-width: 1px; outline-style: solid; animation: highlight 1s; }
    .confirmationItem { color: red; border: 1px solid red; padding: 2px 7px; }
    .confirmationItem:hover { background: #ffeeee; }
    `);
  if (GM_getValue('unset_code_style', false)) {
    GM_addStyle(`
      pre { background: unset; color: unset; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; font-size: 9pt; line-height: 120%; }
      `);
  }
}

// #endregion