FastTech Ignore List (BETA)

Ignore users on the FastTech forums, and more

目前为 2016-01-12 提交的版本。查看 最新版本

// ==UserScript==
// @name        FastTech Ignore List (BETA)
// @namespace   ftil
// @description Ignore users on the FastTech forums, and more
// @include     https://*.fasttech.com/*
// @version     2.1.0
// @grant       GM_getValue
// @grant       GM_setValue
// @run-at      document-end
// @icon        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuNvyMY98AAAFvSURBVFjD7Zc9S8NAGMcDvnSUggjFkoTiJA6u+gGk2Nmhg4Ozk10Ul+s5lg65l95LxC8QaC/Uoh/Fwc3RyW9Q79RIK5hwGI1iDv4QAnfP7//cc8k9jlP0wFgEGHNpLco5QqiygW4q/oXiXnckrQVV4BAiOlpTQu2ECO8lJvRCPQ/GU2uBUSfJQsu4QkSEWdLOTQbaHzOpHbX1osZVmMiF6u4t2MPse+PehXHr27f3NWA89aG6LaS+/gZAwNiWLqpHW2Ei7s0p+DIApeG27QnAhD8NBmIvlwzMARBxhKhopgqJJmNsLbctmAVg7Krx4zVQApQAUsolhMK6EQBgMU8A92xSXQfDeg2MV60n66NWNVD9vkyffBAtmCBGmyBazu+eQPnly0eHiutUl+fDWvJ7dYHaLQFKgBKgBPjfAB4c7xQL0I3juQYkW0HOGciW//6sDPDJpwsjyg5NG6YvqsdpAI3TaMW+KY2FB9S+8xvGM2SF001PVSN6AAAAAElFTkSuQmCC
// ==/UserScript==
'use strict';
var settings = (function() {
  var settings = { };
  var watches = { };

  function inner_set(name, value) {
    if (settings[name].value === value)
      return false;
    settings[name].value = value;
    if (name in watches) {
      var w = watches[name];
      for (var i = 0; i < w.length; i++)
        w[i](name, value);
    }
    return true;
  }

  window.addEventListener('storage', function(e) {
    if (e.key in settings)
      inner_set(e.key, JSON.parse(e.newValue));
  });

  return {
    get: function(name) { return settings[name].value },

    set: function(name, value) {
      if (inner_set(name, value))
        localStorage.setItem(name, JSON.stringify(value));
    },

    all: function() { return settings },

    register: function(name, desc, def, dep) {
      var val = localStorage.getItem(name);
      if (val === null)
        val = GM_getValue(name, null);
      if (val !== null)
        val = JSON.parse(val);
      else
        val = def;
      settings[name] = {
        value: val,
        desc: desc,
        dep: dep,
      };
      this.set(name, val);
    },

    watch: function(name, func) {
      if (!(name in watches))
        watches[name] = [];
      watches[name].push(func);
      if (settings[name] !== undefined)
        func(name, settings[name].value);
    },
  };
})();
// Workaround for AMO warnings about javascript in innerHTML
function fix_as(e) {
  var as = e.getElementsByTagName('a');
  for (var i = 0; i < as.length; i++) {
    if (!as[i].hasAttribute('href'))
      as[i].href = 'javascript:void(0)';
  }
}

// Sanitize external values that may be fed into an innerHTML
var strip_tags = (function() {
  var div = document.createElement('div');
  return function(t) {
    div.innerHTML = t;
    return div.textContent;
  }
})();
/* Block functions that need the entire state set up before running.  In
 * particular, this is used by post_filter to avoid repeatedly filtering posts.
 */
var block = (function() {
  var block = 1;
  var watches = [];
  return {
    get: function() { return block !== 0 },
    block: function() { block++ },

    unblock: function() {
      if (!(--block)) {
        for (var i = 0; i < watches.length; i++)
          watches[i]()
          delete this.block
          delete this.unblock;
        this.watch = function(func) { func() };
      }
    },

    watch: function(func) { watches.push(func) },
  };
})();
// Assorted generic handlers for user input.
var handlers = (function() {
  return {
    // A click on elm toggles the ignored state of user
    ignore_toggle: function(elm, user) {
      elm.setAttribute('username', strip_tags(user));
      elm.addEventListener('click', function() { ignore_list.set(user) });
    },

    // A click on elm toggles the state of setting; setting changes update elm
    checkbox: function(elm, setting) {
      elm.addEventListener('change',
          function() { settings.set(setting, this.checked) });
      settings.watch(setting, function(name, val) {
        elm.checked = val;
      });
    },

    // Handle enter keypress on manage_menu's text box
    addbox_enter: function(elm) {
      elm.addEventListener('keydown', function(k) {
        if (k.which !== 13)
          return true;

        k.stopPropagation();
        k.preventDefault();

        ignore_list.set(strip_tags(this.value), true);
        this.value = '';

        return false;
      });
    },

    // Handle click on manage_menu's add button
    addbox_click: function(elm, tgt) {
      elm.addEventListener('click', function() {
        ignore_list.set(strip_tags(tgt.value), true);
        tgt.value = '';
      });
    },

    top_click: function(elm) {
      elm.addEventListener('click', function() { scrollTo(0, 0) });
    },

    hide_toggle: function(elm, post) {
      elm.addEventListener('click', function() { unhide_posts.set(post) });
    },
  };
})();
// Manage the state of the ignore list
var ignore_list = (function() {
  settings.register('ignorelist', null, [], null);

  // Use two stages so that filters can handle updates before being called
  var watches = { 'early': [], 'late': [] };
  var il = [];

  // Case-insensitive indexOf (needed in case of case errors in user input)
  function ci_indexof(a, n) {
    for (var i = 0; i < a.length; i++) {
      if (a[i].toLowerCase() === n.toLowerCase())
        return i;
    }
    return -1;
  }

  function fire_watches(stage, user, state, idx) {
    var w = watches[stage];
    for (var i = 0; i < w.length; i++)
      w[i](user, state, idx);
  }

  // In order to support userscripts, we need to rebuild the list on every set
  function inner_set(user, state, idx) {
    var nl = [];
    var tmp = settings.get('ignorelist');
    for (var i = 0; i < tmp.length; i++)
      nl.push(tmp[i]);
    if (state)
      nl.push(user);
    else
      nl.splice(idx, 1);
    settings.set('ignorelist', nl);
  }

  // Watch for ignorelist changes, and fire incremental watch events
  settings.watch('ignorelist', function(name, val) {
    var i = 0, j = 0;
    while (true) {
      if (il[i] === undefined && val[j] === undefined)
        break;
      if (il[i] !== val[j]) {
        var user, state, idx;
        if (il[i] === undefined) {
          // New entry @ end of list: user added
          user = val[j];
          il.push(user);
          state = true;
          idx = i;
        } else {
          // Missing entry inside list: user removed
          user = il[i];
          il.splice(i, 1);
          state = false;
          idx = i;
        }
        fire_watches('early', user, state, idx);
        fire_watches('late', user, state, idx);
        // Userscript compat: handle multiple changes from other tabs
        continue;
      }
      i++;
      j++;
    }
  });

  return {
    // Get ignored state of user (true == ignored)
    get: function(user) { return (ci_indexof(il, user) !== -1) },

    // Set ignored state of user (undefined == toggle)
    set: function(user, state) {
      var idx = ci_indexof(il, user);
      if (state === undefined)
        state = (idx === -1);
      if ((state && (idx === -1)) || (!state && (idx !== -1)))
        inner_set(user, state, idx);
    },

    /* Watch the ignorelist for changes (stage == ('early'|'late')).
     * func may be called with a single user and a new state, or an array of
     * users (with implicit ignored state for all)
     */
    watch: function(stage, func) {
      watches[stage].push(func);
      if (il !== [])
        func(il, true, -1);
    },
  };
})();
/* Core post filtering logic
 * post vars are expected to be in the format
 * { user: string, content_elm: element, post_elms: [element] }
 */
