Fixdit for Reddit Redesign

UX enhancements for Reddit's 2018 redesign: filters, collapse child comments, subreddit info...

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Fixdit for Reddit Redesign
// @namespace    http://tampermonkey.net/
// @version      0.7.4.2
// @description  UX enhancements for Reddit's 2018 redesign: filters, collapse child comments, subreddit info...
// @author       scriptpost (u/postpics)
// @match        https://www.reddit.com/*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.slim.min.js
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// @grant        GM_openInTab
// @noframes
// ==/UserScript==
(function ($, undefined) {
  $(function () {
    const GetElById = document.getElementById.bind(document);
    const GetElsByClass = document.getElementsByClassName.bind(document);
    const GetElsByName = document.getElementsByName.bind(document);
    const GetEl = document.querySelector.bind(document);
    const GetEls = document.querySelectorAll.bind(document);
    const GetStyle = (e, p, v) => window.getComputedStyle.bind(window, [e, p]).getPropertyValue(v);

    if (!GetElById('SHORTCUT_FOCUSABLE_DIV')) return; // probably using old site.

    const _stylesheet = String.raw`<style type="text/css" id="fixdit">#fixd_settings em,.fixd_filter_msg em{font-style:italic}.fixd_filter_msg button:hover,button.fixd_collapse:hover,button.fixd_collapse_all:hover{text-decoration:underline}#fixd_launch{box-sizing:border-box;position:fixed;top:2px;right:2px;width:24px;height:24px;z-index:100;text-align:center;border-radius:50%;border:2px solid hsla(70,0%,100%,.5);border-left-color:hsla(70,0%,0%,1);border-right-color:hsla(70,0%,0%,1);background:0 0;cursor:pointer;-moz-user-select:none;user-select:none}#fixd_launch.fixd_active{border-color:hsla(70,0%,100%,.5);border-top-color:hsla(70,0%,0%,1);border-bottom-color:hsla(70,0%,0%,1)}#fixd_launch:not(.fixd_active):hover:before{content:"Fixdit settings...";display:inline-block;padding:6px 12px;font-size:80%;color:#fff;position:absolute;right:calc(100% + 12px);white-space:nowrap;background:hsla(0,0%,0%,.8);border-radius:2px;pointer-events:none}#fixd_settings{position:fixed;top:0;right:0;opacity:0;z-index:101;padding:24px 12px;background:hsla(180,2%,90%,.95);border-radius:3px;box-shadow:0 5px 10px hsla(0,0%,0%,.25),0 0 3px hsla(0,0%,0%,.25);font-size:80%;width:300px;visibility:hidden;overflow:hidden;transition:visibility .2s,height .2s,bottom .2s,left .2s,top .2s,right .2s,opacity .2s}#fixd_settings.fixd_active{visibility:visible;opacity:1;top:5px;right:24px}.fixd_list_area+div,.fixd_static_header .fixd_overlay_head,.fixd_static_header .fixd_overlay_head+div{top:0}#fixd_settings.fixd_expanded{bottom:5px}body.fixd_debug #fixd_settings:after{display:block;margin-top:4px;content:"Version " attr(data-version);text-align:right;font-size:12px;color:#7f7f7f}.fixd_description{line-height:1.5;margin:-8px -12px 0;padding:8px}#fixd_settings h2,#fixd_settings h3{display:inline-block;margin-bottom:10px;vertical-align:middle}#fixd_settings strong{font-weight:700}#fixd_settings h1{font-size:140%;font-weight:400;margin:0 0 .5em}#fixd_settings h2{font-size:120%;font-weight:400}#fixd_settings h3{font-size:100%;font-weight:400}#fixd_settings h3 span{display:block;margin-top:4px;font-size:120%}#fixd_settings .fixd_option,#fixd_settings .fixd_option_btn,#fixd_settings .fixd_option_select,#fixd_settings .fixd_switch{display:block;margin:1px -12px 0;cursor:default;-moz-user-select:none;user-select:none;background:#f3f0f0}#fixd_settings .fixd_option_select input,#fixd_settings .fixd_switch input{vertical-align:middle}#fixd_settings .fixd_option_btn{margin:1px -12px;cursor:default}#fixd_settings .fixd_option_btn:after{content:" >";color:#999;font-weight:700;float:right}#fixd_settings .fixd_option_btn:hover:after{color:#000}#fixd_settings .fixd_option_btn:hover,#fixd_settings .fixd_setting:hover{outline:hsla(0,0%,0%,.3) solid 1px}#fixd_settings .fixd_option_btn,#fixd_settings .fixd_option_select,#fixd_settings .fixd_switch{padding:10px 16px}#fixd_settings .fixd_enabled{background:#fff}#fixd_settings .fixd_switch:not(.fixd_option){font-size:120%;margin-bottom:8px;background:#f3f0f0}#fixd_settings .fixd_switch.fixd_enabled:not(.fixd_option){color:#000;background:#fff}#fixd_settings .fixd_option span{padding-left:.3em}#fixd_dialog{position:absolute;top:0;left:0;right:0;bottom:0;padding:24px 12px 8px;border-radius:4px;background:#e4e6e6;display:flex;flex-direction:column}#fixd_dialog textarea{box-sizing:border-box;min-width:100%;max-width:100%;min-height:50px;max-height:100%;border:1px solid #fff;flex:1}.fixd_settings_buttons{text-align:right;margin:6px 0}.fixd_btn_back,.fixd_btn_save{text-transform:uppercase;border:1px solid transparent;border-radius:2px;vertical-align:middle}.fixd_btn_back{font-weight:700;color:transparent;margin-right:12px;padding:0;overflow:hidden;width:30px;height:30px;line-height:30px;margin-bottom:10px;border:1px solid #ccc;background:0 0}.fixd_btn_back:hover{border:1px solid #666}.fixd_btn_back:before{color:#000;content:"< "}.fixd_btn_save{color:#fff;padding:8px 24px;background:#0076b2;cursor:pointer}.fixd_btn_save:hover{background-color:#0087cc}button.fixd_collapse,button.fixd_collapse_all{cursor:pointer;color:inherit;display:inline-block;background:0 0;outline:0;font-size:12px;font-weight:700}a+button.fixd_collapse{margin-left:10px}button.fixd_collapse_all{margin-left:20px;color:#a6a4a4;font-size:12px;font-weight:700;text-transform:uppercase}button.fixd_collapse:before,button.fixd_collapse_all:before{content:"Hide "}button.fixd_collapse:after,button.fixd_collapse_all:after{content:" <<"}button.fixd_collapse.fixd_active:before,button.fixd_collapse_all.fixd_active:before{content:"Show "}button.fixd_collapse.fixd_active:after,button.fixd_collapse_all.fixd_active:after{content:" >"}.fixd_popup{box-sizing:border-box;position:absolute;z-index:100;padding:12px;font-size:12px;border-radius:4px;border-top:4px solid #c1cfd6;color:#1c1c1c;background-color:#fff;box-shadow:rgba(0,0,0,.2) 0 1px 3px;overflow:hidden}#fixd_popup_subreddit.fixd_subscriber{border-top-color:#0076d1}.fixd_popup>div{float:left}.fixd_popup>div:nth-of-type(2){width:200px;margin-left:12px;padding-left:12px;border-left:1px solid #edeff1}.fixd_popup .fixd_popup_subs span,.fixd_popup h2{display:block;font-size:16px;font-weight:500;line-height:20px}.fixd_filtered .Comment,.fixd_no_blank .icon-outboundLink,.fixd_popup:not(.fixd_filterable) .fixd_popup_filter,.fixd_unfiltered .fixd_filter_msg{display:none}.fixd_popup .fixd_popup_created,.fixd_popup .fixd_popup_subs{font-weight:500}.fixd_popup .fixd_popup_subs{margin-top:12px}.fixd_popup h2:before{content:"r/"}.fixd_popup .fixd_popup_subtitle{margin-bottom:.5em;color:#7f7f7f}.fixd_popup .fixd_popup_desc{color:#7f7f7f;line-height:1.2}.fixd_popup .fixd_popup_desc,.fixd_popup .fixd_popup_title{margin:0 0 .5em}.fixd_popup_filter{font-size:12px;text-transform:uppercase;padding:8px 12px;margin-top:12px;border-radius:2px;color:#fff;border:1px solid transparent;background:#0076d1;cursor:pointer}.fixd_popup_filter.fixd_active{color:#0076d1;border:1px solid;background:#fff}.fixd_popup_filter.fixd_active:before{content:"un"}.fixd_tooltip{pointer-events:none}body:not(.fixd_debug) .fixd_hidden{visibility:hidden;position:absolute}.fixd_debug .fixd_hidden{opacity:.6}.fixd_debug .fixd_filtered{outline:#dc143c solid 1px}.fixd_debug .fixd_unfiltered{outline:green solid 1px}.fixd_filter_msg{font-size:12px;color:#878a8c}.fixd_filter_msg button{content:"Show comment";color:inherit;background:0 0;cursor:pointer;margin:12px 0 0 12px}.fixd_override_vote_icon>div{color:inherit!important}button.fixd_no_icon[data-click-id=upvote],button.fixd_no_icon[data-click-id=downvote]{background-image:none!important}button.fixd_no_icon[data-click-id=upvote][aria-pressed=true]{color:#f40!important}button.fixd_no_icon[data-click-id=downvote][aria-pressed=true]{color:#7091ff!important}button.fixd_no_icon[data-click-id=upvote]:hover{color:#cc3600}button.fixd_no_icon[data-click-id=downvote]:hover{color:#5b75cc}button.fixd_no_icon[data-click-id=upvote]:before,button.fixd_no_icon[data-click-id=downvote]:before{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:redesignFont}button.fixd_no_icon[data-click-id=upvote]:before{content:"\F130"}button.fixd_no_icon[data-click-id=downvote]:before{content:"\F109"}body.fixd_reduce_comment_spacing .Comment.top-level{margin-top:0}.fixd_debug .fixd_no_blank{outline:#00f solid 1px}body.fixd_visited_links .Comment p a:visited,body.fixd_visited_links .Comment span>a:visited,body.fixd_visited_links .Post p a:visited,body.fixd_visited_links .Post span>a:visited{color:purple}.fixd_debug[data-fixd-view=compact] .fixd_list_area .Post>div>div:nth-of-type(2)>div>div:first-child{background-color:#c1e0fe}.fixd_expando_hitbox[data-fixd-view=compact] .fixd_list_area .Post>div>div:nth-of-type(2)>div>div:first-child{margin:0 8px}.fixd_expando_hitbox[data-fixd-view=compact] .fixd_list_area .Post>div>div:nth-of-type(2)>div>div:first-child button{margin:0}.fixd_debug #lightbox{margin:0 200px;width:auto}.fixd_debug #overlayFixedContent+div+div{background-color:rgba(0,0,0,.5)}.fixd_debug .fixd_observed:before{content:"o";position:absolute;z-index:100;padding:1px 3px;color:#fff;font-size:12px;border:2px solid hsla(0,0%,100%,.2);background:hsla(0,0%,0%,.5);pointer-events:none}.fixd_static_header header{position:absolute;z-index:40}</style>`;
    document.body.insertAdjacentHTML('beforeend', _stylesheet);

    // TODO: (current issues)
    // Add observer to hidden comments.
    // Observer for switching from comment permalink to all comments.
    // Use typescript.

    // CHANGELOG: (latest)
    // Apply 'static header' in overlay window.

    // Utils:
    const UT = {
      format_date: {
        age(date) {
          date = new Date(date).valueOf();
          const difference = new Date(new Date().valueOf() - date);
          let y = parseInt(difference.toISOString().slice(0, 4), 10) - 1970;
          let m = difference.getMonth() + 0;
          let d = difference.getDate();
          let result;
          if (y > 0) result = (y === 1) ? y + ' year' : y + ' years';
          else if (m > 0) result = (m === 1) ? m + ' month' : m + ' months';
          else result = (d === 1) ? d + ' day' : d + ' days';
          return result;
        }
      },
      get_post_author_link(node) {
        if (!node) return;
        const region = node.querySelector('div[data-click-id="body"]');
        let links;
        if (region) {
          links = region.getElementsByTagName('a');
          for (const link of links) {
            if (!link.innerText.startsWith('u/')) continue;
            const href = link.getAttribute('href');
            if (href && href.startsWith('/user/')) {
              return link;
            }
          }
        }
      },
      page_type() {
        const pages = {
          'profile': () => {
            return !!GetElById('profile-nav-menu-tooltip');
          },
          'search': () => {
            return !!GetElById('search-results-sort');
          },
          'listing': () => {
            return !!GetElById('ListingSort--SortPicker');
          },
          'comments': () => {
            return !!GetElById('CommentSort--SortPicker');
          }
        };
        const result = [...arguments].map((arg) => {
          return pages[arg]();
        });
        return result.includes(true);
      },
      node_text_includes(str, node) {
        const child = (node) ? node.firstChild : undefined;
        if (child && child.nodeValue) {
          return child.nodeValue.includes(str);
        }
      },
      get_els_by_text(str, tag, region) {
        const tags = region.getElementsByTagName(tag);
        let result = [];
        for (let i = 0; i < tags.length; i++) {
          if (UT.node_text_includes(str, tags[i])) {
            result.push(tags[i]);
          }
        }
        return result;
      },
      nth_parent(node, n) {
        if (!Number.isInteger(n) && n < 1) throw "First argument must be a positive integer";
        let parent = node;
        if (!parent) return;
        for (let i = 0; i < n; i++) {
          parent = parent.parentNode;
        }
        return parent;
      },
      list_has(list, query, ignore_case) {
        if (!query) return false;
        if (ignore_case) {
          list = list.map(i => i.toUpperCase());
          query = query.toUpperCase();
        }
        return list.includes(query);
      },
      comment_count(region) {
        // TBDL: interpret abbreviated numbers.
        const icon = region.querySelector('.icon-comment');
        if (icon && icon.nextElementSibling) {
          const num = parseInt(icon.nextElementSibling.innerText, 10);
          return !isNaN(num) ? num : 0;
        } else {
          return -1;
        }
      },
      hide(node) {
        if (!node) return;
        node.style.display = 'none';
      },
      show(node) {
        if (!node) return;
        const initial = window.getComputedStyle(node).getPropertyValue('display');
        if (initial === 'none') node.style.display = '';
      }
    };

    class Feature {
      constructor(data) {
        const loaded = JSON.parse(GM_getValue('features', '{}'));

        if (loaded.hasOwnProperty(data.id)) {
          data.enabled = loaded[data.id].enabled;
          if (loaded[data.id].hasOwnProperty('options')) {
            this.loaded_options = loaded[data.id].options;
          }
        }
        else {
          const db_entry = loaded;

          db_entry[data.id] = {
            enabled: data.enabled,
            options: {}
          };

          GM_setValue('features', JSON.stringify(db_entry));
        }

        for (const key in data) {
          this[key] = data[key];
        }
        if (!data.hidden) {
          draw_setting(data);
        }
        this.options = {};
        this.option_callbacks = {};
        this.public = {};
      }

      add_option(option) {
        const loaded_options = this.loaded_options;
        let is_stored = loaded_options.hasOwnProperty(option.id);
        if (is_stored) {
          // Modify passed object.
          if (option.hasOwnProperty('enabled')) {
            option.enabled = loaded_options[option.id].enabled;
          }
          if (loaded_options[option.id].hasOwnProperty('value')) {
            option.value = loaded_options[option.id].value;
          }
        }
        else {
          const db_entry = JSON.parse(GM_getValue('features', '{}'));
          db_entry[this.id].options[option.id] = {};

          if (option.hasOwnProperty('value')) {
            db_entry[this.id].options[option.id].value = option.value;
          }
          db_entry[this.id].options[option.id].enabled = option.enabled;
          GM_setValue('features', JSON.stringify(db_entry));
        }
        this.options[option.id] = new Option(option);
        // Delete temporary object when we're finished with it:
        if (this.loaded_options.hasOwnProperty(option.id)) {
          delete this.loaded_options[option.id];
        }
      }

      toggle() {
        if (this.callback) this.callback(this.enabled);
        const callbacks = this.option_callbacks;
        // Go through each option cb, sending the value of feature.enabled.
        for (const key in callbacks) {
          callbacks[key](this.enabled);
        }
      }

      set on_toggle(fn) {
        this.callback = fn;
      }

      set on_toggle_option(functions) {
        this.option_callbacks = functions;
      }

      update_nodes(nodes) {
        nodes.forEach(n => {
          n.classList.toggle('fixd_enabled');
        });
      }

      update(data, nodes) {
        this.enabled = data;
        this.toggle();
        if (nodes) this.update_nodes(nodes);
      }

      update_option(data, oid, nodes) {
        this.options[oid].enabled = data;
        const callbacks = this.option_callbacks;
        if (this.enabled && callbacks.hasOwnProperty(oid)) {
          callbacks[oid](data);
        }
        if (nodes) this.update_nodes(nodes);
      }

      update_values(oid, data) {
        this.options[oid].value = data;
      }
    }

    class Option {
      constructor(data) {
        for (const key in data) {
          this[key] = data[key];
        }
      }
    }

    class Reddit_Observer {
      constructor(arg) {
        this.name = arg.name;
        this.target = arg.target;
        if (arg.options) {
          this.options = arg.options;
        } else {
          this.options = { childList: true };
        }
        this.actions = [];
        this.watch(arg.target);
      }
      set any(callback) {
        this.any_basis = callback;
      }
      set added(callback) {
        this.added_basis = callback;
      }
      set removed(callback) {
        this.removed_basis = callback;
      }
      get records() {
        if (this.observer) {
          return this.observer.takeRecords();
        }
      }
      loop_actions(mutation, other) {
        for (let i = 0; i < this.actions.length; i++) {
          this.actions[i](this, mutation, other);
        }
      }
      extend(fn) {
        this.actions.push(fn);
        if (this.target) {
          this.watch();
        }
      }
      watch(newTarget) {
        if (newTarget) {
          this.target = newTarget;
        }
        else if (!this.target) {
          return;
        }
        const self = this;
        function mutation(mutations) {
          for (let i = 0; i < mutations.length; i++) {
            const a = mutations[i].addedNodes;
            const r = mutations[i].removedNodes;
            if (self.added_basis && a.length && a[0].nodeType === Node.ELEMENT_NODE) {
              self.added_basis(self, a[0], mutations[i]);
            }
            else if (self.removed_basis && r.length && r[0].nodeType === Node.ELEMENT_NODE) {
              self.removed_basis(self, r[0], mutations[i]);
            }
            else if (self.any_basis) {
              self.any_basis(self, mutations[i]);
            }
          }
        };
        if (this.observer) {
          this.observer.disconnect();
        }
        this.observer = new MutationObserver(mutation);
        if (this.target.constructor.name !== 'NodeList') {
          this.observer.observe(this.target, this.options);
        }
        else {
          for (const target of this.target) {
            this.observer.observe(target, this.options);
          }
        }
      }
    }
    // Set up pre-defined Observers:
    const Body_Obs = new Reddit_Observer({
      name: 'Body',
      target: document.body
    });
    const View_Obs = new Reddit_Observer({
      name: 'View',
      target: GetEls('#view--layout--FUE button'),
      options: { attributes: true }
    });
    const Content_Obs = new Reddit_Observer({
      name: 'Content area',
      target: GetEl('header').parentNode.nextElementSibling,
      options: { childList: true, subtree: true }
    });
    const Post_Obs = new Reddit_Observer({
      name: 'Post',
      target: GetEl('header').parentNode.parentNode,
      options: { childList: true, subtree: true }
    });
    const Comment_Obs = new Reddit_Observer({
      name: 'Comment',
      target: (() => {
        const c = GetElsByClass('Comment');
        if (c.length) {
          return UT.nth_parent(c[0], 4);
        }
      })()
    });
    const Lb_Obs = new Reddit_Observer({
      name: 'LB',
      target: GetElById('SHORTCUT_FOCUSABLE_DIV').children[0]
    });
    const Lb_Comments_Obs = new Reddit_Observer({
      name: 'LB comments',
      target: GetElById('overlayScrollContainer'),
      options: { childList: true, subtree: true }
    });
    const Lb_TT_Obs = new Reddit_Observer({
      name: 'L-box tooltip',
      target: GetElById('overlayAbsoluteTooltipContent')
    })
    const Side_Obs = new Reddit_Observer({
      name: 'Side',
      target: GetElsByClass('fixd--side')[0],
      options: { childList: true, subtree: true }
    });
    // Handle each mutation:
    function added_to_body(self, node) {
      self.loop_actions(node);
    };
    Body_Obs.added = added_to_body;

    function changed_view(self, mutation) {
      if (mutation.attributeName === 'aria-pressed') {
        const isActive = JSON.parse(mutation.target.attributes['aria-pressed'].value);
        if (isActive) {
          document.body.dataset.fixdView = mutation.target.attributes['aria-label'].value;
        }
      }
    }
    View_Obs.any = changed_view;

    // TBDL: more testing needed to reduce overhead.
    function added_to_list_area(self, node) {
      if (node.classList.contains('Post')) return;
      // TODO: shorten statement:
      if (node.children[0] && node.children[0].children[0] && node.children[0].children[0].classList.contains('Post')) return;

      if (node.getElementsByClassName('Post').length) {
        Side_Obs.watch(GetEl('.fixd--side'));
        Post_Obs.watch();
        View_Obs.watch(GetEls('#view--layout--FUE button'));
        self.loop_actions(node);
      }
    };
    Content_Obs.added = added_to_list_area;

    function added_to_post_list(self, node) {
      if (node.querySelector('.Post') || node.classList.contains('Post')) {
        self.loop_actions(node);
      }
    };
    Post_Obs.added = added_to_post_list;

    function added_to_comment_list(self, node, mutation) {
      if (node.getElementsByClassName('Comment')[0]) {
        self.loop_actions(node, mutation);
      }
    };
    Comment_Obs.added = added_to_comment_list;

    function add_remove_lightbox(self, mutation) {
      if (mutation.addedNodes.length) {
        const node = mutation.addedNodes[0];
        const lb = GetElById('overlayScrollContainer');
        const com_count = UT.comment_count(node);
        const comments = node.getElementsByClassName('Comment');
        if (com_count > -1 && comments.length > 0) {
          // Comments found, and watch for new comments.
          const list = UT.nth_parent(comments[0], 4);
          Comment_Obs.watch(list);
          Lb_Comments_Obs.loop_actions(list); // Run actions of other observer.
        } else if (!comments.length) {
          // watch for preloaded comments.
          Lb_Comments_Obs.watch(lb);
        }
        const side = lb.children[0].children[1];
        if (side) {
          Side_Obs.loop_actions(side);
          self.loop_actions(lb);
        } else {
          // watch for side
          Side_Obs.watch(lb);
        }
        Lb_TT_Obs.watch(GetElById('overlayAbsoluteTooltipContent'));
      } else {
        Lb_Obs.watch();
      }
    };
    Lb_Obs.any = add_remove_lightbox;

    function added_preloaded_comments(self, node, mutation) {
      const comments = self.target.getElementsByClassName('Comment');
      if (comments.length > 0) {
        const list = UT.nth_parent(comments[0], 4);
        if (list.parentNode === node) {
          // Freshly loaded:
          Comment_Obs.watch(list);
          self.loop_actions(list);
        }
      }
    };
    Lb_Comments_Obs.added = added_preloaded_comments;

    function added_lb_tooltip(self, node, mutation) {
      self.loop_actions(node, mutation);
    };
    Lb_TT_Obs.added = added_lb_tooltip;

    function added_to_side(self, node, mutation) {
      const side = self.target.children[0].children[1];
      if (side.parentNode === node) {
        Lb_Obs.loop_actions(self.target);
      }
    };
    Side_Obs.added = added_to_side;

    function get_reddit_data(kind, name) {
      return new Promise(function (resolve, reject) {
        let url;
        const key = kind + '_' + name;
        const cache = JSON.parse(GM_getValue('cache', '{}'));
        let ratelimit = JSON.parse(GM_getValue('ratelimit_get', '{}'));

        if (cache[key]) {
          resolve(cache[key]);
        }
        else if (!ratelimit.remaining || ratelimit.remaining > 150) {
          const req = new XMLHttpRequest();
          url = '/r/' + name + '/about.json';
          req.open('GET', url);

          req.onload = function () {
            if (req.status === 200) {
              const response = JSON.parse(this.response).data;
              let json_data = cache;

              ratelimit = {
                used: this.getResponseHeader('x-ratelimit-used'),
                remaining: this.getResponseHeader('x-ratelimit-remaining'),
                reset: this.getResponseHeader('x-ratelimit-reset')
              };

              json_data[key] = {
                name: response.display_name,
                title: response.title,
                subtitle: response.header_title,
                desc: response.public_description,
                created: response.created,
                subs: response.subscribers,
                subscriber: response.user_is_subscriber
              };

              GM_setValue('ratelimit_get', JSON.stringify(ratelimit));
              GM_setValue('cache', JSON.stringify(json_data));
              resolve(json_data[key]);
            }
            else {
              reject(Error(req.statusText));
            }
          };
          req.onerror = function () {
            reject(Error("Network Error"));
          };
          req.send();
        } else if (ratelimit.remaining) {
          reject(ratelimit.remaining + ' requests remaining.');
        }
      })
    };

    const clear_cache = (() => {
      let ratelimit = JSON.parse(GM_getValue('ratelimit_get', '{}'));
      if (ratelimit.used !== undefined && ratelimit.used <= 1) {
        GM_setValue('cache', '{}');
      }
    })();

    function draw_options_panel(fid) {
      const feature = FT[fid];
      const tpl = `<div id="fixd_options" data-id="${fid}" class="fixd_panel">\
      <button class="fixd_btn_back">Back</button>\
      <h2>${feature.label}</h2></div>`;

      const classes = ['fixd_switch', 'fixd_setting_switch'];
      let checked = '';
      if (feature.enabled) {
        classes.push('fixd_enabled');
        checked = 'checked';
      }
      const toggle = `<label data-id="${fid}" class="${classes.join(' ')}"> \
      <input type="checkbox" ${checked}> On</label>`;

      UT.hide(GetElById('fixd_settings_menu'));
      GetElById('fixd_settings').insertAdjacentHTML('beforeend', tpl);

      const panel = GetElById('fixd_options');
      if (!feature.hidden) {
        panel.insertAdjacentHTML('beforeend', toggle);
      }
      draw_option_items(feature.options, panel);
    };
    function draw_option_items(options, target) {
      for (let oid in options) {
        const option = options[oid];
        if (option.hidden) continue;
        const is_bool = ['bool', undefined].includes(option.type);
        const is_on = !option.hasOwnProperty('enabled') || option.enabled;
        const classes = ['fixd_option'];
        if (is_bool) {
          classes.push('fixd_switch', 'fixd_option_switch');
        } else {
          classes.push('fixd_option_btn');
        }
        if (is_on) {
          classes.push('fixd_enabled');
        }
        const template = `<label data-id="${option.id}" class="${classes.join(' ')}">\
        ${is_bool ? `<input type="checkbox" ${is_on ? `checked` : ``}> ` : ``}${option.label}</label>`;
        target.insertAdjacentHTML('beforeend', template);
      }
    };
    function draw_option_choices(data, saved, target) {
      for (let uid in data.choices) {
        const label = data.choices[uid][1];
        const classes = ['fixd_option_select'];
        let type = 'radio';
        let checked = '';
        if (data.type !== 'radio') {
          type = 'checkbox';
        }
        if (saved.value.includes(uid)) {
          checked = 'checked';
          classes.push('fixd_enabled');
        }
        const tpl = `<label class="${classes.join(' ')}">\
        <input type="${type}" ${checked} name="fixd_option_choices" value="${uid}">\
         ${label}</label>`;
        target.insertAdjacentHTML('beforeend', tpl);
      };
    };
    function draw_option_dialog(oid) {
      const panel = GetElById('fixd_options');
      const fid = panel.dataset.id;
      const saved = JSON.parse(GM_getValue('features', '{}'));
      const option = FT[fid].options[oid];
      const saved_option = saved[fid].options[oid];
      const type = option.type;
      const is_on = saved_option.enabled;
      let list_val;

      if (type === 'list') {
        saved_option.value.sort((a, b) => {
          return a.localeCompare(b, 'en', { 'sensitivity': 'base' });
        });
        list_val = saved_option.value.join('\n');
      }

      let toggle = '';
      if (option.hasOwnProperty('enabled')) {
        const classes = ['fixd_switch', 'fixd_option_switch'];
        if (is_on) {
          classes.push('fixd_enabled');
        }
        toggle = `<label class="${classes.join(' ')}" data-id="${oid}">\
        <input type="checkbox" ${is_on ? `checked` : ``}> On</label>`;
      }

      const description = option.description ? option.description : '';
      const tpl = `\
      <div id="fixd_dialog" data-id="${oid}">\
        <div class="fixd_settings_header">\
          <button class="fixd_btn_back">Back</button>\
          <h3>${FT[fid].label} <span>${option.label}</span></h3>\
        </div>\
        ${toggle}\
        <div class="fixd_description">${description}</div>\
        <div class="fixd_settings_buttons">\
        <button class="fixd_btn_save">Save changes</button></div>\
        ${type === 'list' ? `<textarea name="fixd_option_list">${list_val}</textarea>` : ``}\
      </div>`;

      panel.insertAdjacentHTML('beforeend', tpl);
      const dialog = GetElById('fixd_dialog');
      if (type !== 'list') {
        draw_option_choices(option, saved_option, dialog);
      }
      GetElById('fixd_settings').classList.add('fixd_expanded');
    };

    function close_dialog() {
      GetElById('fixd_settings').classList.remove('fixd_expanded');
      $('#fixd_dialog').remove();
    };

    function handle_submit() {
      const panel = GetElById('fixd_options');
      const fid = panel.dataset.id;
      const oid = GetElById('fixd_selected_option').dataset.id;
      const saved = JSON.parse(GM_getValue('features', '{}'));
      const feature = FT[fid];
      const option = feature.options[oid];
      let db_entry = saved;
      if (option.type === 'list') {
        let value = GetElsByName('fixd_option_list')[0].value;
        value = value.replace(/[^a-zA-Z\d\n#._-]/mg, "");
        db_entry[fid].options[oid].value = value.split('\n');
      }
      else if (option.hasOwnProperty('choices')) {
        const choices = GetElsByName('fixd_option_choices');
        // Clear the old value before repopulating.
        db_entry[fid].options[oid].value = [];
        function handle_choices(el, idx) {
          if (el.value && el.checked) {
            if (option.type === 'radio') {
              db_entry[fid].options[oid].value[0] = el.value;
              return false;
            }
            else {
              db_entry[fid].options[oid].value[idx] = el.value;
            }
          }
        }
        choices.forEach(handle_choices);
      }
      GM_setValue('features', JSON.stringify(db_entry));
      feature.update_values(oid, db_entry[fid].options[oid].value);
      close_dialog();
    };

    function handle_back(ev) {
      if (GetElById('fixd_dialog')) {
        close_dialog();
      } else {
        $('#fixd_options').remove();
        UT.show(GetElById('fixd_settings_menu'));
      }
    };

    function close_settings() {
      GetEls('#fixd_settings, #fixd_launch').forEach(e => e.classList.remove('fixd_active'));
      close_dialog();
    };

    function handle_settings_click(ev) {
      const clicked = ev.target;
      const parent = clicked.parentNode;
      const class_list = clicked.classList;
      const has_class = c => class_list ? class_list.contains(c) : false;
      const data_id = clicked.dataset.id;
      const is_input = clicked.tagName === 'INPUT';
      if (has_class('fixd_setting_btn')) {
        draw_options_panel(data_id);
      }
      else if (has_class('fixd_option_btn')) {
        let selected = GetElById('fixd_selected_option');
        if (selected) selected.removeAttribute('id');
        clicked.id = 'fixd_selected_option';
        draw_option_dialog(data_id);
      }
      else if (has_class('fixd_btn_save')) {
        handle_submit(ev);
      }
      else if (has_class('fixd_btn_back')) {
        handle_back(ev);
      }
      else if (is_input && parent.classList.contains('fixd_switch')) {
        handle_switch(parent);
      }
      else if (is_input && parent.classList.contains('fixd_option_select')) {
        if (clicked.getAttribute('type') === 'radio') {
          let choices = GetElsByName('fixd_option_choices');
          choices.forEach(i => i.parentNode.classList.remove('fixd_enabled'));
        }
        parent.classList.toggle('fixd_enabled');
      }
    };

    const insert_settings_form = (() => {
      const tpl = `<div id="fixd_settings" data-version="${GM_info.script.version}">
      <div id="fixd_settings_menu" class="fixd_panel"><h1>Fixdit Settings</h1></div></div>`;
      document.body.insertAdjacentHTML('beforeend', tpl);
      const box = GetElById('fixd_settings');
      box.addEventListener('click', handle_settings_click, false);
    })();

    function draw_setting(data) {
      const classes = ['fixd_option_btn', 'fixd_setting_btn'];

      if (data.enabled) {
        classes.push('fixd_enabled');
      }
      const template = `<div data-id="${data.id}" class="${classes.join(' ')}">\
      ${data.label}</div>`;
      GetElById('fixd_settings_menu').insertAdjacentHTML('beforeend', template);
    }

    document.addEventListener('click', ev => {
      const path = ev.composedPath();
      const launcher = GetElById('fixd_launch');
      const settings = GetElById('fixd_settings');
      const popup = GetElsByClass('fixd_popup')[0];
      if (!path.includes(popup)) {
        FT.subreddit_info.public.close_popup();
      }
      if (!path.includes(launcher) &&
        !path.includes(settings)) {
        close_settings();
      }
      if (ev.target === launcher) {
        if (ev.target.classList.contains('fixd_active')) {
          close_settings();
        }
        else {
          launcher.classList.add('fixd_active');
          settings.classList.add('fixd_active');
          $('#fixd_options').remove();
          UT.show(GetElById('fixd_settings_menu'));
        }
      }
    });
    function handle_switch(clicked) {
      const saved = JSON.parse(GM_getValue('features', '{}'));
      const fid = GetElById('fixd_options').dataset.id;
      const feature = FT[fid];
      const db_entry = saved;
      const nodes = [clicked];

      let oid;
      if (clicked.classList.contains('fixd_option_switch')) {
        oid = clicked.dataset.id;
      }

      if (oid) {
        if (GetElById('fixd_dialog')) {
          nodes.push(GetEl(`.fixd_option_btn[data-id="${oid}"]`));
        }
        if (saved[fid].options[oid].enabled) {
          db_entry[fid].options[oid].enabled = false;
        } else {
          db_entry[fid].options[oid].enabled = true;
        }
        feature.update_option(db_entry[fid].options[oid].enabled, oid, nodes);
      } else if (fid) {
        nodes.push(GetEl(`.fixd_setting_btn[data-id="${fid}"]`));
        if (saved[fid].enabled) {
          db_entry[fid].enabled = false;
        } else {
          db_entry[fid].enabled = true;
        }
        feature.update(db_entry[fid].enabled, nodes);
      } else {
        return;
      }
      GM_setValue('features', JSON.stringify(db_entry));
    }
    // Begin modules/features.
    const FT = {};
    FT.ui_selectors = (() => {
      const ftr = new Feature({
        id: "ui_selectors",
        label: "Add UI Selectors",
        enabled: true,
        internal: true,
        hidden: true
      });
      function tag_body() {
        const layout_switch_card = GetElById('layoutSwitch--card');

        if (!layout_switch_card) return;

        const layout_switches_wrap = layout_switch_card.parentNode;
        let view_mode;

        for (const button of layout_switches_wrap.getElementsByTagName('button')) {
          const pressed = JSON.parse(button.getAttribute('aria-pressed'));
          if (pressed) {
            view_mode = button.getAttribute('aria-label');
          }
        }

        document.body.dataset.fixdView = view_mode;

        const list_area = GetEl('header').parentNode.nextElementSibling;
        if (list_area) {
          list_area.classList.add('fixd_list_area');
        }
      }
      function tag_subreddits_boxes(arg) {
        if (!arg) return;
        let region = arg;
        let is_profile = UT.page_type('profile');
        let lightbox = GetElById('overlayScrollContainer');
        for (const el of region.getElementsByTagName('button')) {
          if (UT.list_has(['subscribe', 'unsubscribe'], el.innerText, true)) {
            const item = el.parentNode.parentNode;
            const img = item.getElementsByTagName('img');
            const svg = item.getElementsByTagName('svg');
            if (img.length === 1 || svg.length === 1) {
              let contents = item.parentNode.parentNode.parentNode;
              if (is_profile && !lightbox) {
                contents = contents.parentNode;
              }
              const a = item.querySelector('a');
              const sib = a.nextElementSibling;
              const p = sib && sib.tagName === 'P' ? sib : undefined;
              if (p && a.getAttribute('href').startsWith('/r/') && UT.node_text_includes('subscribers', p)) {
                let container = contents.parentNode.parentNode;
                container.classList.add('fixd--subreddits');
                break;
              }
            }
          }
        }
        region = [...GetElsByClass('fixd--subreddits')].pop();
        region = region ? region.nextElementSibling : undefined;
        if (!region) return;
        let button = region.querySelector('button');
        let label = button ? button.innerText.toUpperCase() : undefined;
        if (UT.list_has(['subscribe', 'unsubscribe'], label, true)) {
          // Repeat
          tag_subreddits_boxes(region);
        }
      };
      function tag_content(region, np) {
        const posts = document.getElementsByClassName('Post');
        if (posts.length) {
          // Walk thru parents if np is defined, else take 'region' as content node.
          let content = np ? UT.nth_parent(posts[0], np) : region;
          content.classList.add('fixd--content');
          tag_side(content);
        }

        const overlay = GetElById('overlayScrollContainer');
        if (overlay) {
          const overlayHead = overlay.parentNode.previousSibling;
          if (overlayHead) {
            overlayHead.classList.add('fixd_overlay_head');
          }
        }
      };
      const tag_side = content => {
        const side = content.nextElementSibling;
        if (!side) return;
        side.classList.add('fixd--side');
        const mods_h = UT.get_els_by_text('Moderators', 'h3', side)[0];
        if (mods_h) {
          mods_h.parentNode.classList.add('fixd--moderators');
        }
        tag_subreddits_boxes(side);
      };

      // doc loaded:
      tag_body();

      if (UT.page_type('comments')) {
        tag_content(document, 3);
      }

      else if (UT.page_type('profile')) {
        const images = GetEl('header').parentNode.nextElementSibling.getElementsByTagName('img');
        for (const img of images) {
          if (img.src && img.src.startsWith('https://www.redditstatic.com/avatars')) {
            let a = img.parentNode.parentNode.querySelector('a');
            if (a && a.href && a.getAttribute('href').startsWith('/user/')) {
              return tag_content(UT.nth_parent(img, 5), 7);
            }
          }
        }
      }

      else if (UT.page_type('listing')) {
        tag_content(document, 5);
      }

      Content_Obs.extend((self, node) => {
        tag_body();
        tag_content(document, 5);
      });
      Lb_Obs.extend((self, node) => {
        tag_content(node.children[0].children[0]);
      });
      Side_Obs.extend((self, node) => {
        tag_subreddits_boxes(self.target);
      });
      return ftr;
    })();
    FT.ui_tweaks = (() => {
      const ftr = new Feature({
        id: "ui_tweaks",
        label: "UI Tweaks",
        enabled: false
      });
      ftr.add_option({
        id: 'middle_click_posts',
        label: 'Middle mouse click behavior',
        description: `Choose what happens when you press the middle mouse\
        button in the empty space of a post. Might not work for Firefox.`,
        enabled: false,
        type: 'radio',
        choices: {
          1: ['do_nothing', "Do nothing (no scroll)"],
          2: ['view_thread', 'Open comments in new tab'],
          3: ['view_thread_fg', 'Open comments & switch to tab']
        },
        value: ['1']
      });
      ftr.add_option({
        id: "no_prefix",
        label: "No prefixes for subreddits, users",
        type: "bool",
        enabled: false
      });
      ftr.add_option({
        id: "no_blanks",
        label: "All links can open in current tab",
        type: "bool",
        enabled: false
      });
      ftr.add_option({
        id: "override_vote_icons",
        label: "Override custom vote icons",
        enabled: false,
        type: "bool"
      });
      ftr.add_option({
        id: "static_header",
        label: "Make the top bar stationary/static",
        enabled: false,
        type: "bool"
      });
      ftr.add_option({
        id: "reduce_comment_spacing",
        label: "Reduce comment spacing",
        enabled: false,
        type: 'bool'
      });
      ftr.add_option({
        id: "visited_links",
        label: "Different color for visited links",
        enabled: true,
        type: 'bool'
      });
      ftr.add_option({
        id: "expando_hitbox",
        label: "Tighten expando hover area (in compact mode)",
        enabled: true,
        type: 'bool'
      });

      function handle_mousedown(ev) {
        if (ev.button !== 1) return;
        const lb = GetElById('overlayScrollContainer');
        const listing = UT.page_type('listing', 'search');
        if (!lb && listing) {
          let path = ev.composedPath();
          let post = (() => {
            // Check if I clicked inside a post or link.
            for (let i = 0; i < path.length; i++) {
              let n = path[i];
              let has_class = c => n.classList ? n.classList.contains(c) : false;
              if (['A', 'BODY'].includes(n.tagName) || has_class('fixd--side')) break;
              if (has_class('Post')) {
                const link = n.querySelector('a[data-click-id="body"]');
                if (link) {
                  return { el: n, url: link.href };
                }
              }
            }
          })();
          if (post) {
            ev.stopImmediatePropagation();
            ev.preventDefault();
            if (ftr.options.middle_click_posts.value[0] !== '1') {
              let bg = false;
              if (ftr.options.middle_click_posts.value[0] === '2') {
                bg = true;
              }
              GM_openInTab(post.url, bg);
            }
          }
        }
      };

      function config_middle_click(feature_on) {
        if (feature_on && ftr.options.middle_click_posts.enabled) {
          document.body.addEventListener('mousedown', handle_mousedown, false);
        } else {
          document.body.removeEventListener('mousedown', handle_mousedown);
        }
      };

      function check_vote_icon(up) {
        const wrap = up.parentNode;
        const down = wrap.querySelector('button[data-click-id="downvote"]');
        const score = wrap.querySelector('div');

        // Customized vote buttons do not have child nodes (until we add one via CSS)
        if (!up.children.length) override_vote_icon(up);
        if (!down.children.length) override_vote_icon(down);

        // remove colour override from score.
        if (up.classList.contains('fixd_no_icon') || down.classList.contains('fixd_no_icon')) {
          wrap.classList.add('fixd_override_vote_icon');
        }
      };

      function init_override_vote_icons(region) {
        if (ftr.options.override_vote_icons.enabled) {
          const selector = 'button[data-click-id="upvote"]';
          region.querySelectorAll(selector).forEach(check_vote_icon);
        }
      };

      function override_vote_icon(el) {
        // Remove style override so we can search the url string for active/inactive
        el.classList.add('fixd_no_icon');
        el.style.background = 'none';
      }

      function strip_prefixes(node) {
        if (ftr.options.no_prefix.enabled) {
          let u = UT.get_post_author_link(node);
          if (u) {
            u.innerText = u.innerText.replace("u/", "");
          }
          let sub_links = node.querySelectorAll('a[data-click-id="subreddit"]');
          for (const a of sub_links) {
            if (!a.children.length) {
              a.innerText = a.innerText.replace("r/", "");
            }
          };
        }
      };
      function remove_blanks(region) {
        if (ftr.options.no_blanks.enabled) {
          const anchors = region.getElementsByTagName('a');
          for (let i = 0; i < anchors.length; i++) {
            if (anchors[i].getAttribute('target')) {
              anchors[i].removeAttribute('target');
              anchors[i].classList.add('fixd_no_blank');
            }
          }
        }
      };
      function static_header(feature_on) {
        if (feature_on && ftr.options.static_header.enabled) {
          const header = GetEl('header');
          document.body.classList.add('fixd_static_header');
        } else {
          const header = GetEl('header');
          document.body.classList.remove('fixd_static_header');
        }
      };

      function reduce_comment_spacing(feature_on) {
        if (feature_on && ftr.options.reduce_comment_spacing.enabled) {
          document.body.classList.add('fixd_reduce_comment_spacing');
        } else {
          document.body.classList.remove('fixd_reduce_comment_spacing');
        }
      }

      function visited_links(feature_on) {
        if (feature_on && ftr.options.visited_links.enabled) {
          document.body.classList.add('fixd_visited_links');
        } else {
          document.body.classList.remove('fixd_visited_links');
        }
      }

      function expando_hitbox(feature_on) {
        if (feature_on && ftr.options.expando_hitbox.enabled) {
          document.body.classList.add('fixd_expando_hitbox');
        } else {
          document.body.classList.remove('fixd_expando_hitbox');
        }
      }

      ftr.on_toggle_option = {
        'static_header': static_header,
        'reduce_comment_spacing': reduce_comment_spacing,
        'visited_links': visited_links,
        'expando_hitbox': expando_hitbox,
        'middle_click_posts': config_middle_click
      };

      if (ftr.enabled) {
        config_middle_click(ftr.enabled);
        static_header(ftr.enabled);
        reduce_comment_spacing(ftr.enabled);
        visited_links(ftr.enabled);
        expando_hitbox(ftr.enabled);
        init_override_vote_icons(document.body);
        remove_blanks(document);
        const posts = GetElsByClass('Post');
        for (const post of posts) {
          strip_prefixes(post);
          remove_blanks(post);
        }
        Lb_Obs.extend((self, node) => {
          init_override_vote_icons(node);
          remove_blanks(node);
        });
        Lb_Comments_Obs.extend((self, node) => {
          init_override_vote_icons(node);
          remove_blanks(node);
        });
        Content_Obs.extend((self, node) => {
          const posts = self.target.getElementsByClassName('Post');
          for (const post of posts) {
            strip_prefixes(post);
            remove_blanks(post);
            init_override_vote_icons(post);
          }
        });
        Post_Obs.extend((self, node) => {
          strip_prefixes(node);
          init_override_vote_icons(node);
          remove_blanks(node);
        });
        Comment_Obs.extend((self, node) => {
          init_override_vote_icons(node);
          remove_blanks(node);
        });
        document.body.addEventListener('click', ev => {
          if (['upvote', 'downvote'].includes(ev.target.dataset.clickId)) {
            init_override_vote_icons(ev.target.parentNode);
          }
        });
      }
      return ftr;
    })();
    FT.filter_content = (function () {
      const ftr = new Feature({
        id: "filter_content",
        label: "Filter Content",
        enabled: true
      });
      ftr.add_option({
        id: "subreddits",
        label: "Posts by subreddit",
        type: "list",
        enabled: true,
        description: "One <em>subreddit name</em> per line. No commas or slashes. Ignores search results.",
        value: []
      });
      ftr.add_option({
        id: "users",
        label: "Posts by user",
        description: "One <em>user name</em> per line. No commas or slashes. Ignores search results.",
        type: "list",
        enabled: false,
        value: []
      });
      ftr.add_option({
        id: "comments",
        label: "Comments by user",
        description: "One <em>user name</em> per line. No commas or slashes.",
        type: "list",
        enabled: false,
        value: []
      });
      const blocked_subs = ftr.options.subreddits.value.map(i => i.toUpperCase());
      const blocked_submitters = ftr.options.users.value.map(i => i.toUpperCase());
      const blocked_comments = ftr.options.comments.value.map(i => i.toUpperCase());

      const regex = {
        url_subreddit: /.*\/r\//i,
        url_user: /.*\/user\//i
      };

      function init_posts(region) {
        if (UT.page_type('comments', 'search', 'profile')) return;
        const posts = region.getElementsByClassName('Post');
        for (let i = 0; i < posts.length; i++) {
          filter_post(posts[i].parentNode.parentNode);
        }
      };

      function init_comments(region) {
        const comments = region.getElementsByClassName('Comment');
        for (let i = 0; i < comments.length; i++) {
          filter_comment(UT.nth_parent(comments[i], 3));
        }
      };

      function filter_post(node) {
        let sub_href, user_href;
        if (ftr.options.subreddits.enabled) {
          let sub_a = node.querySelector('a[data-click-id="subreddit"]');
          sub_href = sub_a ? sub_a.getAttribute('href') : undefined;
        }
        if (ftr.options.users.enabled) {
          let user_a = UT.get_post_author_link(node);
          user_href = user_a ? user_a.getAttribute('href') : undefined;
        }
        if (sub_href) {
          let sub_name = sub_href.replace(regex.url_subreddit, "").replace("/", "");

          if (UT.list_has(blocked_subs, sub_name.toUpperCase())) {
            node.classList.add('fixd_hidden');
          }
        }
        if (!node.classList.contains('fixd_hidden') && user_href) {
          let user_name = user_href.replace(regex.url_user, "").replace("/", "");

          if (UT.list_has(blocked_submitters, user_name.toUpperCase())) {
            node.classList.add('fixd_hidden');
          }
        }
      };

      function filter_comment(node) {
        const comment_body = node.querySelector('.Comment').children[1];
        if (!comment_body) return;

        let user_link = comment_body.children[0].querySelector('a');
        user_link = user_link ? user_link.getAttribute('href') : undefined;
        if (!user_link) return false;

        let user_name = user_link.replace(regex.url_user, "");
        user_name = user_name.replace("/", "");

        if (UT.list_has(blocked_comments, user_name.toUpperCase())) {
          node.classList.add('fixd_filtered');

          let cid;
          node.querySelector('.Comment').classList.forEach(str => {
            const match = /^t1_/.exec(str);
            if (match) {
              cid = match.input;
              return false;
            }
          });

          let c_node = GetElById(cid);
          const tpl = `<div class="fixd_filter_msg"><span>${user_name}</span>\
          <em>(Fixdit filtered)</em>\
          <button data-click-id="fixd_unfilter_btn">Show comment</button></div>`;
          c_node.insertAdjacentHTML('beforeend', tpl);
        }
      };

      function handle_body_click(ev) {
        let clicked = ev.target;
        if (clicked.dataset.clickId === 'fixd_unfilter_btn') {
          // TBDL: reconnect comment_change observer for fixd_collapse.
          let comment = $(clicked).parents('.fixd_filtered')[0];

          if (comment.classList.contains('fixd_filtered')) {
            comment.classList.replace('fixd_filtered', 'fixd_unfiltered');
          }
          else if (comment.classList.contains('fixd_unfiltered')) {
            comment.classList.replace('fixd_unfiltered', 'fixd_filtered');
          }
        }
      };
      if (ftr.enabled) {
        if (ftr.options.comments.enabled) {
          init_comments(document);
          document.body.addEventListener('click', handle_body_click, false);
          Lb_Comments_Obs.extend((self, node) => {
            init_comments(node);
          });
          Comment_Obs.extend((self, node) => {
            filter_comment(node);
          });
        }

        if (ftr.options.subreddits.enabled || ftr.options.users.enabled) {
          // document ready:
          init_posts(document);

          Content_Obs.extend((self, node) => {
            init_posts(self.target);
          });

          Post_Obs.extend((self, node) => {
            if (node.classList.contains('Post')) {
              node = node.parentNode.parentNode;
            }
            filter_post(node);
          });
        }
      }
      return ftr;
    })();
    FT.subreddit_info = (function () {
      const ftr = new Feature({
        id: "subreddit_info",
        label: "Subreddit Info Box",
        enabled: true
      });
      ftr.add_option({
        id: 'delay',
        label: 'Popup delay',
        choices: {
          1: ['short', 'Short', 200],
          2: ['medium', 'Medium', 400],
          3: ['long', 'Long', 700]
        },
        value: ['2'],
        type: 'radio'
      });
      const delay_uid = ftr.options.delay.value[0];
      const delay_open = ftr.options.delay.choices[delay_uid][2] || 400;
      const delay_close = 100;
      let tmo_open;
      let tmo_close;

      function get_popup() {
        ftr.public.close_popup();
        const box = $('<div>', {
          "id": "fixd_popup_subreddit",
          "class": 'fixd_popup'
        }).css({ 'display': 'none' })[0];

        box.addEventListener('click', ev => {
          if (ev.target.classList.contains('fixd_popup_filter')) {
            const saved = JSON.parse(GM_getValue('features', '{}'));
            const name = GetElById('fixd_popup_subreddit').dataset.id;
            let db_entry = saved;
            if (ev.target.classList.contains('fixd_active')) {
              const list = db_entry.filter_content.options.subreddits.value;
              const rexp = RegExp(name, 'gi');
              db_entry.filter_content.options.subreddits.value = list.filter(i => !rexp.test(i));
              ev.target.classList.remove('fixd_active');
            }
            else {
              db_entry.filter_content.options.subreddits.value.push(name);
              ev.target.classList.add('fixd_active');
            }
            GM_setValue('features', JSON.stringify(db_entry));
          }
        }, false);

        box.addEventListener('mouseover', ev => {
          window.clearTimeout(tmo_close);
        }, false);

        box.addEventListener('mouseleave', ev => {
          ftr.public.close_popup();
        }, false);
        return box;
      };

      function add_popup(data, ev) {
        const box = get_popup();
        const filter_btn_classes = ["fixd_popup_filter"];
        const saved = JSON.parse(GM_getValue('features', '{}'));
        const filter_data = saved.filter_content.options.subreddits;
        if (data.subscriber) {
          box.classList.add('fixd_subscriber');
        }
        if (saved.filter_content.enabled && filter_data.enabled) {
          box.classList.add('fixd_filterable');
          if (UT.list_has(filter_data.value, data.name, true)) {
            filter_btn_classes.push('fixd_active');
          }
        }
        let document_width = GetEl('html').offsetWidth;
        let target_offset = $(ev.target).offset().left;
        let offset_left = target_offset;
        if ((document_width - target_offset) < (document_width / 2)) {
          offset_left -= 240;
        }
        const subtitle = data.subtitle ? data.subtitle : '';
        const description = data.desc ? data.desc : '';
        const content = `<div>\
          <h2>${data.name}</h2>\
          <div class="fixd_popup_created">${data.created}</div>\
          <div class="fixd_popup_subs">\
          <span class="fixd_popup_subs">${data.subs}</span> Subscribers\
          </div>\
          <button class="${filter_btn_classes.join(' ')}">Filter</button>\
          </div><div>\
          <div class="fixd_popup_title">${data.title}</div>\
          <div class="fixd_popup_subtitle">${subtitle}</div>\
          <div class="fixd_popup_desc">${description}</div></div>`;
        box.dataset.id = data.name;
        box.style.top = $(ev.target).offset().top + ev.target.offsetHeight + 'px';
        box.style.left = offset_left + 'px';
        box.insertAdjacentHTML('beforeend', content);
        document.body.appendChild(box);
        UT.show(box);
      };

      function init_popup(ev) {
        let name = ev.target.getAttribute('href').split('/')[2];
        get_reddit_data('t5', name).then((data) => {
          const date_created = new Date(data.created * 1000);
          const formatted = {
            name: data.name,
            title: data.title,
            subtitle: data.subtitle,
            created: UT.format_date.age(date_created),
            subs: data.subs.toLocaleString(),
            subscriber: data.subscriber,
            desc: data.desc
          };
          // Stop if mouse pointer exited the target element.
          if (!tmo_open) return;

          add_popup(formatted, ev);
        }, function (error) {
          console.warn("Error retrieving subreddit info popup.", error);
        });
      };

      ftr.public.close_popup = (ev) => {
        const popup = GetElById('fixd_popup_subreddit');
        if (popup) popup.remove();
      };

      if (ftr.enabled) {
        document.body.addEventListener('mouseover', ev => {
          if (ev.target.tagName === 'A') {
            let a = ev.target;
            if ((a.dataset.clickId === 'subreddit' ||
              $(a).parents('p, .md, .fixd--subreddits').length) &&
              a.getAttribute('href').startsWith('/r/')) {

              window.clearTimeout(tmo_open);
              window.clearTimeout(tmo_close);

              tmo_open = window.setTimeout(() => {
                init_popup(ev);
              }, delay_open);

              let mouse_out = (ev) => {
                ev.target.removeEventListener('mouseleave', mouse_out);
                window.clearTimeout(tmo_open);
                tmo_open = null;

                tmo_close = window.setTimeout(() => {
                  ftr.public.close_popup();
                }, delay_close);
              };
              a.addEventListener('mouseleave', mouse_out);
            }
          }
        });
      }
      return ftr;
    })();
    FT.comments_collapse = (function () {
      const ftr = new Feature({
        id: "comments_collapse",
        label: "Collapsible Child Comments",
        enabled: true
      });
      ftr.add_option({
        id: 'auto',
        type: 'bool',
        label: 'Automatically collapse children',
        enabled: false
      });

      const auto_on = ftr.options.auto.enabled;

      function init(region, is_mutate) {
        let lb = GetElById('overlayScrollContainer');
        if (!lb && UT.page_type('profile')) return;
        let comments = region.getElementsByClassName('Comment');
        if (comments.length) {
          let comments_list = UT.nth_parent(comments[0], 4).children;
          const sort_picker = GetElById('CommentSort--SortPicker').parentNode;
          const btn_all_classList = ['fixd_collapse_all'];
          if (auto_on && !is_mutate) {
            btn_all_classList.push('fixd_active');
          }
          const btn_all = `<button class="${btn_all_classList.join(' ')}">children</button>`;
          if (sort_picker) {
            sort_picker.insertAdjacentHTML('beforeend', btn_all);
          }
          for (let i = 0; i < comments_list.length; i++) {
            init_comment(comments_list[i], is_mutate);
          }
        }
      };

      function init_comment(item, is_mutate) {
        item.classList.add('fixd_comment_wrap');
        const is_comment = !!item.getElementsByClassName('Comment').length;
        const is_child = !item.getElementsByClassName('top-level').length;
        const is_hidden = !!item.getElementsByClassName('icon-expand').length;

        if (is_comment && !is_child) {
          item.classList.add('fixd_top-level');
          const next = item.nextElementSibling;
          const next_is_child = next && !next.getElementsByClassName('top-level').length;
          const next_is_thread = next && !!next.getElementsByClassName('threadline').length;

          if (next_is_child && next_is_thread && !is_hidden && !item.querySelector('.fixd_collapse')) {
            const btn_classList = ['fixd_collapse'];

            if (auto_on && !is_mutate) {
              btn_classList.push('fixd_active');
            }
            const btn = `<button class="${btn_classList.join(' ')}">children</button>`;
            let target = [...item.getElementsByTagName('button')].pop();
            if (!target) target = [...item.getElementsByTagName('a')].pop();
            if (target) target.insertAdjacentHTML('afterend', btn);
          }
        }
        if (auto_on && is_child && !is_mutate) {
          item.classList.add('fixd_hidden');
        }
      };

      function toggle_visibility(clicked) {
        let wrap = $(clicked).parents('.fixd_comment_wrap')[0];
        let is_active = clicked.classList.contains('fixd_active');

        function check_next(node) {
          const node_is_child = !node.getElementsByClassName('top-level').length;
          const node_is_thread = !!node.getElementsByClassName('threadline').length;
          if (node_is_child && node_is_thread) {
            if (is_active) {
              node.classList.remove('fixd_hidden');
            } else {
              node.classList.add('fixd_hidden');
            }
            if (node.nextElementSibling) {
              check_next(node.nextElementSibling);
            }
          }
        };
        const next = wrap.nextElementSibling;
        if (next) check_next(next);
        clicked.classList.toggle('fixd_active');
      };

      function handle_click(ev) {
        if (ev.target.classList.contains('fixd_collapse')) {
          ev.stopImmediatePropagation();
          toggle_visibility(ev.target);
        }
        else if (ev.target.classList.contains('fixd_collapse_all')) {
          ev.stopImmediatePropagation();
          if (ev.target.classList.contains('fixd_active')) {
            GetEls('.fixd_collapse.fixd_active').forEach(e => e.click());
          }
          else {
            GetEls('.fixd_collapse:not(.fixd_active)').forEach(e => e.click());
          }
          if (GetEls('.fixd_collapse:not(.fixd_active)').length) {
            ev.target.classList.remove('fixd_active');
          }
          else {
            ev.target.classList.add('fixd_active');
          }
        }
      };

      if (ftr.enabled) {
        init(document, false);
        document.body.addEventListener('click', handle_click, false);
        Lb_Obs.extend((self, node) => {
          init(node, true);
        });
        Lb_Comments_Obs.extend((self, node) => {
          init(node, true);
        });
        Comment_Obs.extend((self, node, mutation) => {
          init_comment(mutation.previousSibling, true);
        });
      }
      return ftr;
    })();
    FT.menu_hover = (function () {
      const ftr = new Feature({
        id: "menu_hover",
        label: "Hover to open menus",
        enabled: false
      });
      ftr.add_option({
        id: 'menus',
        label: "Choose menus",
        enabled: true,
        type: 'checkbox',
        choices: {
          1: ['sortpicker', 'Sort Posts', '#ListingSort--SortPicker'],
          2: ['commentsort', 'Comment Sort Picker', '#CommentSort--SortPicker'],
          3: ['user', 'User dropdown', '#USER_DROPDOWN_ID'],
          4: ['headermoderate', 'Moderate (header)', '#Header--Moderation']
        },
        value: ['1', '2', '3', '4']
      });
      ftr.add_option({
        id: 'delay',
        label: "Delay",
        requires: "menus",
        type: 'radio',
        choices: {
          1: ['short', 'Short', 200],
          2: ['medium', 'Medium', 400],
          3: ['long', 'Long', 700]
        },
        value: ['2']
      });
      const menus_val = ftr.options.menus.value;
      const delay_uid = ftr.options.delay.value[0];
      const delay_open = ftr.options.delay.choices[delay_uid][2] || 700;

      if (ftr.enabled && ftr.options.menus.enabled) {
        function init() {
          for (let i = 0; i < menus_val.length; i++) {
            const uid = menus_val[i];
            if (uid !== null) {
              add_menu_listener(ftr.options.menus.choices[uid]);
            }
          }
        };
        function add_menu_listener(menu) {
          let commit_timeout;
          const name = menu[0];
          let el = GetEl(menu[2]);
          if (name === 'sortpicker') {
            el = el ? el.parentNode : undefined;
          }
          if (!el) return;
          el.addEventListener('mouseenter', (ev) => {
            const target = ev.currentTarget;
            commit_timeout = window.setTimeout(() => {
              target.click();
            }, delay_open);
          }, false);
          el.addEventListener('mouseleave', () => {
            window.clearTimeout(commit_timeout);
          }, false);
        };
        init();
        if (menus_val.includes('2')) {
          Lb_Comments_Obs.extend((self, node) => {
            add_menu_listener(ftr.options.menus.choices[2]);
          });
        }
      }
      return ftr;
    })();
    FT.remindme = (function () {
      const ftr = new Feature({
        id: "remindme",
        label: "Remind Me",
        enabled: false,
        hidden: true
      });
      return ftr;
    })();
    FT.debug = (function () {
      const ftr = new Feature({
        id: "debug",
        label: "Debug Fixdit",
        enabled: false
      });
      function init(enabled) {
        if (enabled) {
          document.body.classList.add('fixd_debug');
        } else {
          document.body.classList.remove('fixd_debug');
        }
      };
      ftr.on_toggle = init;
      init(ftr.enabled);
      return ftr;
    })();

    document.body.insertAdjacentHTML('beforeend', `<div id="fixd_launch"></div>`);
  });
})(window.jQuery.noConflict(true));