Fixdit for Reddit Redesign

UIX enhancements for Reddit's 2018 redesign. We <3 the redesign. Filter content, view hover cards for subreddits, & more...

当前为 2018-06-02 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Fixdit for Reddit Redesign
// @namespace    http://tampermonkey.net/
// @version      0.6.7
// @description  UIX enhancements for Reddit's 2018 redesign. We <3 the redesign. Filter content, view hover cards for subreddits, & more...
// @author       scriptpost (u/postpics)
// @match        https://www.reddit.com/*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// @noframes
// ==/UserScript==

(function ($, undefined) {
  $(function () {
    if (!$('#hamburgers').length) return; // probably using old site.

    // TODO:
    // Add a UI tweak to force comments to reload the page.
    // Extend debug: add button to reset storage.
    // Remove ability to disable internal/required features.
    // Remove ability to toggle options of type 'radio'.
    // Add notification each time settings are changed.
    // Find more observers to add.
    // Optimize all observers (only act on .addedNodes)
    // User tagging and highlighting (highlight with symbol?)
    // Add observer to comments that contain icon-expand, then add collapse button
    // Observer for switching from comment permalink to all comments.
    // Prevent settings from flashing on page load.
    // Try moving observers to bottom (after features) for simplicity.
    // Option to change the color of visited links.

    // CHANGELOG (latest):
    // Fix for content filter and strip_prefixes

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

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

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

          GM_setValue('features', JSON.stringify(db_entry));
          this.enabled = data.enabled;
        }

        this.feature = data;
        this.setting = Feature.add_setting(data);
        this.options = {};
      }

      static add_setting(data) {
        return new Setting(data);
      }

      add_option(option) {
        const loaded_options = this.loaded[this.feature.id].options;
        let is_stored = loaded_options.hasOwnProperty(option.id);
        const is_choices = option.hasOwnProperty('choices');

        // Check if outdated values are in storage and reset to new values.
        // TBDL: Remove these checks in v0.7.0
        if (is_stored && is_choices) {
          if (option.type === 'radio') {
            const is_outdated = isNaN(loaded_options[option.id].value);
            if (is_outdated) {
              is_stored = false;
            }
          }
          else if (option.type === 'checkbox') {
            const is_oudated = isNaN(loaded_options[option.id].value[0]);
            if (is_oudated) {
              is_stored = false;
            }
          }
        }

        if (is_stored) {
          // Modify passed object.
          option.enabled = loaded_options[option.id].enabled;

          if (loaded_options[option.id].hasOwnProperty('value')) {
            option.value = loaded_options[option.id].value;
          }
        }
        else {
          // Continue without modifying.
          const db_entry = this.loaded;
          db_entry[this.feature.id].options[option.id] = {};

          if (option.hasOwnProperty('value')) {
            db_entry[this.feature.id].options[option.id].value = option.value;
          }
          db_entry[this.feature.id].options[option.id].enabled = option.enabled;
          GM_setValue('features', JSON.stringify(db_entry));
          this.loaded = db_entry;
        }

        this.options[option.id] = new Option(option);
      }

      set toggle(fn) {
        this.setting.toggle = fn;
      }


      // arg: object
      set options_toggle(functions) {
        this.options_triggers = functions;
      }

      redraw() {
        // Finds all the changed options and updates their appearance.
        // Also calls any provided callback functions.
        this.setting.redraw();
        for (var oid in this.options) {
          const new_val = this.loaded.options[oid].enabled;
          const current_val = this.options[oid].enabled;

          if (new_val !== current_val) {
            const fid = this.feature.id;
            this.options[oid].enabled = new_val;

            const panel = '.fixd_options[data-id="' + fid + '"]';
            const dialog = '.fixd_dialog[data-id="' + oid + '"]';
            const $btn = $(panel + ' .fixd_option[data-id="' + oid + '"]');
            const $switch = $(dialog + ' .fixd_switch[data-id="' + oid + '"]');

            $btn.add($switch).each((idx, el) => {
              if (el.classList.contains('fixd_enabled')) {
                el.classList.remove('fixd_enabled');
              }
              else {
                el.classList.add('fixd_enabled');
              }
            });

            if (this.options_triggers &&
              this.options_triggers.hasOwnProperty(oid) &&
              this.setting.enabled) {
              this.options_triggers[oid](new_val);
            }
          }
        }
      }

      set update(data) {
        this.setting.enabled = data.enabled;
        this.loaded = data;
        this.redraw();
      }
    }

    class Setting {
      constructor(data) {
        for (let key in data) {
          this[key] = data[key];
        }

        this.button = $('<div>', {
          "text": data.label,
          "class": "fixd_option_btn",
          "data-click-id": "fixd_setting",
          "data-id": data.id
        })[0];

        if (data.enabled) {
          $(this.button).addClass('fixd_enabled');
        }
        if (!data.internal) {
          $('#fixd_settings .fixd_panel:not(.fixd_options)').append(this.button);
        }
      }

      redraw() {
        const el = $('.fixd_options .fixd_switch:not(.fixd_option)[data-id="' + this.id + '"]')[0];
        if (this.enabled) {
          $(el).add(this.button).addClass('fixd_enabled');
        }
        else {
          $(el).add(this.button).removeClass('fixd_enabled');
        }
        if (this.toggle) {
          this.toggle(this.enabled);
        }
      }
    }

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

    /**
     * Creates an observer wrapper that can be extended from anywhere.
     * Each time an RO is extended, it disconnects the last observer
     * and adds a new one in its place with all the added functions.
     * When there's a mutation and a condition is met, it loops over
     * all the saved functions (from this.actions).
     */
    class Reddit_Observer {
      constructor(target) {
        this.target = target;
        this.actions = [];
      }

      set callback(callback) {
        this.basis = callback;
      }

      get records() {
        if (this.observer) {
          return this.observer.takeRecords();
        }
      }

      loop_all(mutations) {
        for (var i = 0; i < this.actions.length; i++) {
          this.actions[i](this, mutations);
        }
      }

      extend(fn) {
        this.actions.push(fn);
        if (this.target) {
          this.connect();
        }
      }

      connect(newTarget) {
        if (newTarget) {
          this.target = newTarget;
        }
        else if (!this.target) {
          return console.warn("Can't connect observer because target node is undefined");
        }
        const self = this;
        const mutation = function (mutationsList) {
          self.basis(self, mutationsList);
        };
        if (this.observer) {
          this.observer.disconnect();
        }
        this.observer = new MutationObserver(mutation);
        this.observer.observe(this.target, { childList: true });
      }
    }

    const Util = {
      format_date: {
        age: date => {
          // https://stackoverflow.com/a/23286781
          const diff_date = new Date(new Date() - date);
          let y = diff_date.toISOString().slice(0, 4) - 1970;
          let m = diff_date.getMonth() + 0;
          let d = diff_date.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 => {
        const $context = $(node).find('div[data-click-id="body"] > div:nth-of-type(2)');
        const links = $context[0].getElementsByTagName('a');
        for (let i = 0; i < links.length; i++) {
          const href = links[i].getAttribute('href');
          if (href && href.startsWith('/user/')) {
            return links[i];
          }
        }
      }
    };

    const get_reddit_data = function (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.');
        }
      })
    };

    /**
     * Adds a visual indicator to show which mutations are occuring.
     */
    $('body').append($('<div id="fixd_indicators">').hide());
    class observer_indicator {
      constructor(name) {
        this.name = name;
        this.create();
      }

      create() {
        this.$el = $('<div class="fixd_observer_indicator" data-fixd-name="' + this.name + '">');
        $('#fixd_indicators').append(this.$el);
      }

      pulse() {
        this.$el.addClass('fixd_active');
        this.timeout = window.setTimeout(() => {
          this.$el.removeClass('fixd_active');
        }, 2000);
      }
    };

    /**
     * @param {string} html_tag Element to look inside.
     * @param {string} query Inner text to match against.
     * @param {Node} context Region to search.
     */
    var getElementByText = function (html_tag, query, context) {
      const result = document.evaluate(
        "//" + html_tag + "[contains(., '" + query + "')]",
        context, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
      return result;
    };

    const body_indicator = new observer_indicator('doc body');
    const viewframe_indicator = new observer_indicator('viewframe');
    const subreddit_indicator = new observer_indicator('subreddit');
    const overlay_indicator = new observer_indicator('overlay');
    const comment_indicator = new observer_indicator('comment');
    const comment_change_indicator = new observer_indicator('comment change');
    let post_indicator = new observer_indicator('post');

    /**
     * These special observers are defined according to Reddit's DOM behavior.
     * Each observer can be extended with .extend() and re-added with .connect().
     */
    const Body_Observer = new Reddit_Observer(document.body);
    const Viewframe_Observer = new Reddit_Observer($('#hamburgers + div')[0]);
    const Subreddit_Observer = new Reddit_Observer($('.Post').parent().parent().parent().parent().parent()[0]);
    const Overlay_Observer = new Reddit_Observer($('#hamburgers').parent().parent()[0]);
    const Comment_Observer = new Reddit_Observer($('.Comment').parent().parent().parent().parent()[0]);
    const Posts_Observer = new Reddit_Observer($('.Post').parent().parent().parent()[0]);

    const body_mutation_basis = function (self, mutations) {
      body_indicator.pulse();
      self.loop_all(mutations);
    };

    var viewframe_mutation_basis = function (self, mutations) {
      // When new data is fetched.
      viewframe_indicator.pulse();
      let ping = window.setInterval(() => {
        var $posts = $('.Post').parent().parent().parent();
        if ($posts.length) {
          window.clearInterval(ping);
          Subreddit_Observer.connect($posts.parent().parent()[0]);
          Posts_Observer.connect($posts[0]);
          Overlay_Observer.connect($('#hamburgers').parent().parent()[0]);
          self.loop_all(mutations);
        }
      }, 250);
    };

    var subreddit_mutation_basis = function (self, mutations) {
      // When loading from the cache.
      subreddit_indicator.pulse();
      let ping = window.setInterval(() => {
        var $posts = $('.Post').parent().parent().parent();
        if ($posts.length) {
          window.clearInterval(ping);
          Subreddit_Observer.connect($posts.parent()[0]);
          Posts_Observer.connect($posts[0]);
          self.loop_all(mutations);
        }
      }, 250);
    };

    const overlay_mutation_basis = function (self, mutationsList) {
      overlay_indicator.pulse();
      var ping = window.setInterval(() => {
        let comments = document.getElementsByClassName('Comment');
        if (comments.length) {
          window.clearInterval(ping);
          for (var i = 0; i < mutationsList.length; i++) {
            if (mutationsList[i].addedNodes.length) {
              let container = $(comments[0]).parent().parent().parent().parent()[0];
              Comment_Observer.connect(container);
              self.loop_all(mutationsList[i]);
            }
          }
        }
      }, 250);

      var timeout = window.setTimeout(() => {
        window.clearInterval(ping);
      }, 10000);
    };

    const comment_mutation_basis = function (self, mutations) {
      comment_indicator.pulse();
      self.loop_all(mutations);
    };

    var posts_mutation_basis = function (self, mutations) {
      post_indicator.pulse();
      self.loop_all(mutations);
    };

    Body_Observer.callback = body_mutation_basis;
    Viewframe_Observer.callback = viewframe_mutation_basis;
    Subreddit_Observer.callback = subreddit_mutation_basis;
    Overlay_Observer.callback = overlay_mutation_basis;
    Comment_Observer.callback = comment_mutation_basis;
    Posts_Observer.callback = posts_mutation_basis;

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

    const get_settings_form = function () {
      const layout = $('<div>', {
        "id": "fixd_settings",
        'data-version': GM_info.script.version
      }).append($('<div>', {
        "class": "fixd_panel"
      }).append($('<h1>', {
        "text": "Fixdit Settings"
      })))[0];
      return layout;
    };

    const draw_options_panel = function (fid) {
      const settings = $('#fixd_settings')[0];
      const feature = FT[fid];

      const setting_toggle = $('<label>', {
        "html": '<input type="checkbox"> On',
        "data-click-id": 'fixd_setting_toggle',
        "data-id": fid,
        "class": "fixd_switch"
      })[0];

      const panel = $('<div>', {
        "class": "fixd_panel fixd_options"
      })[0];

      const btn_back = $('<button>', {
        "data-click-id": "fixd_back",
        "class": "fixd_btn_back",
        "text": "Back"
      })[0];

      settings.appendChild(panel);

      panel.dataset.id = feature.setting.id;
      panel.appendChild(btn_back);
      panel.appendChild($('<h2>', {
        "text": feature.setting.label
      })[0]);
      panel.appendChild(setting_toggle);

      if (feature.setting.enabled) {
        setting_toggle.querySelector('input').setAttribute('checked', 'checked');
        setting_toggle.classList.add('fixd_enabled');
      }

      for (var oid in feature.options) {
        draw_option_item(panel, feature.options[oid]);
      }
    };

    const draw_option_item = function (panel, option) {
      const label = document.createTextNode(option.label);
      const row = $('<label>', {
        "class": "fixd_option",
        "data-id": option.id
      })[0];

      if (option.hidden) return;

      // Will it be a checkbox or button?
      if (!option.type || option.type !== 'bool') {
        row.dataset.clickId = "fixd_option_btn";
        row.appendChild($('<div>', {
          "class": "fixd_option_btn",
          "text": option.label
        })[0]);
        row.classList.add('fixd_option_btn');
        row.innerText = option.label;
      }
      else {
        row.classList.add('fixd_switch');
        row.dataset.clickId = "fixd_option_toggle";
        row.innerHTML = '<input type="checkbox"> ' + option.label;
      }
      if (option.enabled) {
        let checkbox = row.querySelector('input');
        if (checkbox) {
          checkbox.setAttribute('checked', 'checked');
        }
        row.classList.add('fixd_enabled');
      }
      panel.appendChild(row);
    };

    const append_option_selector = function (el, data, saved) {
      for (let uid in data.choices) {
        if (isNaN(uid)) {
          saved = data; // Reset outdated saved values.
        }
        const choice = data.choices[uid];
        const row = $('<label>', {
          'html': '<input type="checkbox"> ' + choice[1],
          'class': 'fixd_option_select'
        })[0];
        const input = row.querySelector('input');

        input.setAttribute('name', 'fixd_option_choices');
        input.setAttribute('value', uid);

        if (data.type === 'radio') {
          input.setAttribute('type', 'radio');
        }
        if (saved.value.includes(uid)) {
          input.setAttribute('checked', 'checked');
          row.classList.add('fixd_enabled');
        }
        el.appendChild(row);
      };
    };

    const draw_option_dialog = function (oid) {
      const panel = $('#fixd_settings .fixd_options')[0];
      const fid = $(panel).data('id');
      const saved = JSON.parse(GM_getValue('features', '{}'));
      const option = FT[fid].options[oid];
      const saved_option = saved[fid].options[oid];

      if (option.type === 'list') {
        saved_option.value.sort((a, b) => {
          return a.localeCompare(b, 'en', { 'sensitivity': 'base' });
        });
      }
      const dialog = panel.appendChild($('<div>', {
        "data-id": oid,
        "class": "fixd_dialog"
      })[0]);
      const dialog_content = dialog.appendChild($('<div>')[0]);
      const header = $('<div>', {
        "class": "fixd_settings_header"
      })[0];
      dialog_content.appendChild(header);
      header.appendChild($('<button>', {
        "data-click-id": "fixd_back",
        "class": "fixd_btn_back",
        "text": "Back"
      })[0]);
      header.appendChild($('<h3>', {
        "html": FT[fid].setting.label + '<span>' + option.label + '</span>'
      })[0]);
      const toggle = $('<label>', {
        "html": '<input type="checkbox"> On',
        "data-click-id": 'fixd_option_toggle',
        "data-id": option.id,
        "class": "fixd_switch"
      })[0];
      dialog_content.appendChild(toggle);
      if (saved_option.enabled) {
        toggle.classList.add('fixd_enabled');
        toggle.querySelector('input').setAttribute('checked', 'checked');
      }
      if (option.description) {
        dialog_content.appendChild($('<div>', {
          "class": "fixd_description",
          "html": option.description
        })[0]);
      }
      if (option.type === 'list') {
        dialog_content.appendChild($('<textarea>', {
          "data-click-id": "fixd_option_value",
          "text": saved_option.value.join('\n')
        })[0]);
      }
      else if (option.hasOwnProperty('choices')) {
        append_option_selector(dialog_content, option, saved_option);
      }
      const buttons = $('<div>', {
        "class": "fixd_settings_buttons"
      })[0];
      dialog_content.appendChild(buttons);
      buttons.appendChild($('<button>', {
        "data-click-id": "fixd_save",
        "class": "fixd_btn_save",
        "text": "Save"
      })[0]);
      $('#fixd_settings').addClass('fixd_expanded').append(dialog).css({});
    };

    const close_dialog = function () {
      $('#fixd_settings').removeClass('fixd_expanded');
      $('#fixd_settings .fixd_dialog').remove();
    };

    const handle_submit = function (ev) {
      const panel = $('#fixd_settings .fixd_options')[0];
      const fid = panel.dataset.id;
      const oid = document.getElementById('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 = $('#fixd_settings .fixd_dialog textarea').val();
        value = value.replace(/[^a-zA-Z\d\n#._-]/mg, "");
        db_entry[fid].options[oid].value = value.split('\n');
      }
      else if (option.hasOwnProperty('choices')) {
        const inputs = document.getElementsByName('fixd_option_choices');
        // Clear the old value before repopulating.
        db_entry[fid].options[oid].value = [];

        $(inputs).each((idx, el) => {
          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;
            }
          }
        });
      }
      GM_setValue('features', JSON.stringify(db_entry));
      close_dialog();
    };

    const handle_back = function (ev) {
      if (document.getElementsByClassName('fixd_dialog').length) {
        close_dialog();
      }
      else {
        $('#fixd_settings .fixd_options').remove();
        $('#fixd_settings .fixd_panel:not(.fixd_options)').show();
      }
    };

    const close_settings = ev => {
      $('#fixd_settings, #fixd_launch').removeClass('fixd_active');
      close_dialog();
    };

    $('body').append(get_settings_form);

    document.addEventListener('click', ev => {
      const path = ev.composedPath();
      const launcher = document.getElementById('fixd_launch');
      const settings = document.getElementById('fixd_settings');
      if (!path.includes(launcher) &&
        !path.includes(settings)) {
        close_settings();
        return;
      }
      ev.stopImmediatePropagation();

      if (ev.target.dataset.clickId === 'fixd_launcher') {
        if ($(ev.target).hasClass('fixd_active')) {
          close_settings();
        }
        else {
          $('#fixd_launch, #fixd_settings').addClass('fixd_active');
          $('#fixd_settings .fixd_options').remove();
          $('#fixd_settings .fixd_panel:not(.fixd_options)').show();
        }
      }
      if (ev.target.dataset.clickId === 'fixd_setting') {
        $('#fixd_settings .fixd_panel:not(.fixd_options)').hide();
        draw_options_panel(ev.target.dataset.id);
      }
      if (ev.target.dataset.clickId === 'fixd_option_btn') {
        const oid = ev.target.dataset.id;
        let selected = document.getElementById('fixd_selected_option');
        if (selected) {
          selected.removeAttribute('id');
        }
        ev.target.id = 'fixd_selected_option';
        draw_option_dialog(oid);
      }
      if (ev.target.dataset.clickId === 'fixd_save') {
        handle_submit(ev);
      }
      if (ev.target.dataset.clickId === 'fixd_back') {
        handle_back(ev);
      }
      if ($(ev.target).hasClass('fixd_switch') || $(ev.target.parentNode).hasClass('fixd_switch')) {
        let fid = $('#fixd_settings .fixd_options').data('id');
        let oid = ev.target.dataset.id || ev.target.parentNode.dataset.id;
        if (!ev.target.dataset.id) {
          // Checkbox has been clicked by user or label.
          handle_settings_switch(ev, fid, oid);
        }
      }
      if ($(ev.target).hasClass('fixd_option_select') ||
        $(ev.target.parentNode).hasClass('fixd_option_select')) {
        if (ev.target.tagName === 'INPUT') {
          if (ev.target.getAttribute('type') === 'radio') {
            let choices = document.getElementsByName('fixd_option_choices');
            $(choices).parent().removeClass('fixd_enabled');
          }
          ev.target.parentNode.classList.toggle('fixd_enabled');
        }
      }
    });

    const handle_settings_switch = function (ev, fid, oid) {
      const saved = JSON.parse(GM_getValue('features', '{}'));
      const feature = FT[fid];
      const clicked = ev.target.dataset.clickId || ev.target.parentNode.dataset.clickId;

      let db_entry = saved;
      if (clicked === 'fixd_setting_toggle') {
        if (saved[fid].enabled) {
          db_entry[fid].enabled = false;
        }
        else {
          db_entry[fid].enabled = true;
        }
      }
      else {
        let option = feature.options[oid];
        if (saved[fid].options[oid].enabled) {
          db_entry[fid].options[oid].enabled = false;
        }
        else {
          db_entry[fid].options[oid].enabled = true;
        }
      }
      GM_setValue('features', JSON.stringify(db_entry));
      feature.update = db_entry[fid];
    }

    /**
     * Begin modules/features.
     */
    const FT = {};
    FT.ui_selectors = (() => {
      var feature = new Feature({
        id: "ui_selectors",
        label: "Add UI Selectors",
        enabled: true,
        internal: true
      });
      const tag_subreddits_boxes = (arg) => {
        let context = arg;
        $(context).find('button').each((idx, el) => {
          if (['SUBSCRIBE', 'UNSUBSCRIBE'].includes(el.innerText.toUpperCase())) {
            const item = el.parentNode.parentNode;
            // TBDL: img/svg selectors should be more precise.
            const img = item.getElementsByTagName('img');
            const svg = item.getElementsByTagName('svg');
            if (img.length === 1 || svg.length === 1) {
              let $h3 = $(item).parent().parent().find('> h3');
              if ($h3.length === 1) {
                $(item).parent().parent().parent().parent().addClass('fixd--subreddits');
                return false;
              }
            }
          }
        });
        context = $('.fixd--subreddits').filter(':last').next()[0];
        let button_text = $(context).find('button:first').text().toUpperCase();
        if (['SUBSCRIBE', 'UNSUBSCRIBE'].includes(button_text)) {
          // Repeat
          tag_subreddits_boxes(context);
        }
      };

      var init = function () {
        let $page, $content, $side, posts;
        let lightbox = document.getElementById('lightbox');

        if (!lightbox) {
          let comment_sort_pick = document.getElementById('CommentSort--SortPicker');
          $page = $('#hamburgers + div').addClass('fixd--page');
          posts = document.getElementsByClassName('Post');
          if (!comment_sort_pick) {
            $content = $(posts[0]).parent().parent().parent().parent().parent();
          }
          else {
            $content = $(posts[0]).parent().parent().parent();
          }
        }
        else {
          $content = $(lightbox).find('.Post').parent().parent();
        }

        $content.addClass('fixd--content');
        $side = $content.find('+ div').addClass('fixd--side');

        if ($side.length) {
          const moderators_h3 = getElementByText('h3', 'Moderators', $side[0]).iterateNext();
          $(moderators_h3).parent().addClass('fixd--moderators');
          tag_subreddits_boxes($side[0]);
        }
      };

      if (feature.setting.enabled) {
        init();
        Overlay_Observer.extend(function (self, mutations) {
          init();
        });
        Viewframe_Observer.extend(function (self, mutations) {
          init();
        });
        Subreddit_Observer.extend(function (self, mutations) {
          init();
        });
      }

      return feature;
    })();

    FT.ui_tweaks = (() => {
      var feature = new Feature({
        id: "ui_tweaks",
        label: "UI Tweaks",
        enabled: false
      });
      feature.add_option({
        id: "no_prefix",
        label: "No prefixes for subreddits, users",
        type: "bool",
        enabled: false
      });
      feature.add_option({
        id: "no_blanks",
        label: "All links can open in current tab",
        type: "bool",
        enabled: false
      });
      feature.add_option({
        id: "override_vote_icons",
        label: "Override custom vote icons",
        enabled: false,
        type: "bool"
      });
      feature.add_option({
        id: "static_header",
        label: "Make the top bar stationary",
        enabled: false,
        type: "bool",
        update: 'test' // TBDL: function to be called when option is modified
      });
      feature.add_option({
        id: "reduce_comment_spacing",
        label: "Reduce comment spacing",
        enabled: false,
        type: 'bool'
      });

      const init_override_vote_icon = function (el) {
        if (!el.childNodes.length) {
          // Remove style override so we can search the url string for active/inactive
          el.style.background = '';
          const computed_style = window.getComputedStyle(el);
          const bg_value = computed_style.getPropertyValue('background-image');
          const class_list = ['fixd_no_icon'];

          if (bg_value.includes('IconInactive')) {
            class_list.push('fixd_inactive');
          }
          else if (bg_value.includes('IconActive')) {
            class_list.push('fixd_active');
          }
          $(el).addClass(class_list.join(' '));
          el.style.background = 'none';
        }
      };

      const init_override_vote_icons = function (context) {
        if (feature.options.override_vote_icons.enabled) {
          const selector = 'button[data-click-id="upvote"], button[data-click-id="downvote"]';
          $(context).find(selector).each((idx, el) => {
            init_override_vote_icon(el);
          });
        }
      };

      const strip_prefixes = function (collection) {
        if (feature.options.no_prefix.enabled) {
          if (!collection) {
            collection = $('.Post');
          }
          $(collection).each((idx, el) => {
            let a = Util.get_post_author_link(el);
            if (a) {
              a.innerText = a.innerText.replace("u/", "");
            }
          });
          $(collection).each((idx, el) => {
            let $a = $(el).find('a[data-click-id="subreddit"]');
            $a.each((idx, el) => {
              if (!el.children.length) {
                el.innerText = el.innerText.replace("r/", "");
              }
            });
          });
        }
      };

      const remove_blanks = function () {
        if (feature.options.no_blanks.enabled) {
          $('a[target="_blank"]').removeAttr('target');
        }
      };

      const static_header = (enabled) => {
        if (enabled === undefined || enabled) {
          document.querySelector('body > div #header').style.position = 'absolute';
        } else {
          document.querySelector('body > div #header').style.position = '';
        }
      };

      const reduce_comment_spacing = (enabled) => {
        if (enabled) {
          document.body.classList.add('fixd_reduce_comment_spacing');
        }
        else {
          document.body.classList.remove('fixd_reduce_comment_spacing');
        }
      }

      feature.options_toggle = {
        'static_header': static_header,
        'reduce_comment_spacing': reduce_comment_spacing
      };

      if (feature.setting.enabled) {
        static_header(feature.options.static_header.enabled);
        reduce_comment_spacing(feature.options.reduce_comment_spacing.enabled);

        Body_Observer.extend(function (self, mutations) {
          init_override_vote_icons(document.body);
          remove_blanks();
          strip_prefixes();
        });

        Overlay_Observer.extend((self, mutations) => {
          init_override_vote_icons(document.getElementById('lightbox'));
          remove_blanks();
          strip_prefixes();
        });

        if (feature.options.override_vote_icons.enabled) {
          $(document).click(ev => {
            if (['upvote', 'downvote'].includes(ev.target.dataset.clickId)) {
              init_override_vote_icons(ev.target.parentNode);
            }
          });
        }

        Posts_Observer.extend(function (self, mutations) {
          var collection = [];

          for (var record in mutations) {
            let added_nodes = mutations[record].addedNodes;
            if (added_nodes.length === 1) {
              collection.push(added_nodes[0]);
            }
          }
          if (collection.length) {
            strip_prefixes(collection);
            remove_blanks();
          }
        });
      }

      return feature;
    })();
    FT.filter_content = (function () {
      const feature = new Feature({
        id: "filter_content",
        label: "Filter Content",
        enabled: true
      });
      feature.add_option({
        id: "subreddits",
        label: "Posts by subreddit",
        type: "list",
        enabled: true,
        description: "One subreddit name per line. No commas or slashes. Ignores search results.",
        value: []
      });
      feature.add_option({
        id: "users",
        label: "Posts by user",
        description: "One user name per line. No commas or slashes. Ignores search results.",
        type: "list",
        enabled: false,
        value: []
      });
      feature.add_option({
        id: "comments",
        label: "Comments by user",
        description: "One user name per line. No commas or slashes.",
        type: "list",
        enabled: false,
        value: []
      });

      const blocked_subs = feature.options.subreddits;
      const blocked_submitters = feature.options.users;
      const blocked_comments = feature.options.comments;

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

      const is_match = function (query, list) {
        let result = list.findIndex(item => query.toUpperCase() === item.toUpperCase());
        if (result !== -1) {
          return true;
        }
      };

      const init_posts = function () {
        const $posts = $('.Post').parent().parent();

        const search_page = (() => {
          const h1 = $('.fixd--page h1')[0];
          const child = (h1) ? h1.firstChild : undefined;

          if (child && child.nodeValue) {
            return child.nodeValue.includes('Search results for');
          }
        })();

        if ($posts.length > 1 && !search_page) {
          // If length is 1, it's probably comments page.
          $posts.each((idx, el) => {
            filter_post(el);
          });
        }
      };

      const init_comments = function () {
        const $comments = $('.Comment').parent().parent().parent();
        const lightbox = document.getElementById('lightbox');

        $comments.each((idx, el) => {
          filter_comment(el);
        });
      };

      const filter_post = function (node) {
        let sub_href, user_href;
        if (feature.options.subreddits.enabled) {
          sub_href = $(node).find('.Post a[data-click-id="subreddit"]:first').attr('href');
        }
        if (feature.options.users.enabled) {
          let user_link = Util.get_post_author_link(node);
          if (user_link) {
            user_href = user_link.getAttribute('href');
          }
        }

        if (sub_href) {
          let sub_name = sub_href.replace(regex.url_subreddit, "").replace("/", "");

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

          if (is_match(user_name, blocked_submitters.value)) {
            node.classList.add('fixd_hidden');
          }
        }
      };

      const filter_comment = function (node) {
        let user_link = $(node).find('.Comment > div:nth-of-type(2) > div > div > a:first').attr('href');
        let is_hidden = !!node.getElementsByClassName('icon-expand').length;
        if (!user_link || is_hidden) return false;

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

        if (is_match(user_name, blocked_comments.value)) {
          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 = document.getElementById(cid);

          $(c_node).append($('<div>', {
            'html': "<span>" + user_name + "</span> <em>(Fixdit filtered)</em>",
            'class': "fixd_filter_msg"
          }).append($('<button>', {
            'text': 'Show comment',
            'data-click-id': "fixd_unfilter_btn"
          })));
        }
      };

      const handle_posts_mutation = function (mutation) {
        const nodes_array = Array.from(mutation.addedNodes);

        nodes_array.forEach((node) => {
          filter_post(node);
        });
      };

      const handle_comments_mutation = function (mutations) {
        mutations.forEach(function (mutation) {
          for (var i = 0; i < mutation.addedNodes.length; i++) {
            const nodes_array = Array.from(mutation.addedNodes);

            nodes_array.forEach((node) => {
              filter_comment(node);
            });
          }
        });
      };

      const handle_body_click = (ev) => {
        let clicked = ev.target;
        if (clicked.dataset.clickId === 'fixd_unfilter_btn') {
          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 (feature.setting.enabled) {
        if (feature.options.comments.enabled) {
          init_comments();
          document.body.addEventListener('click', handle_body_click, false);

          Overlay_Observer.extend(function (self, mutations) {
            init_comments();
          });

          Comment_Observer.extend(function (self, mutations) {
            handle_comments_mutation(mutations);
          });
        }
        if (feature.options.subreddits.enabled || feature.options.users.enabled) {
          init_posts();

          Posts_Observer.extend(function (self, mutations) {
            mutations.forEach(function (mutation) {
              for (var i = 0; i < mutation.addedNodes.length; i++) {
                handle_posts_mutation(mutation);
              }
            });
          });

          Viewframe_Observer.extend(function (self, mutations) {
            let $posts = $('.Post').parent().parent().parent();
            init_posts();
          });

          Subreddit_Observer.extend(function (self, mutations) {
            let $posts = $('.Post').parent().parent().parent();
            init_posts();
          });
        }
      }

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

      const get_popup = function (ev) {
        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 = $('#fixd_popup_subreddit').data('id');
            let db_entry = saved;
            const arr = saved.filter_content.options.subreddits.value;
            if (ev.target.classList.contains('fixd_active')) {
              const idx = arr.indexOf(name);
              db_entry.filter_content.options.subreddits.value.splice(idx);
              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('mouseout', ev => {
          const contains_target = !!$(box).find(ev.target).length;
          const contains_related = !!$(box).find(ev.relatedTarget).length;
          if (!contains_related && !contains_target) {
            close_popup(ev);
          }
        }, false);
        return box;
      };

      const add_popup = (data, ev) => {
        const box = get_popup(ev);
        const classes = [];
        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) {
          classes.push('fixd_subscriber');
        }
        if (saved.filter_content.enabled && filter_data.enabled) {
          classes.push('fixd_filterable');
          if (filter_data.value.includes(data.name)) {
            filter_btn_classes.push('fixd_active');
          }
        }
        let document_width = $('html')[0].offsetWidth;
        let target_offset = $(ev.target).offset().left;
        let offset_left = target_offset;
        if ((document_width - target_offset) < (document_width / 2)) {
          offset_left -= 240;
        }
        $(box)
          .attr('data-id', data.name)
          .addClass(classes.join(' '))
          .css({
            top: $(ev.target).offset().top + ev.target.offsetHeight,
            left: offset_left,
            display: ''
          })
          .append($('<div>')
            .append($('<h2>', {
              "class": "fixd_popup_name",
              "text": data.name
            }))
            .append($('<div>', {
              "class": "fixd_popup_created",
              "html": data.created
            }))
            .append($('<div>', {
              "class": "fixd_popup_subs",
              "html": '<span>' + data.subs + '</span> Subscribers'
            }))
            .append($('<button>', {
              "class": filter_btn_classes.join(' '),
              "text": 'Filter'
            }))
          )
          .append($('<div>')
            .append($('<div>', {
              "class": "fixd_popup_title",
              "text": data.title
            }))
            .append($('<div>', {
              "class": "fixd_popup_subtitle",
              "text": data.subtitle
            }))
            .append($('<div>', {
              "class": "fixd_popup_desc",
              "html": data.desc
            }))
          );
        document.body.appendChild(box);
      };

      const init_popup = function (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: Util.format_date.age(date_created),
            subs: data.subs.toLocaleString(),
            subscriber: data.subscriber,
            desc: data.desc
          };
          add_popup(formatted, ev);
        }, function (error) {
          console.warn("Error retrieving subreddit info popup.", error);
        });
      };

      const close_popup = (ev) => {
        $('#fixd_popup_subreddit').remove();
      };

      if (feature.setting.enabled) {
        let mouse_over = 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('mouseout', mouse_out);
                window.clearTimeout(tmo_open);

                tmo_close = window.setTimeout(() => {
                  close_popup();
                }, delay_close);
              };

              a.addEventListener('mouseout', mouse_out, false);
            }
            else if (!tmo_close && document.getElementById('#fixd_popup_subreddit')) {
              close_popup();
            }
          }
        }, false);
      }
      return feature;
    })();
    FT.comments_collapse = (function () {
      const feature = new Feature({
        id: "comments_collapse",
        label: "Collapsible Child Comments",
        enabled: true
      });
      feature.add_option({
        id: 'auto',
        type: 'bool',
        label: 'Automatically collapse children',
        enabled: false
      });

      const auto_on = feature.options.auto.enabled;

      const init = function (is_mutate) {
        let comment = document.getElementsByClassName('Comment')[0];
        let comments_list;

        if (comment) {
          comments_list = comment.parentNode.parentNode.parentNode.parentNode.childNodes;

          const sort_picker = $('#CommentSort--SortPicker').parent()[0];
          const btn_all_classList = ['fixd_collapse_all'];

          if (auto_on && !is_mutate) {
            btn_all_classList.push('fixd_active');
          }

          const $btn_all = $('<button>', {
            "text": "children",
            "class": btn_all_classList.join(' ')
          });

          if (sort_picker) {
            $(sort_picker).append($btn_all);
          }

          for (var i = 0; i < comments_list.length; i++) {
            init_comment(comments_list[i], is_mutate);
          }
        }
      };

      const init_comment = function (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_thread = !!item.getElementsByClassName('threadline').length;
        const is_hidden = !!item.getElementsByClassName('icon-expand').length;

        if (is_comment && !is_child) {
          $(item).addClass('fixd_top-level');
          const $next = $(item).next();
          const next_is_child = $next.length && !$next[0].getElementsByClassName('top-level').length;
          const next_is_thread = $next.length && !!$next[0].getElementsByClassName('threadline').length;

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

            if (auto_on && !is_mutate) {
              btn_classList.push('fixd_active');
            }

            const $btn = $('<button>', {
              "text": "children",
              "class": btn_classList.join(' ')
            });

            let $target = $(item).find('button:last');

            if (!$target.length) {
              $target = $(item).find('a:last');
            }

            $btn.insertAfter($target);
          }
        }
        if (auto_on && is_child && !is_mutate && !is_hidden) {
          item.classList.add('fixd_hidden');
        }
      };

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

        const check_next = function ($next) {
          const next_is_child = !$next[0].getElementsByClassName('top-level').length;
          const next_is_thread = !!$next[0].getElementsByClassName('threadline').length;
          if (next_is_child && next_is_thread) {
            if (is_active) {
              $next.removeClass('fixd_hidden');
            }
            else {
              $next.addClass('fixd_hidden');
            }
            if ($next.next().length) {
              check_next($next.next());
            }
          }
        };

        check_next($(wrap).next());

        if (is_active) {
          $(clicked).removeClass('fixd_active');
        } else {
          $(clicked).addClass('fixd_active');
        }
      };

      const handle_click = function (ev) {
        if ($(ev.target).hasClass('fixd_collapse')) {
          ev.stopImmediatePropagation();
          toggle_visibility(ev.target);
        }
        else if ($(ev.target).hasClass('fixd_collapse_all')) {
          ev.stopImmediatePropagation();
          if ($(ev.target).hasClass('fixd_active')) {
            $('.fixd_collapse.fixd_active').click();
          }
          else {
            $('.fixd_collapse:not(.fixd_active)').click();
          }
          if ($('.fixd_collapse:not(.fixd_active)').length) {
            $(ev.target).removeClass('fixd_active');
          }
          else {
            $(ev.target).addClass('fixd_active');
          }
        }
      };

      const handle_mutation = function (mutation) {
        init_comment(mutation.previousSibling, true);
      };

      if (feature.setting.enabled) {
        init();
        $(document).on('click', handle_click);

        Overlay_Observer.extend(function (self, mutations) {
          init(true);
        });

        Comment_Observer.extend(function (self, mutations) {
          for (var a = 0; a < mutations.length; a++) {
            handle_mutation(mutations[a]);
          }
        });
      }

      return feature;
    })();
    FT.menu_hover = (function () {
      var feature = new Feature({
        id: "menu_hover",
        label: "Hover to open menus",
        enabled: false
      });
      feature.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']
      });
      feature.add_option({
        id: 'delay',
        label: "Delay",
        enabled: true,
        requires: "menus",
        type: 'radio',
        choices: {
          1: ['short', 'Short', 200],
          2: ['medium', 'Medium', 400],
          3: ['long', 'Long', 700]
        },
        value: ['2']
      });
      const menus_val = feature.options.menus.value;
      const delay_uid = feature.options.delay.value[0];
      const delay_open = feature.options.delay.choices[delay_uid][2] || 400;

      if (feature.setting.enabled) {
        var add_menu_listeners = function (selector) {
          var commit_timeout;
          $(selector).on('mouseover', function (ev) {
            commit_timeout = window.setTimeout(() => {
              ev.currentTarget.click();
            }, delay_open);
          });
          $(selector).on('mouseout', function (ev) {
            window.clearTimeout(commit_timeout);
          });
        };

        for (let i = 0; i < menus_val.length; i++) {
          const uid = menus_val[i];
          const menu = feature.options.menus.choices[uid][2];
          add_menu_listeners(menu);
        }
        if (menus_val.includes('2')) {
          Overlay_Observer.extend((self, mutations) => {
            add_menu_listeners(feature.options.menus.choices[2][2]);
          });
        }
      }

      return feature;
    })();

    $('body').append('<div id="fixd_launch" data-click-id="fixd_launcher"></div>');

    FT.remindme = (function () {
      const feature = new Feature({
        id: "remindme",
        label: "Remind Me",
        enabled: false,
        internal: true
      });
      feature.add_option({
        id: "post",
        label: "Posts",
        enabled: false,
        type: "list"
      });
      feature.add_option({
        id: "comment",
        label: "Comments",
        enabled: false,
        type: "list"
      });
      const show_menu = function (ev, target, menu) {
        menu.display(ev, target, 'left');
      };

      return feature;
    })();
    FT.debug = (function () {
      var feature = new Feature({
        id: "debug",
        label: "Debug Fixdit",
        enabled: false
      });

      const init = (enabled) => {
        if (enabled) {
          $('#fixd_indicators').show();
          document.body.classList.add('fixd_debug');
        }
        else {
          $('#fixd_indicators').hide();
          document.body.classList.remove('fixd_debug');
        }
      };

      feature.toggle = init;
      init(feature.setting.enabled);
      return feature;
    })();

    const _stylesheet = String.raw`<style type="text/css" id="fixdit">.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_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{margin:12px 0 0;line-height:1.3}#fixd_settings h2,#fixd_settings h3{font-weight:400;display:inline-block;margin-bottom:10px;vertical-align:middle}#fixd_settings h1{font-size:140%;font-weight:400;margin:0 0 .5em}#fixd_settings h2{font-size:120%}#fixd_settings h3{font-size:100%}#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:#e4e6e6}#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:#f2f2f2}#fixd_settings .fixd_switch.fixd_enabled:not(.fixd_option){color:#000;background:#fff}#fixd_settings .fixd_dialog,#fixd_settings .fixd_dialog>div{position:absolute;top:0;left:0;bottom:0;border-radius:4px;background:hsla(180,2%,90%,1);right:0}#fixd_settings .fixd_option span{padding-left:.3em}#fixd_settings .fixd_dialog{content:""}#fixd_settings .fixd_dialog>div{padding:24px 12px;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;margin-top:12px}.fixd_settings_buttons{margin-top:12px}#fixd_settings button{text-transform:uppercase;border:1px solid transparent;border-radius:2px;vertical-align:middle}#fixd_settings button:hover{border:1px solid #666}#fixd_settings .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:before{color:#000;content:"< "}#fixd_settings .fixd_btn_save{color:#fff;padding:8px 24px;background:#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_indicators{position:fixed;top:40px;right:0;z-index:100}.fixd_observer_indicator{box-sizing:border-box;margin:5px 0 0;width:20px;height:15px;border:1px solid hsla(0,0%,0%,.2);background:hsla(0,0%,50%,.2);transition:all .3s}.fixd_observer_indicator.fixd_active{border-color:hsla(0,0%,0%,1);background:hsla(0,0%,100%,1)}.fixd_observer_indicator.fixd_active:before,.fixd_observer_indicator:hover:before{content:attr(data-fixd-name);display:block;position:absolute;margin-top:-1px;right:100%;white-space:nowrap;color:#fff;font-size:11px;padding:3px 6px;background:hsla(0,0%,0%,.4);pointer-events:none}.fixd_observer_indicator:hover:before{background:hsla(0,0%,0%,.8)}.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_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}body.fixd_debug .fixd_hidden{opacity:.6}body.fixd_debug .fixd_filtered{outline:#dc143c solid 1px}body.fixd_debug .fixd_unfiltered{outline:green solid 1px}.fixd_filter_msg{font-size:12px;color:#878a8c}.fixd_filter_msg em{font-style:italic}.fixd_filter_msg button{content:"Show comment";color:inherit;background:0 0;cursor:pointer;margin:12px 0 0 12px}button.fixd_no_icon.fixd_active[data-click-id=upvote]{color:#f40!important}button.fixd_no_icon.fixd_active[data-click-id=downvote]{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:"\F12A"}button.fixd_no_icon[data-click-id=downvote]:before{content:"\F107"}body.fixd_reduce_comment_spacing .Comment.top-level{margin-top:0}</style>`;
    $('body').append(_stylesheet);

  });
})(window.jQuery.noConflict(true));