var post_filter = (function() {
  var posts = [];
  var watches = [];
  var filters = [];

  function update() {
    if (block.get()) return;

    for (var i = 0; i < posts.length; i++) {
      var post = posts[i];
      var hide = false;
      for (var j = 0; j < filters.length; j++) {
        var tmp = filters[j](post);
        if (tmp === true) {
          hide = true;
        } else if (tmp === false) {
          hide = false;
          break;
        }
      }

      var display = hide ? 'none' : '';
      for (var j = 0; j < post.post_elms.length; j++)
        post.post_elms[j].style.display = display;

      for (var j = 0; j < watches.length; j++)
        watches[j](post, hide);
    }
  }

  return {
    // Register a list of posts to be filtered
    register: function(list) {
      posts = list;
      block.watch(update);
    },

    /* Add a new filter.  Must be called before the unblock.  Filters return
     * true (should hide), false (must not hide), or undefined (no judgement).
     */
    filter: function(func) { filters.push(func) },
    // Run all filters again
    update: update,

    /* Call func with changes to the hidden status of each post (true ==
     * hidden).  Must be called before the unblock.
     */
    watch: function(func) { watches.push(func) },
  };
})();

// General-purpose built-in filters:
// Hide posts by ignored users
post_filter.filter((function() {
  settings.watch('hide_ignored', post_filter.update);
  settings.register('hide_ignored', 'Hide posts by ignored users', true, null);
  ignore_list.watch('late', post_filter.update);

  return function(post) {
    if (settings.get('hide_ignored') && ignore_list.get(post.user))
      return true;
  }
})());

// Temporarily unhide hidden posts
var unhide_posts = (function() {
  var posts = [];

  post_filter.filter(function(post) {
    if (posts.indexOf(post) !== -1)
      return false;
  });

  return {
    // Set the unhide state of a post (true == unhide, undefined == toggle)
    set: function(post, state) {
      var idx = posts.indexOf(post);
      if (state === undefined)
        state = (idx === -1);
      if (state && idx === -1)
        posts.push(post);
      else if (!state && idx !== -1)
        posts.splice(idx, 1);
      else
        return;
      post_filter.update();
    }
  };
})();
// Generic buttons to toggle ignored state of a user
var ignore_buttons = (function() {
  var ignbtns = { };
  var inner_ign = '';
  var inner_unign = '';

  function inner_update(user, state) {
    var elms = ignbtns[user];
    if (elms === undefined)
      return;
    var fmt = inner_ign;
    if (state)
      fmt = inner_unign;
    for (var i = 0; i < elms.length; i++)
      elms[i].innerHTML = fmt;
  }

  function update_btns(users, state) {
    if (users instanceof Array) {
      for (var i = 0; i < users.length; i++)
        inner_update(users[i], state);
    } else {
      inner_update(users, state);
    }
  }

  return {
    // Set the (HTML) format of the (un)ignore buttons
    set: function(ign, unign) {
      inner_ign = ign;
      inner_unign = unign;
    },

    // Register an element to serve as an ignore button for user
    register: function(user, elm) {
      if (ignbtns[user] === undefined)
        ignbtns[user] = [];
      ignbtns[user].push(elm);
      handlers.ignore_toggle(elm, user);
      elm.innerHTML = inner_ign;
    },

    // Wire ignore_buttons to ignore_list to keep all buttons updated
    update: function() { ignore_list.watch('late', update_btns) }
  };
})();
// Handle RES-style tagging of users
var user_tags = (function() {
  settings.register('user_tags', null, { }, null);
  var badges = [];

  function update_badges(n, val) {
    for (var i = 0; i < badges.length; i++) {
      var b = badges[i];
      var t = val[b.user];
      if (t === undefined)
        t = b.saved;
      for (var j = 0; j < b.elms.length; j++)
        b.elms[j].innerHTML = strip_tags(t);
    }
  }

  // Return a function to prompt for a new tag for user
  function gen_prompt(user) {
    return (function(e) {
      e.preventDefault();
      e.stopPropagation();

      var tmp = settings.get('user_tags');
      var def = '';
      if (tmp[user] !== undefined)
        def = tmp[user];
      var tag = prompt('Tag for ' + user + ' or blank for default:', def);
      if (tag === null)
        return;
      tag = strip_tags(tag);

      // Ugh.
      var tags = { };
      for (var u in tmp)
        tags[u] = tmp[u];

      if (tags[user] !== undefined && tag === '')
        delete tags[user];
      else if (tag !== '')
        tags[user] = tag;
      settings.set('user_tags', tags);
    });
  }

  return {
    // Register elm as the element showing user's tag
    register: function(user, elm) {
      user = strip_tags(user);
      var i;
      for (i = 0; i < badges.length; i++) {
        if (badges[i].user === user)
          break;
      }
      if (i === badges.length)
        badges.push({ user: user, saved: elm.innerHTML,
          func: gen_prompt(user), elms: [] });
      var b = badges[i];
      b.elms.push(elm);
      elm.addEventListener('click', b.func);
    },

    // Wire user_tags to settings to track changes
    update: function() { settings.watch('user_tags', update_badges) },
  };
})();
// Track the last viewed page and post count for threads
var last_viewed = (function() {
  var db;
  var store = 'thread_data';

  // Accumulate commands and fire them once idb is open
  var cmds = [];
  function run_cmds() {
    if (db === undefined)
      return;
    var cmd;
    while (cmd = cmds.shift()) {
      if (cmd.cmd === 'set') {
        delete cmd.cmd;
        var tr = db.transaction(store, 'readwrite');
        var os = tr.objectStore(store);
        var req = os.get(cmd.id);
        req.onsuccess = (function(os, cmd) {
          return function(e) {
            var t = e.target.result;
            if (t === undefined) {
              t = cmd;
            } else {
              t.lvt = cmd.lvt;
              t.lvp = cmd.lvp;
              if (cmd.lpn > t.lpn) {
                t.lpn = cmd.lpn;
                t.lp = cmd.lp;
              }
            }
            os.put(t);
          }
        })(os, cmd);
      } else if (cmd.cmd === 'get') {
        var os = db.transaction(store, 'readonly').objectStore(store);
        os.get(cmd.id).onsuccess = (function(cmd) {
          return function(e) {
            var t = e.target.result;
            if (t === undefined)
              return;
            cmd.f(cmd.e, t.lvp, t.lpn, t.lp);
          }
        })(cmd);
      }
    }
  }

  /* Periodically scrub the database (FIXME this is awful & may run in multiple
   * tabs simultaneously)
   */
  settings.register('lv_scrub_time', null, 0, null);
  // Open idb and maybe remove old threads
  (function() {
    var idb = indexedDB;
    var req = idb.open('ignore_list', 2);
    req.onupgradeneeded = function(e) {
      var tmp = e.target.result;
      if (!tmp.objectStoreNames.contains(store)) {
        var os = tmp.createObjectStore(store, { keyPath: 'id' });
        os.createIndex('lvt', 'lvt');
      }
    };
    req.onsuccess = function(e) {
      db = e.target.result;
      run_cmds();
      setTimeout(function() {
        var now = Date.now();

        // This is awful, but it's better than scrubbing every page load.
        if (settings.get('lv_scrub_time') + 24 * 60 * 60 * 1000 > now)
          return;

        var tr = db.transaction(store, 'readwrite');
        var os = tr.objectStore(store);
        os.index('lvt')
            .openCursor(IDBKeyRange.upperBound(now - 14 * 24 * 60 * 60 * 1000))
            .onsuccess = function(e) {
              var c = e.target.result;
              if (c) {
                os.delete(c.primaryKey);
                c.continue();
              } else {
                settings.set('lv_scrub_time', now);
              }
            };
      }, 5000);
    };
  })();

  return {
    /* Record a visit to thread_id, on page last_viewed_page, with up to
     * last_post_nr (of id last_post_id) visible.  Should be called on every
     * (thread) pageview.
     */
    set: function(thread_id, last_viewed_page, last_post_nr, last_post_id) {
      cmds.push({
        cmd: 'set',
        id: thread_id,
        lvt: Date.now(),
        lvp: last_viewed_page,
        lpn: last_post_nr,
        lp: last_post_id,
      });
      run_cmds();
    },

    // Call func with the view history of thread_id (and pass extra as well)
    get: function(thread_id, func, extra) {
      cmds.push({ cmd: 'get', id: thread_id, f: func, e: extra });
      run_cmds();
    },
  };
})();
// Generic settings menu
var settings_menu = (function() {
  return {
    /* Attach a settings menu to elm.  Menus is a list of submenus, in the form
     * [ { title: 'str' | undefined, vars: [ "name" ] } ]
     */
    register: function(elm, menus) {
      var s = settings.all();
      for (var i = 0; i < menus.length; i++) {
        var menu = menus[i];
        var d;
        if (i !== 0)
          elm.appendChild(document.createElement('hr'));
        if (menu.title !== undefined) {
          d = elm.appendChild(document.createElement('div'));
          d.innerHTML = '<b>' + menu.title + '</b>';
        }
        for (var j = 0; j < menu.vars.length; j++) {
          var v = menu.vars[j];
          d = elm.appendChild(document.createElement('div'));
          d.innerHTML = '<input type="checkbox"></input><label></label>';
          var e = d.children[0];
          handlers.checkbox(e, v);
          if (s[v].dep !== null) {
            var ds = s[v].dep.match(/(!)?(.*)/);
            settings.watch(ds[2], (function(e, i) {
              return function(n, v) { e.disabled = v ^ i }
            })(e, !ds[1]));
          }
          e.id = 'ilcb_' + v;
          d.children[1].innerHTML = s[v].desc;
          d.children[1].setAttribute('for', 'ilcb_' + v);
        }
      }
    }
  };
})();
// Display a nice menu for managing the ignorelist
var manage_menu = (function() {
  var menu_list;
  var menu_elms = [];
  var remfmt = '';

  function inner_update(user, state) {
    var i;
    for (i = 0; i < menu_elms.length; i++) {
      // Keep menu alphabetical
      var cmp = user.toLowerCase()
          .localeCompare(menu_elms[i].user.toLowerCase());
      if (cmp === 0) {
        if (!state) {
          menu_elms[i].elm.remove();
          menu_elms.splice(i, 1);
        }
        return;
      } else if (cmp === -1) {
        break;
      }
    }
    if (state) {
      var tr = document.createElement('tr');
      tr.innerHTML = '<td style="padding: 0px">' + user + '</td>' +
          '<td align="right" style="padding: 0px"><a username=' + user + '>' +
          remfmt + '</a></td>';
      fix_as(tr);
      handlers.ignore_toggle(tr.getElementsByTagName('a')[0], user);
      if (i < menu_elms.length) {
        menu_list.insertBefore(tr, menu_elms[i].elm);
        menu_elms.splice(i, 0, { user: user, elm: tr });
      } else {
        menu_list.appendChild(tr);
        menu_elms.push({ user: user, elm: tr});
      }
    }
  }

  function update(user, state) {
    if (menu_list === undefined)
      return;
    if (user instanceof Array) {
      for (var i = 0; i < user.length; i++)
        inner_update(user[i], true);
    } else {
      inner_update(user, state);
    }
  }

  return {
    // Set the format of the remove/unignore button
    set: function(fmt) { remfmt = fmt },

    // Attach management elements to elm
    register: function(elm) {
      var tbl = elm.appendChild(document.createElement('table'));
      tbl.innerHTML = '<tbody><tr><td colspan="2" style="padding: 0px">' +
          '<input type="text" placeholder="Add user..." width="auto">' +
          '</input> <div class="il_addbtn">Add</div></td></tr></tbody>';
      menu_list = tbl.children[0];
      var td = menu_list.children[0].children[0];
      handlers.addbox_enter(td.children[0]);
      handlers.addbox_click(td.children[1], td.children[0]);

      ignore_list.watch('late', update);
    },
  };
})();
// Where are we?
// N.B. Permalink URLs are matched by both thread and perma.
var ftl = {
  mobile: '^https?://m\\.',
  thread: '^https?://(m|www)\\.fasttech\\.com/forums/[^/]+/t/[0-9]+/.',
  perma: '^https?://(m|www)\\.fasttech\\.com/forums' +
      '/[^/]+/t/[0-9]+/[^/]+\\?[0-9]+$',
  threadlist: '^https?://(m|www)\\.fasttech\\.com/forums' +
      '(/[^/]+(/page/[0-9]+)?(/search\\?.*)?)?$',
  author: '^https?://(m|www)\\.fasttech\\.com/forums/author/',
  product: '^https?://(m|www)\\.fasttech\\.com/p(roducts?)?/',
  settings: '^https?://www\\.fasttech\\.com/forums/settings$',
};
for (var k in ftl)
  ftl[k] = new RegExp(ftl[k]).test(location.href);

// Menu configuration
var full_menus = [
  { title: 'General', vars: ['track_viewed', 'track_posts', 'always_unread',
    'hide_threads', 'quote_quotes', 'quote_imgs'] },
  { title: 'Thread Menu', vars: ['inthread_menu', 'inthread_manage'] },
  { title: 'Post Hiding', vars: ['placeholders', 'hide_ignored', 'hide_quotes',
    'hide_deleted'] },
];
var thread_menu;
if (true) {
  thread_menu = [
    { title: 'General', vars: ['quote_quotes', 'quote_imgs'] },
    { title: 'Post Hiding',
      vars: ['hide_ignored', 'hide_quotes', 'hide_deleted'] },
  ];
} else {
  thread_menu = full_menus;
}

manage_menu.set('<img alt="Remove" ' +
    'src="https://www.fasttech.com/images/minus-button.png"></img></a></td>');
settings.register('inthread_menu', 'Show menu in thread toolbar', true, null);
settings.register('inthread_manage', 'Manage ignored users from the menu',
    false, 'inthread_menu');
settings.register('agree_tou', null, false, null);
settings.register('placeholders', 'Show placeholders for hidden posts', true,
    null);
settings.register('track_viewed', 'Link to last viewed page', false, null);
settings.register('track_posts', 'Link to unread posts', true, null);
settings.register('always_unread', 'Always show unread posts button', false,
    'track_posts');
settings.register('hide_quotes', 'Hide posts quoting ignored users', false,
    'hide_ignored');
settings.register('hide_deleted', 'Hide deleted posts', true, null);
settings.register('hide_threads', 'Hide threads by ignored users', false, null);
settings.register('quote_quotes', 'Include quotes in quotes', true, null);
settings.register('quote_imgs', 'Include images in quotes', true, null);

if (ftl.thread) {
  // Add a filter for posts quoting ignored users
  post_filter.filter((function() {
    var quote_res = [];
    function new_qre(user) {
      quote_res.push(
          new RegExp('\\b' + user + ' wrote(</a>)?:?[\r\n]*<br>', 'i'));
    }

    function update(user, state, idx) {
      if (user instanceof Array) {
        quote_res = [];
        for (var i = 0; i < user.length; i++)
          new_qre(user[i]);
      } else {
        if (state)
          new_qre(user);
        else
          quote_res.splice(idx, 1);
      }
    }
    ignore_list.watch('early', update);
    settings.watch('hide_quotes', post_filter.update);

    return function(post) {
      if (settings.get('hide_ignored') && settings.get('hide_quotes')) {
        for (var i = 0; i < quote_res.length; i++) {
          if (quote_res[i].test(post.content_elm.innerHTML))
            return true;
        }
      }
    }
  })());

  // Add a filter for posts that the admins have deleted
  post_filter.filter((function() {
    var deleted_re = /post deleted/i;
    var edited_re = /^[ \r\n]*$/;
    settings.watch('hide_deleted', post_filter.update);

    return function(post) {
      if (settings.get('hide_deleted')) {
        var wb = post.content_elm.getElementsByClassName('WarningBox')[0];
        if ((wb !== undefined && deleted_re.test(wb.innerHTML)) ||
            edited_re.test(post.content_elm.innerHTML))
          return true;
      }
    }
  })());
}

window.setTimeout(block.unblock, 0);

// Get all post bodies, desktop and mobile
function get_posts() {
  var ret = [];
  var posts = document.getElementsByClassName('PostContent');
  for (var i = 0; i < posts.length; i++) {
    var pc = posts[i];
    var user = pc.getAttribute('data-username');
    if (user !== null)
      ret.push({ user: user, content_elm: pc });
  }
  return ret;
}

// Create a function to create placeholders as needed
function gen_watch_filter(ph_func) {
  return function(post, state) {
    var placeholders = settings.get('placeholders');
    if (state && placeholders) {
      if (!post.placeholder)
        post.placeholder = ph_func(post);
      post.placeholder.style.display = '';
    } else {
      if (post.placeholder !== undefined)
        post.placeholder.style.display = 'none';
    }
  }
}

// Get the current and last pages from a pager element, desktop and mobile
function parse_pager(elm) {
  var cur, last;
  if (elm === undefined || elm === null) {
    cur = last = 1;
  } else if (!ftl.mobile) {
    var sel = elm.getElementsByClassName('PageLink_Selected')[0];
    if (sel !== undefined)
      cur = parseInt(sel.innerHTML);
    last = parseInt(elm.lastElementChild.previousElementSibling.innerHTML);
  } else {
    var sel = elm.getElementsByClassName('active')[0];
    if (sel !== undefined)
      cur = parseInt(sel.firstElementChild.innerHTML);
    last = parseInt(elm.lastElementChild.firstElementChild.innerHTML);
  }
  return {
    cur: cur,
    last: last
  };
}

if (!ftl.mobile) {
  /* Jump-to-page boxes are broken site-wide.  Fix them.
   * The existing JumpToPage box could be fixed by changing "function (e)" to
   * "function (event)" in the event listener, but instead, I'm doing all this.
   */
  var jtpbox = document.getElementById('JumpToPage');
  if (jtpbox !== null) {
    var lastpg = parse_pager(jtpbox.parentElement).last;
    var as = jtpbox.parentElement.getElementsByTagName('a');
    if (as[0] !== undefined) {
      var base = as[0].href.match(new RegExp('(.*/)[0-9]+($|[?#/].*)'));
      if (base !== null) {
        jtpbox.addEventListener('keypress', function(k) {
          if (k.which === 13) {
            k.preventDefault();
            k.stopPropagation();
            var pg = parseInt(this.value);
            if (pg > 0 && pg < lastpg)
              location.href = base[1] + pg + base[2];
          }
          return false;
        });
      }
    }
  }
}

// RE to pull a thread id from a URL
var thread_id_re = new RegExp('/forums/[^/]+/t/([0-9]+)/[^?]*$');
if (ftl.thread) {
  var thread_id = location.href.match(thread_id_re);
  if (thread_id !== null && thread_id[1] !== undefined)
    thread_id = parseInt(thread_id[1]);
  else
    thread_id = undefined;
}

// Translate a post number into a page number, handling FT's off-by-one quirk
function postnr_to_pagenr(post, maxpage) {
  return Math.floor(Math.min(maxpage, post / 10 + 1));
}

// Create a link to a specific page of a thread
function gen_page_link(base, pn, unread) {
  var ret = base;
  if (pn !== 1)
    ret += '/' + pn;
  if (unread === true)
    ret += '#unread';
  return ret;
}
// Find the reply button for a post
var replace_quotebtn;

// Add a "Top of page" button at the bottom of the page, aligned with the pager
var add_top_btn;
if (!ftl.mobile) {
  add_top_btn = function() {
    var elm = document.getElementsByClassName('ForumThread TightTable')[0];
    if (elm)
      elm = elm.nextElementSibling;
    if (!elm)
      return;
    elm.className = 'Pager';
    var tbtn = document.createElement('span');
    tbtn.className = 'ControlArrows';
    tbtn.setAttribute('style', 'float: left; min-width: 150px; ' +
        'max-width: 250px; text-align: center');
    tbtn.innerHTML = '<a>Top of page</a>';
    fix_as(tbtn);
    handlers.top_click(tbtn);
    var pager = elm.firstElementChild;
    elm.appendChild(tbtn);
    if (pager) {
      var pspan = document.createElement('span');
      pspan.appendChild(pager);
      elm.appendChild(pspan);
    } else {
      elm.parentElement
          .insertBefore(document.createElement('br'), elm.nextElementSibling);
    }
  };
} else {
  add_top_btn = function() {
    var elm = document.getElementsByClassName('ListRow')[0];
    if (elm)
      elm = elm.parentElement.nextElementSibling;
    if (!elm)
      return;
    var tbtn = document.createElement('button');
    tbtn.className = 'btn btn-default';
    tbtn.innerHTML = 'Top';
    handlers.top_click(tbtn);
    if (elm.firstElementChild) {
      tbtn.style.marginTop = '-5px';
      elm = elm.firstElementChild;
      var td = document.createElement('td');
      td.style.textAlign = 'right';
      td.appendChild(tbtn);
      elm.style.width = '100%';
      elm.firstElementChild.firstElementChild.appendChild(td);
    } else {
      var d = elm.appendChild(document.createElement('div'));
      d.setAttribute('style', 'width: 100%; text-align: right; ' +
          'margin-bottom: 8px');
      d.appendChild(tbtn);
    }
  };
}

if (ftl.thread) {
  add_top_btn();

  if (!ftl.mobile) {
    // Persist the terms of use checkbox
    var toubox = document.getElementById('AgreeTerms');
    if (toubox !== null)
      handlers.checkbox(toubox, 'agree_tou');

    replace_quotebtn = function(elm) {
      // Ugh.
      var oldbtn = elm.parentElement.parentElement.parentElement
          .previousElementSibling.firstElementChild.lastElementChild
          .firstElementChild;
      var newbtn = oldbtn.parentElement
          .insertBefore(document.createElement('a'), oldbtn);
      oldbtn.remove();
      newbtn.innerHTML = 'quote/reply';
      newbtn.href = 'javascript:void(0)';
      return newbtn;
    };

    setTimeout(function() {
      var ta = document.getElementsByClassName('mbbc-editor')[0];
      if (ta !== undefined) {
        // Remove the broken size=[67] buttons
        var size_popup = document.getElementsByClassName('mbbc-size-popup')[0];
        if (size_popup !== undefined) {
          size_popup.lastElementChild.remove();
          size_popup.lastElementChild.remove();
        }
        // Fix the editor width
        ta.style.boxSizing = 'border-box';
        ta.style.width = '100%';
        // Add a YouTube button
        var url_btn = document.getElementsByClassName('mbbc-url')[0];
        if (url_btn !== undefined) {
          var yt_btn = url_btn.parentElement
              .insertBefore(document.createElement('li'), url_btn);
          yt_btn.className = 'mbbc-youtube';
          yt_btn.addEventListener('click', function() {
            var sp = ta.selectionStart;
            var ep = ta.selectionEnd;
            var sel;
            if (ep < sp) {
              sp = ep;
              ep = ta.selectionStart;
            }
            if (ep !== sp) {
              sel = ta.value.slice(sp, ep);
            } else {
              sel = prompt('Video URL:', '');
              if (sel === null)
                return;
              var vid = sel.match(new RegExp('[/?=#]([-\\w]{11})([^-\\w]|$)'));
              if (vid !== null)
                sel = 'https://www.youtube.com/watch?v=' + vid[1];
            }
            if (sel === undefined)
              return;
            ta.value = ta.value.slice(0, sp) +
                '[youtube]' + sel + '[/youtube]' +
                ta.value.slice(ep);
            ta.selectionStart = ta.selectionEnd = sp + 9 +
                (sel === '' ? 0 : 10 + sel.length);
            ta.focus();
          });
        }
      }
    }, 1000);
  } else { // mobile
    // Add a link to forum settings to allow ignorelist management
    // Ugh.  There's no semi-robust way to select this.
    var li = document.querySelector('ul.nav:nth-child(1)>li:nth-child(3)' +
        '>ul:nth-child(2)>li:nth-child(8)');
    if (li !== null) {
      var new_li = document.createElement('li');
      new_li.innerHTML = '<a title="Forum Settings" ' +
          'href="https://www.fasttech.com/forums/settings">Forum Settings</a>';
      li.parentElement.insertBefore(new_li, li);
    }

    // Fix up YT iframe size: CSS doesn't work well here.
    var ifrs = document.getElementsByTagName('iframe');
    for (var i = 0; i < ifrs.length; i++) {
      var ifr = ifrs[i];
      if (ifr.clientWidth > ifr.parentElement.clientWidth) {
        var ar = ifr.clientHeight / ifr.clientWidth;
        ifr.style.width = '100%';
        ifr.style.height = Math.ceil(ifr.clientWidth * ar) + 'px';
      }
    }

    replace_quotebtn = function(elm) {
      // Ugh.
      var oldbtn = elm.parentElement.firstElementChild.lastElementChild
          .lastElementChild.firstElementChild;
      var newbtn = oldbtn.parentElement
          .insertBefore(document.createElement('a'), oldbtn);
      newbtn.innerHTML = oldbtn.innerHTML;
      oldbtn.remove();
      newbtn.href = 'javascript:void(0)';
      newbtn.tabindex = '-1';
      newbtn.role = 'menuitem';
      return newbtn;
    };
  }
}

/* Fix up display of certain post elements:
 *  - Fix align tags (which should have been 100% divs to begin with)
 *  - Avoid breaking SKU links across lines
 */
document.body.appendChild(document.createElement('style')).innerHTML =
    '.PostContent span[style*="text-align:"] {display:block;width:100%}' +
    'a.SKUAutoLink {display=inline-block}';

// Re-implement quotes in a way that sort of works
var fix_quote_btns = (function() {
  var textarea;
  var editor;
  var edit_panel;
  var expand;
  var thread_id;

  function init(elms) {
    if (!ftl.mobile)
      textarea = document.getElementsByName('mbbc-editor')[0];
    else
      textarea = document.getElementById('RawContent');
    if (!textarea || !replace_quotebtn)
      return;

    if (!ftl.mobile) {
      editor = textarea.parentElement.parentElement;
    } else {
      edit_panel = document.getElementById('writePost');
      if (edit_panel) {
        editor = edit_panel.parentElement;
        expand = edit_panel.previousElementSibling.firstElementChild
            .firstElementChild;
      }
    }

    var tid = location.href.match(new RegExp('/t/([0-9]+)/'));
    if (tid !== null)
      thread_id = tid[1];


    for (var i = 0; i < elms.length; i++) {
      var elm = elms[i];
      var newbtn = replace_quotebtn(elm.content_elm);
      newbtn.addEventListener('click', (function(elm) {
        return (function() { quote(elm.user, elm.content_elm) })})(elm));
    }
  }

  function wrap_tag(tag, val, str) {
    return '[' + tag + (val ? '=' + val : '') + ']' + str + '[/' + tag + ']';
  }

  function inner_quote(elm, range, depth) {
    var tmp = '';
    for (var e = elm.firstChild; e !== null; e = e.nextSibling) {
      var sp, ep;
      if (range !== undefined) {
        if (range.startContainer.compareDocumentPosition(e) === 2)
          continue;
        if (range.endContainer.compareDocumentPosition(e) === 4)
          break;
        if (range.startContainer === e)
          sp = range.startOffset;
        if (range.endContainer === e)
          ep = range.endOffset;
      }
      switch (e.tagName) {
        case 'BR':
          tmp += '\n';
          break;

        case 'STRONG':
          tmp += wrap_tag('b', '', inner_quote(e, range, depth));
          break;

        case 'EM':
          tmp += wrap_tag('i', '', inner_quote(e, range, depth));
          break;

        case 'HR':
          tmp += '[hr]';
          break;

        case 'P':
          if (e.innerHTML !== '')
            tmp += '\n' + inner_quote(e, range, depth);
          break;

        case 'SPAN':
          var s = e.style;
          var t = null;
          var v = '';
          if (s.textDecoration === 'underline') {
            t = 'u';
          } else if (s.textDecoration === 'line-through') {
            t = 's';
          } else if (s.fontSize !== '') {
            t = 'size';
            v = parseInt(s.fontSize);
          } else if (s.color !== '') {
            t = 'color';
            v = s.color;
          } else if (s.textAlign !== '') {
            t = 'align';
            v = s.textAlign;
          } else {
            console.log('Unknown span!');
          }

          tmp += wrap_tag(t, v, inner_quote(e, range, depth));
          break;

        case 'DIV':
          if (e.className === 'ForumQuote') {
            if (tmp[tmp.length - 1] !== '\n')
              tmp += '\n';
            if (depth === 0 && settings.get('quote_quotes'))
              tmp += wrap_tag('quote', '',
                  inner_quote(e.firstElementChild, range, depth + 1).trim());
            else
              tmp += '(snip)\n';
          } else {
            console.log('Unknown div!');
          }

          break;

        case 'IFRAME':
          var m = e.src.match(/youtube[^\/]*.com.*\/embed\/([^?]+)/);
          if (m !== null && m[1] !== undefined) {
            /* A YouTube tag inside a quote breaks the parsing of the quote tag.
          tmp += wrap_tag('youtube', '',
            'https://www.youtube.com/watch?v=' + m[1])
          */
            tmp += wrap_tag('url',
                'https://www.youtube.com/watch?v=' + m[1], 'YouTube video');
          } else {
            console.warn('Unknown iframe!');
          }
          break;

        case 'IMG':
          if (settings.get('quote_imgs')) {
            tmp += wrap_tag('img', '', e.src);
          } else if (e.parentElement.tagName !== 'A') {
            tmp += wrap_tag('url', e.src, 'Image');
          } else {
            tmp += 'Image';
          }
          break;

        case 'A':
          if (e.className !== 'SKUAutoLink') {
            tmp += wrap_tag('url', e.href, inner_quote(e, range, depth));
          } else {
            tmp += e.textContent;
          }
          break;

        case undefined:
          // Filter out newlines and empty tags
          tmp += e.wholeText.slice(sp ? sp : 0, ep)
              .replace(/[\r\n]+/mg, '')
              .replace(/\[([^\]=]*)(=[^\]]*)?\]\[\/\1\]/mg, '');
          break;

        default:
          console.warn('Unhandled tag ' + e.tagName);
      }
    }
    return tmp;
  }

  function quote(user, elm) {
    var post_id;
    var pid = elm.id.match(/^POST([0-9]+)$/);
    if (pid !== null)
      post_id = pid[1];

    var sel = getSelection();
    if (sel.rangeCount > 0) {
      sel = sel.getRangeAt(0);
      if (sel.collapsed || !sel.intersectsNode(elm))
        sel = undefined;
    } else {
      sel = undefined;
    }

    if (textarea.value.length > 0 &&
        textarea.value[textarea.value.length - 1] !== '\n')
      textarea.value += '\n';
    if (thread_id !== undefined && post_id !== undefined)
      textarea.value += wrap_tag('url',
          '/forums/-/t/' + thread_id + '?' + post_id, user + ' wrote') + ':\n';
    else
      textarea.value += user + ' wrote:\n';
    textarea.value += wrap_tag('quote', '',
        inner_quote(elm, sel, 0).trim().replace(/[\r\n]{3,}/mg, '\n\n')) + '\n';

    if (editor)
      editor.scrollIntoView();

    if (expand && edit_panel && edit_panel.className &&
        !/\bin\b/.test(edit_panel.className))
      expand.click();
  }

  return function(elms) {
    // Delay fixing links until the editor has loaded.  1s should be enough.
    setTimeout(function() { init(elms) }, 1000);
  }
})();
function scroll_to_unread(tid, posts) {
  last_viewed.get(tid, function(posts, lvp, lpn, lp) {
    var i;
    var pid = 'POST' + lp;
    for (i = posts.length - 1; i >= 0; i--) {
      if (posts[i].content_elm.id === pid)
        break;
    }
    // Drop the #unread so we won't run again
    history.replaceState({}, '', location.pathname);
    // We've already seen all posts
    if (i === posts.length - 1)
      return;
    i = Math.min(i + 1, posts.length - 1);
    // scrollIntoView() doesn't work on hidden elements.
    var elm = posts[i].post_elms[0];
    var sd = elm.style.display;
    elm.style.display = '';
    elm.scrollIntoView();
    elm.style.display = sd;
  }, posts);
}

if (ftl.threadlist || ftl.product || ftl.author || ftl.settings) {
  if (!ftl.mobile) {
    if (!ftl.settings)
      add_top_btn();
    block.watch(function() {
      var add_last_viewed =
          function(elm, last_viewed_page, last_post_nr, last_post_id) {
        var row = elm.parentElement;
        var pelm = row.parentElement.getElementsByClassName('Pager')[0];
        var p = parse_pager(pelm);

        if (pelm === undefined) {
          if (!ftl.settings) {
            pelm = row.lastElementChild;
            pelm.style.display = '';
            pelm = pelm.appendChild(document.createElement('div'));
            pelm.className = 'Pager';
          } else {
            pelm = row.parentElement.appendChild(document.createElement('div'));
            pelm.className = 'Pager';
          }
        }

        if (settings.get('track_viewed')) {
          var lv = pelm.appendChild(document.createElement('span'));
          lv.innerHTML = '<a href="' +
              gen_page_link(elm.href, last_viewed_page) + '">Last viewed</a>';
          lv.className = 'FieldFlag';
          lv.style.padding = '7px 8px 5px';
          lv.firstElementChild.style.fontSize = '10pt';
        }

        var tr = row.parentElement.parentElement;
        var ts = tr.getElementsByClassName('ThreadStats')[0];
        var pc;
        if (ts !== undefined)
          pc = (pc = ts.innerHTML.match(/Replies: ([0-9]+)/)) ?
              parseInt(pc[1]) : undefined;
        var unread = pc > 0 && pc >= last_post_nr;
        if ((unread || settings.get('always_unread')) &&
            settings.get('track_posts')) {
          var np = pelm.appendChild(document.createElement('span'));
          np.className = 'FieldFlag';
          var npa = np.appendChild(document.createElement('a'));
          if (unread) {
            np.style.backgroundColor = '#AA0D0D';
            npa.innerHTML = 'Unread posts';
            npa.href = gen_page_link(elm.href,
                postnr_to_pagenr(last_post_nr, p.last), true);
          } else {
            np.style.backgroundColor = '#F0F0F0';
            npa.innerHTML = 'No unread posts';
            npa.style.color = '#333';
          }
          np.style.padding = '7px 8px 5px';
          npa.style.fontSize = '10pt';
        }
      };

      var header = document.getElementsByClassName('ForumHeader')[0];
      var filter = settings.get('hide_threads');
      var sku_re = new RegExp('fasttechcdn.com/[0-9]+/([0-9]+)/.*');
      if (header !== undefined) {
        var tbl = header.parentElement;
        for (var i = 1; i < tbl.children.length; i += 2) {
          var tr = tbl.children[i];
          var tl = tr.getElementsByClassName('ThreadLink')[0];
          var tid;
          if (tl !== undefined &&
              (settings.get('track_posts') || settings.get('track_threads'))) {
            tid = parseInt(tl.href.match(thread_id_re)[1]);
            if (!isNaN(tid))
              last_viewed.get(tid, add_last_viewed, tl);
          }
          var img = tr.firstElementChild.firstElementChild;
          if (img && img.tagName === 'IMG') {
            var sku = img.src.match(sku_re);
            if (sku !== null) {
              var a = img.parentElement
                  .appendChild(document.createElement('a'));
              a.href = '/products/' + sku[1];
              a.appendChild(img);
            }
          }
          if (filter) {
            var u = tl.parentElement.textContent.match(/started\W+by\W+(\w+)/);
            if (u && u[1] && ignore_list.get(u[1])) {
              tr.style.display = 'none';
              if (tr.previousElementSibling !== header)
                tr.previousElementSibling.style.display = 'none';
            }
          }
        }
      }
    });
  } else { // mobile
    block.watch(function() {
      var add_last_viewed =
          function(elm, last_viewed_page, last_post_nr, last_post_id) {
        var rh = elm.parentElement;
        var tr = rh.parentElement;
        var p = parse_pager(tr.getElementsByClassName('pagination')[0]);
        var d = rh.appendChild(document.createElement('div'));
        d.style.marginLeft = '-5px';

        if (settings.get('track_viewed')) {
          var lv = d.appendChild(document.createElement('span'));
          lv.innerHTML = '<a href="' +
              gen_page_link(elm.href, last_viewed_page) + '">Last viewed</a>';
          lv.className = 'btn btn-default';
          lv.style.margin = '5px';
        }

        var pc = (pc = tr.textContent.match(/R(eplies)?: ([0-9]+)/)) ?
            parseInt(pc[2]) : undefined;
        var unread = pc > 0 && pc >= last_post_nr;
        if ((unread || settings.get('always_unread')) &&
            settings.get('track_posts')) {
          var np = d.appendChild(document.createElement('span'));
          np.className = 'btn btn-default';
          np.style.margin = '5px';
          var npa = np.appendChild(document.createElement('a'));
          if (unread) {
            npa.innerHTML = 'Unread posts';
            npa.href = gen_page_link(elm.href,
                postnr_to_pagenr(last_post_nr, p.last), true);
            npa.style.color = '#C64148';
          } else {
            npa.innerHTML = 'No unread posts';
            npa.style.color = '#888';
          }
        }
      };

      var mangle_list = function() {
        add_top_btn();
        var filter = settings.get('hide_threads');
        var elms = document.getElementsByClassName('ListRow');
        for (i = 0; i < elms.length; i++) {
          var elm = elms[i];
          if (elm === undefined)
            continue;
          var rh = elm.getElementsByClassName('RowHeading')[0];
          if (rh === undefined)
            continue;
          var l = rh.firstElementChild;
          last_viewed.get(parseInt(l.href.match(thread_id_re)[1]),
              add_last_viewed, l);

          if (filter) {
            var u = tl.parentElement.textContent.match(/started\W+by\W+(\w+)/);
            if (u && u[1] && ignore_list.get(u[1]))
              elm.style.display = 'none';
          }
        }
      };
      if (ftl.product) {
        // Wait for thread list to load before mangling
        var div = document.getElementById('forum');
        if (div !== null)
          (new MutationObserver(mangle_list)).observe(div, { childList: true });
      } else {
        mangle_list();
      }
    });
  }
}
if (!ftl.mobile && ftl.thread) {
  // Re-implement FT's PopoutMenu, only nicer
  var PM2 = (function() {
    var saved_class, saved_par, saved_ch, timeout;

    function add_listener(elem, type, val, par, ch) {
      elem.addEventListener(type, function() { act(val, par, ch) });
    }

    function add_listeners(elem, par, ch) {
      add_listener(elem, 'mouseover', 1, par, ch);
      add_listener(elem, 'mouseout', 0, par, ch);
    }

    function show() {
      saved_par.className += ' focused';
      saved_ch.style.display = 'inline';
      // Hack: this is the easiest way to fix up the element locations.
      saved_ch.style.left = saved_ch.style.top = '0px';
      var pb = saved_par.getBoundingClientRect();
      var cb = saved_ch.getBoundingClientRect();
      var pad = parseInt(saved_ch.style.padding);
      pad = pad ? pad : 0;
      saved_ch.style.left = Math.min(pb.left - cb.left,
          document.body.clientWidth - cb.width + pad) + 'px';
      saved_ch.style.top = (pb.top - cb.top + 35) + 'px';
      saved_ch.style.width = (cb.width - pad * 2) + 'px';
    }

    function hide() {
      if (!saved_class) return;
      saved_par.className = saved_class;
      saved_ch.style.display = 'none';
      saved_class = undefined;
    }

    function act(val, par, ch) {
      if (val) {
        clearTimeout(timeout);
        if (saved_par != par)
          hide();
        saved_par = par;
        saved_ch = ch;
        if (saved_class === undefined) {
          saved_class = par.className;
          show();
        }
      } else {
        timeout = setTimeout(function() { hide() }, 300);
      }
    }

    // Hack: we can't remove anonymous event listeners, so duplicate elements
    function dupe(id, raw) {
      var elm = document.getElementById(id);
      var nelm = document.createElement(elm.tagName);
      nelm.align = elm.align;
      if (raw) {
        nelm.innerHTML = elm.innerHTML;
      } else {
        nelm.setAttribute('style', 'position: absolute; height: auto; ' +
            'background-color: white; padding: 20px');
        while (elm.childNodes[0])
          nelm.appendChild(elm.childNodes[0]);
      }
      nelm.id = elm.id + '2';
      nelm.className = elm.className;
      elm.parentElement.insertBefore(nelm, elm);
      elm.remove();

      return nelm;
    }

    return function(par) {
      var ch = dupe(par + 'Popout', false);
      par = dupe(par, true);
      add_listeners(par, par, ch);
      add_listeners(ch, par, ch);
    }
  })();

  var update_itmanage = function() {
    var sp = document.getElementById('Ignorelist2');
    if (sp === null)
      return;
    var div = document.getElementById('IgnorelistPopout2');
    if (div === null)
      return;
    var elms = div.getElementsByClassName('itmanage');
    switch ((elms.length === 0) * 2 + settings.get('inthread_manage')) {
      case 3:
        div.appendChild(document.createElement('hr')).className = 'itmanage';
        var l = div.appendChild(document.createElement('div'));
        l.innerHTML = '<b>Ignored Users</b>';
        l.className = 'itmanage';
        l.style.marginTop = '10px';
        manage_menu.register(l);
        l.getElementsByClassName('il_addbtn')[0].className = 'FormButton blue';
        break;
      case 1:
        for (var i = 0; i < elms.length; i++)
          elms[i].style.display = '';
        break;
      case 0:
        for (var i = 0; i < elms.length; i++)
          elms[i].style.display = 'none';
        break;
    }
  };
  settings.watch('inthread_manage', update_itmanage);
  var update_itmenu = function() {
    var sp = document.getElementById('Ignorelist2');
    var div;
    switch ((sp === null) * 2 + settings.get('inthread_menu')) {
      case 3:
        var b = document.getElementsByClassName('ThreadCommandBar')[0];
        sp = b.appendChild(document.createElement('span'));
        sp.id = 'Ignorelist';
        sp.className = 'ThreadCommand';
        sp.innerHTML = 'Settings';

        div = b.appendChild(document.createElement('div'));
        div.id = 'IgnorelistPopout';
        div.className = 'PopoutPanel';
        div.align = 'left';
        settings_menu.register(div, thread_menu);
        PM2('Ignorelist');

        if (settings.get('inthread_manage') !== undefined)
          update_itmanage();
        break;
      case 1:
        sp.style.display = '';
        break;
      case 0:
        sp.style.display = 'none';
        break;
    }
  };
  settings.watch('inthread_menu', update_itmenu);
  PM2('RateThread');
  PM2('ForumTools');

  post_filter.watch(gen_watch_filter(function(post) {
    var tbl = post.post_elms[0].parentElement;
    var ph = post.placeholder = tbl
        .insertBefore(document.createElement('tr'), post.post_elms[0]);
    ph.innerHTML = '<td colspan="2" class="ForumHeader" ' +
        'style="padding: 10px; text-align: center"><a>Post by ' +
        strip_tags(post.content_elm.getAttribute('data-username')) +
        ' hidden.  Click to show.</a></td>';
    fix_as(ph);
    handlers.hide_toggle(ph.firstElementChild.firstElementChild, post);
    return ph;
  }));
  settings.watch('placeholders', post_filter.update);

  // Wire posts & ignorebuttons up
  ignore_buttons.set('+ ignore', '+ unignore');
  var posts = get_posts();
  for (var i = 0; i < posts.length; i++) {
    var post = posts[i];
    var tl = post.content_elm.parentElement.parentElement.parentElement;
    post.post_elms = [tl.previousElementSibling, tl];
    var head = tl.previousElementSibling.firstElementChild.lastElementChild;
    var new_a = head.appendChild(document.createElement('a'));
    new_a.href = 'javascript:void(0)';
    ignore_buttons.register(post.user, new_a);

    var badge = tl.getElementsByClassName('Badges')[0];
    if (badge !== undefined)
      user_tags.register(post.user, badge);
  }
  post_filter.register(posts);
  ignore_buttons.update();
  user_tags.update();
  fix_quote_btns(posts);

  if (!ftl.perma && thread_id !== undefined) {
    if (location.hash === '#unread')
      scroll_to_unread(thread_id, posts);
    var p = parse_pager(document.getElementsByClassName('Pager')[1]);
    var lpn = (p.cur - 1) * 10 + posts.length;
    var lp = parseInt(posts[posts.length - 1].content_elm.id.slice(4));
    last_viewed.set(thread_id, p.cur, lpn, lp);
  }
}
if (ftl.mobile && ftl.thread) {
  post_filter.watch(gen_watch_filter(function(post) {
    var tbl = post.post_elms[0].parentElement;
    var ph = post.placeholder =
        tbl.insertBefore(document.createElement('div'), post.post_elms[0]);
    ph.className = 'col-xs-12 ListRow';
    ph.style.paddingBottom = '5px';
    ph.style.textAlign = 'center';
    ph.innerHTML = '<a>Post by ' +
        strip_tags(post.content_elm.getAttribute('data-username')) +
        ' hidden.  Click to show.</a>';
    fix_as(ph);
    handlers.hide_toggle(ph.firstElementChild, post);
    return ph;
  }));
  settings.watch('placeholders', post_filter.update);

  var posts = get_posts();
  for (var i = 0; i < posts.length; i++)
    posts[i].post_elms = [posts[i].content_elm.parentElement];
  post_filter.register(posts);
  fix_quote_btns(posts);

  ignore_buttons.set(
      '<span class="glyphicon glyphicon-plus red"></span> Ignore',
      '<span class="glyphicon glyphicon-plus red"></span> Unignore');
  var cont = document.getElementsByClassName('container-fluid')[0];
  var menus = cont.getElementsByClassName('dropdown-menu');
  for (var i = 0; i < menus.length; i++) {
    var user = menus[i].getElementsByTagName('b')[0].innerHTML;
    var li = document.createElement('li');
    li.role = 'presentation';
    li.innerHTML = '<a tabindex="-1" role="menuitem"></a>';
    fix_as(li);

    for (var j = 0; j < menus[i].children.length; j++) {
      if (menus[i].children[j].innerHTML.match('Report'))
        break;
    }
    menus[i].insertBefore(li, menus[i].children[j]);
    ignore_buttons.register(user, li.firstElementChild);

    var badge = menus[i].firstElementChild.firstElementChild;
    var m = badge.innerHTML.match(/(.*)\((.*)\)/);
    badge.innerHTML = m[1] + '(<span>' + m[2] + '</span>)';
    user_tags.register(user, badge.lastElementChild);
  }
  ignore_buttons.update();
  user_tags.update();

  if (!ftl.perma && thread_id !== undefined) {
    if (location.hash === '#unread')
      scroll_to_unread(thread_id, posts);
    var p = parse_pager(document.getElementsByClassName('pagination')[0]);
    var lpn = (p.cur - 1) * 10 + posts.length;
    var lp = parseInt(posts[posts.length - 1].content_elm.id.slice(4));
    last_viewed.set(thread_id, p.cur, lpn, lp);
  }

  var update_itmanage = function() {
    var menu = document.getElementById('hideSettings');
    if (menu === null)
      return;
    var elms = menu.getElementsByClassName('itmanage');
    switch ((elms.length === 0) * 2 + settings.get('inthread_manage')) {
      case 3:
        var inner = menu.getElementsByClassName('panel-body')[0];
        inner.appendChild(document.createElement('hr')).className = 'itmanage';
        var l = inner.appendChild(document.createElement('div'));
        l.className = 'itmanage';
        l.innerHTML = '<b>Ignored Users</b>';
        l.style.marginTop = '10px';
        manage_menu.register(l);
        l.getElementsByClassName('il_addbtn')[0].remove();
        break;
      case 1:
        for (var i = 0; i < elms.length; i++)
          elms[i].style.display = '';
        break;
      case 0:
        for (var i = 0; i < elms.length; i++)
          elms[i].style.display = 'none';
        break;
    }
  };
  settings.watch('inthread_manage', update_itmanage);
  var update_itmenu = function() {
    var menu = document.getElementById('itmenu');
    switch ((menu === null) * 2 + settings.get('inthread_menu')) {
      case 3:
        var div = document.getElementsByClassName('body-content')[0]
            .appendChild(document.createElement('div'));
        div.id = 'itmenu';
        div.className = 'panel panel-default';
        div.innerHTML = '<div class="panel-heading"><h5 class="panel-title">' +
            '<a href="#hideSettings" data-toggle="collapse">Settings ' +
            '<span class="caret"></span></a></h5>' +
            '<div id="hideSettings" class="panel-collapse collapse">' +
            '<div class="panel-body"></div></div>';
        var inner = div.getElementsByClassName('panel-body')[0];
        settings_menu.register(inner, thread_menu);

        if (settings.get('inthread_manage') !== undefined)
          update_itmanage();
        break;
      case 1:
        menu.style.display = '';
        break;
      case 0:
        menu.style.display = 'none';
        break;
    }
  };
  settings.watch('inthread_menu', update_itmenu);
}
if (ftl.settings) {
  var div = document.getElementsByClassName('PageContentPanel')[0]
      .appendChild(document.createElement('div'));
  div.style.marginTop = '5px';
  var tbl = div.appendChild(document.createElement('table'));
  tbl.style.width = 'auto';
  tbl.innerHTML = '<tbody><tr><td></td><td width="15"></td><td></td></tr>' +
      '<tr><td></td><td></td><td></td></tr></tbody>';
  var cells = tbl.getElementsByTagName('td');
  cells[0].className = 'MediumLabel Bold EndOfInlineSection';
  cells[0].style.padding = '5px';
  cells[0].innerHTML = 'Ignored Users';
  cells[2].className = 'MediumLabel Bold EndOfInlineSection';
  cells[2].style.padding = '5px';
  cells[2].innerHTML = 'FastTech Ignore List Settings';
  cells[5].style.padding = '0px';
  div = cells[5].appendChild(document.createElement('div'));
  div.className = 'BGShadow';
  div.style.padding = '10px';
  settings_menu.register(div, full_menus);

  cells[3].style.padding = '0px';
  div = cells[3].appendChild(document.createElement('div'));
  div.className = 'BGShadow';
  div.style.padding = '10px';
  manage_menu.register(div);
  div.getElementsByClassName('il_addbtn')[0].className = 'FormButton blue';
}