Greasy Fork is available in English.

FastTech Forum Enhancements (BETA)

Ignore users on the FastTech forums, and more

As of 2016-02-02. See the latest version.

// ==UserScript==
// @name        FastTech Forum Enhancements (BETA)
// @namespace   ftil
// @description Ignore users on the FastTech forums, and more
// @include     https://*.fasttech.com/*
// @version     2.2.1
// @grant       GM_getValue
// @grant       GM_setValue
// @run-at      document-start
// @icon        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAB+klEQVRYw+2XPUscQRjHF+J7YWWhKDtLwJyIWmhnoU16r/YDREgRsLAysi+ije55OzPnzKRR1Car3gwXsAiBiI0fILWtqB8i57OeC3eKuDt3egr3wI+FLWb+PO9jGM02QngOY8Z02NrazlieyliOZDqYrswZmPIlEFEmNCWEnQoh2qe+iHbkyFPkqnJaTEct3XkBF0QWROxhwg+SAJcvB0HQGXsR2TtdyFHLyJUHtajLymXy4sH/PeTJ7IuHFzzz+17AblPy630IYIwNQ/z/6ZDPb4/VLQAOGk9fAbyMMd9oiAdqBBDuQX0vPAelfKZhIagWkC8UJl49B1oCWgKgp/diLLIRQbDf20gB5qqcjnq+aZcmnxZA2VwsIC2U0n5kh/3RJTpYbnEuCsFh6kZUGcdXm0L0DdilPtORVzrjGEJzaPi+3323YBB+DQff1ECqoNVfdkbIj0+xF63vKoOc4hnE/CYRrrqOFpKhxZ/dRsuabrkcH8CU+TrkYZR/9H4NQxL6WrhyXW8cVybneRiGHaN22AEHnestpXKlRgAm7A9k+HEC1mAj7om9CKXYAxvuGgg5TgIssEfIlvMv3ooTWUtAvQKQXRp5/CBJiFMU9XsgDD9AAp5ozIH/wFeYBWwwfmyCDep4cdb+22a56lviR6mnCuaq+vwmGuEte5DlWQaVMykAAAAASUVORK5CYII=
// ==/UserScript==
'use strict';
// Delay function calls until their preconditions have been met.
var block = (function() {
  var counters = { };

  return {
    get: function(name) { return counters[name].val !== 0 },

    block: function(name) {
      if (counters[name] === undefined)
        counters[name] = { val: 1, watches: [] };
      else if (counters[name].val !== 0)
        counters[name].val++;
    },

    unblock: function(name) {
      var c = counters[name];
      if (c.val !== 0)
        c.val--;
      if (c.val === 0) {
        var w = c.watches;
        while (w.length !== 0)
          w.shift()();
      }
    },

    watch: function(name, func) {
      var c = counters[name];
      c.watches.push(func);
      if (c.val === 0 && c.watches.length === 1)
        this.unblock(name);
    },
  };
})();
/* Userscript settings:
 * This is just terrible.  GM_[sg]etValue is used to initialize settings since
 * it's shared across subdomains, while localStorage is used to notify other
 * tabs of new settings.
 */
var settings = (function() {
  var settings = { };
  var watches = { };
  var prefix = 'ignore_list:';
  var prere = new RegExp('^' + prefix + '(.+)');

  // Unblock after execution of the script
  block.block('ready');

  function inner_set(name, value) {
    // Ugh.
    if (JSON.stringify(settings[name].value) === JSON.stringify(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;
  }

  function migrate(name) {
    var v = localStorage.getItem(name);
    if (v !== null) {
      GM_setValue(name, v);
      localStorage.removeItem(name);
    }
  }

  window.addEventListener('storage', function(e) {
    var set = e.key.match(prere);
    if (set !== null && set[1] in settings)
      inner_set(set[1], JSON.parse(e.newValue));
  });

  function get_sync(name) {
    migrate(name);
    var val = GM_getValue(name, null);
    if (val !== null)
      return JSON.parse(val);
    else
      return settings[name].def;
  }

  function wrap_get(name) {
    if (settings[name].value === undefined)
      settings[name].value = get_sync(name);
    return settings[name].value;
  }

  function request() {
    for (var k in settings) {
      if (settings[k] === undefined)
        settings[k] = get_sync(k);
    }
    block.unblock('ready');
  }

  return {
    get: wrap_get,
    get_sync: get_sync,

    set: function(name, value) {
      if (inner_set(name, value)) {
        var s = JSON.stringify(value);
        localStorage.setItem(prefix + name, s);
        GM_setValue(name, s);
      }
    },

    all: function() { return settings },

    register: function(name, desc, def, dep) {
      settings[name] = {
        desc: desc,
        def: def,
        dep: dep,
      };
    },

    watch: function(name, func) {
      if (!(name in watches))
        watches[name] = [];
      watches[name].push(func);
      func(name, wrap_get(name));
    },

    update: request,
  };
})();
// 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;
  }
})();

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

// Case-insensitive attribute lookup
function ci_lookup(a, n) {
  var l = n.toLowerCase();
  for (var k in a) {
    if (k.toLowerCase() === l)
      return k;
  }
}
// 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.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() {
  // Use two stages so that filters can handle updates before being called
  var watches = { 'early': [], 'late': [] };
  var il = [];

  function fire_watches(user, state, idx) {
    ['early', 'late'].forEach(function(stage) {
      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) {
    if (settings.get_sync)
      il = settings.get_sync('ignorelist');
    if (state)
      il.push(user);
    else
      il.splice(idx, 1);
    settings.set('ignorelist', il);
    fire_watches(user, state, idx);

  }

  var connect = function() {
    // Watch for ignorelist changes, and fire incremental watch events
    settings.watch('ignorelist', function(name, val) {
      if (il.length === val.length)
        return;
      if (il.length === 0) {
        for (var i = 0; i < val.length; i++)
          il.push(val[i]);
        fire_watches(il, true);
        return;
      }
      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(user, state, idx);
          continue;
        }
        i++;
        j++;
      }
    });
    connect = function() { };
  };

  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) {
      connect();
      watches[stage].push(func);
      if (il !== [])
        func(il, true, -1);
    },

    update: function() {
      settings.register('ignorelist', null, [], null);
    },
  };
})();
/* 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;

  settings.register('hide_ignored', 'Hide posts by ignored users', true, null);
  block.block('filter');

  function update() {
    if (posts === undefined)
      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);
    }

    block.unblock('filter');
  }

  function builtin() {
    filters = [];
    filters.unshift((function() {
      settings.watch('hide_ignored', post_filter.update);
      ignore_list.watch('late', post_filter.update);

      return function(post) {
        if (settings.get('hide_ignored') && ignore_list.get(post.user))
          return true;
      }
    })());
    unhide_posts = (function() {
      var posts = [];

      filters.unshift(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();
        }
      };
    })();
  }

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

    /* Add a new filter.  Filters return true (should hide), false (must not
     * hide), or undefined (no judgement).  Does not trigger update().
     */
    filter: function(func) {
      if (filters === undefined)
        builtin();
      filters.push(func);
    },
    // Run all filters again
    update: update,

    /* Call func with changes to the hidden status of each post (true ==
     * hidden).  Does not trigger update().
     */
    watch: function(func) { watches.push(func) },
  };
})();

var unhide_posts;
// Generic buttons to toggle ignored state of a user
var ignore_buttons = (function() {
  var posts;
  var inner_ign = '';
  var inner_unign = '';

  function update_btns(users, state) {
    if (users instanceof Array) {
      for (var i = 0; i < posts.length; i++) {
        posts[i].ignbtn.innerHTML =
            ci_indexof(users, posts[i].user) !== -1 ? inner_unign : inner_ign;
      }
    } else {
      var ul = users.toLowerCase();
      for (var i = 0; i < posts.length; i++) {
        if (!ul.localeCompare(posts[i].user.toLowerCase()))
          posts[i].ignbtn.innerHTML = state ? inner_unign : inner_ign;
      }
    }
  }

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

    // Register posts to have their ignore buttons handled
    register: function(p) {
      posts = p;
      for (var i = 0; i < p.length; i++)
        handlers.ignore_toggle(p[i].ignbtn, p[i].user);
      ignore_list.watch('late', update_btns);
    },
  };
})();
// Handle RES-style tagging of users
var user_tags = (function() {
  var posts;

  function update_badges(n, val) {
    for (var i = 0; i < posts.length; i++) {
      var post = posts[i];
      var k = ci_lookup(val, post.user);
      post.badge.innerHTML = k !== undefined ? val[k] : post.saved_badge;
    }
  }

  function do_prompt(e, user) {
    e.preventDefault();
    e.stopPropagation();

    var tmp =
        (settings.get_sync ? settings.get_sync : 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);
  }

  function gen_prompt(user) {
    return function(e) { do_prompt(e, user); }
  }

  return {
    // Register posts to have their badges handled
    register: function(p) {
      posts = p;
      for (var i = 0; i < p.length; i++) {
        var post = p[i];
        post.saved_badge = post.badge.innerHTML;
        post.badge.addEventListener('click', gen_prompt(post.user));
      }
      settings.watch('user_tags', update_badges);
    },

    update: function() {
      settings.register('user_tags', null, { }, null);
    },
  };
})();
// 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 || db === null) {
      open_db();
      return;
    }
    var cmd;
    while (cmd = cmds.shift()) {
      if (cmd.cmd === 'set') {
        delete cmd.cmd;
        var os = db.transaction(store, 'readwrite').objectStore(store);
        os.get(cmd.id).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);
      }
    }
  }

  // Open idb and maybe remove old threads
  function open_db() {
    if (db === undefined)
      db = null;
    else
      return;
    var req = indexedDB.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();

      /* Periodically scrub the database (FIXME this is awful & may run in
       * multiple tabs simultaneously)
       */
      settings.register('lv_scrub_time', null, 0, null);
      settings.watch('lv_scrub_time', function(n, v) {
        var now = Date.now();
        if (v + 24 * 60 * 60 * 1000 > now)
          return;

        var tr = db.transaction(store, 'readwrite');
        var os = tr.objectStore(store);
        os.index('lvt')
            .openCursor(IDBKeyRange.upperBound(now - 60 * 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);
              }
            };
      });
    };
  }

  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();
    },

    update: open_db,
  };
})();
// 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();
      var d = document.createElement('div');
      var html = '';
      var vs = [];
      for (var i = 0; i < menus.length; i++) {
        var menu = menus[i];
        if (i !== 0)
          html += '<hr></hr>';
        if (menu.title !== undefined)
          html += '<b>' + menu.title + '</b>';
        for (var j = 0; j < menu.vars.length; j++) {
          var v = menu.vars[j];
          vs.push(v);
          html += '<div><input type="checkbox" id="ilcb_' + v + '></input>' +
              '<label for="ilcb_' + v + '>' + s[v].desc + '</label></div>';
        }
      }
      d.innerHTML = html;
      var e = d.firstElementChild;
      var depre = /(!)?(.*)/;
      function h(e, v) {
        handlers.checkbox(e, v);
        var d = s[v].dep;
        if (d) {
          var ds = s[v].dep.match(depre);
          var i = !ds[1];
          d = ds[2];
          settings.watch(d, function(n, v) { e.disabled = v ^ i });
        }
      }
      while (e !== null) {
        if (e.tagName === 'DIV')
          h(e.firstElementChild, vs.shift());
        e = e.nextElementSibling;
      }
      elm.appendChild(d);
    }
  };
})();
// Display a nice menu for managing the ignorelist
var manage_menu = (function() {
  var menu_list;
  var menu_elms = [];
  var remfmt = '';

  function create_elm(user) {
    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>';
    var a = tr.lastElementChild.firstElementChild;
    a.href = 'javascript:void(0)';
    handlers.ignore_toggle(a, user);
    return { user: user.toLowerCase(), elm: tr };
  }

  function find_elm(user) {
    var l = user.toLowerCase();
    for (var i = 0; i < menu_elms.length; i++) {
      if (menu_elms[i].user.localeCompare(l) === 0)
        return i;
    }
  }

  function comp_elm(a, b) { return a.user.localeCompare(b.user) }

  function update(user, state) {
    if (menu_list === undefined)
      return;
    if (user instanceof Array) {
      var frag = document.createDocumentFragment();
      menu_elms = user.map(create_elm).sort(comp_elm);
      for (var i = 0; i < menu_elms.length; i++)
        frag.appendChild(menu_elms[i].elm);
      menu_list.appendChild(frag);
    } else {
      if (state) {
        menu_elms.push(create_elm(user));
        menu_elms.sort(comp_elm);
        var idx = find_elm(user);
        if (idx + 1 < menu_elms.length)
          menu_list.insertBefore(menu_elms[idx].elm, menu_elms[idx + 1].elm);
        else
          menu_list.appendChild(menu_elms[idx].elm);
      } else {
        var idx = find_elm(user);
        menu_elms[idx].elm.remove();
        menu_elms.splice(idx, 1);
      }
    }
  }

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

    // Attach management elements to elm
    register: function(elm) {
      menu_list = document.createElement('table');
      menu_list.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>';
      var td = menu_list.firstElementChild.firstElementChild.firstElementChild;
      handlers.addbox_enter(td.firstElementChild);
      handlers.addbox_click(td.lastElementChild, td.firstElementChild);

      ignore_list.watch('late', update);
      elm.appendChild(menu_list);
    },
  };
})();
// Where are we?
// In the interest of sanity, ftl.thread is true for all thread-like pages
// (i.e. any page with one or more posts); ftl.threadlist is true for all
// threadlist-like pages (i.e. any page with a list of threads).  The
// formatting for all such pages is similar enough to treat them equivalently.
var ftl = {
  // Mobile pages
  mobile: '^https?://m\\.',
  // All forum pages
  forums: '^https?://(m|www)\\.fasttech\\.com/forums',
  // Thread-like pages (i.e. those with posts)
  thread: '^https?://(m|www)\\.fasttech\\.com/forums/[^/]+/t/[0-9]+/.',
  // Permalink pages
  perma: '^https?://(m|www)\\.fasttech\\.com/forums' +
      '/[^/]+/t/[0-9]+/[^/]+\\?[0-9]+$',
  // Threadlist-like pages (i.e. those with thread links)
  threadlist: '^https?://(m|www)\\.fasttech\\.com/forums' +
      '(/[^/]+(/page/[0-9]+)?(/search\\?.*)?)?$',
  // Lists of threads for a single author
  author: '^https?://(m|www)\\.fasttech\\.com/forums/author/',
  // Lists of threads for a single product
  product: '^https?://(m|www)\\.fasttech\\.com/p(roducts?)?/',
  // New thread page for a product
  pnthread: '^https?://(m|www)\\.fasttech\\.com/forums/[0-9]+/post',
  // Forum settings page
  settings: '^https?://www\\.fasttech\\.com/forums/settings$',
};
for (var k in ftl)
  ftl[k] = new RegExp(ftl[k]).test(location.href);
// ftl.editor matches all pages with a post editor
ftl.editor = (ftl.thread && !ftl.perma) ||
    (ftl.threadlist && !ftl.settings) ||
    ftl.pnthread;
// ftl.threadlist matches all pages listing threads
ftl.threadlist = ftl.threadlist || ftl.author || ftl.product || ftl.settings;
// ftl.forums matches all pages with forum elements (threads or posts)
ftl.forums = ftl.forums || ftl.product;

if (ftl.thread || ftl.settings) {
  // 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('placeholders', 'Show placeholders for hidden posts', true,
      null);
  settings.register('hide_quotes', 'Hide posts quoting ignored users', false,
      'hide_ignored');
  settings.register('hide_deleted', 'Hide deleted posts', true, null);
  settings.register('quote_quotes', 'Include quotes in quotes', true, null);
  settings.register('quote_imgs', 'Include images in quotes', true, null);
}
if (ftl.threadlist) {
  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_threads', 'Hide threads by ignored users', false,
      null);
}
if (ftl.editor) {
  settings.register('agree_tou', null, false, null);
}

block.block('ready');
if (ftl.forums) {
  ignore_list.update();
  last_viewed.update();
  if (ftl.thread)
    user_tags.update();
  settings.update();
  block.block('late');
  block.watch('ready',
      //function() { block.watch('ready', function() { block.unblock('late') });
      function() { window.setTimeout(function() { block.unblock('late') }, 0);
      });
}
document.addEventListener('readystatechange', function() {
  if (document.readyState === 'interactive') block.unblock('ready');
});

if (ftl.thread || ftl.editor) {
  /* 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
   */
  var style = document.createElement('style');
  style.innerHTML =
      '.PostContent span[style*="text-align:"] {display:block;width:100%}' +
      'a.SKUAutoLink {display=inline-block}';
  block.watch('ready', function() { document.body.appendChild(style) });
}

// RE to pull a thread id from a URL
if (ftl.forums)
  var thread_id_re = new RegExp('/forums/[^/]+/t/([0-9]+)/[^?]*$');

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;
      }
    }
  })());

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

  // Create a function to create placeholders as needed
  var gen_watch_filter = function(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 this thread's ID
  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;
}

// 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 fix_jtp = function() {
    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;
          });
        }
      }
    }
  };
  block.watch(ftl.forums ? 'late' : 'ready', fix_jtp);
}

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

  // Create a link to a specific page of a thread
  var gen_page_link = function(base, page, hash) {
    var ret = base;
    if (page !== 1)
      ret += '/' + page;
    if (hash)
      ret += '#' + hash;
    return ret;
  };
}
// Add a "Top of page" button at the bottom of the page, aligned with the pager
if ((ftl.thread && !ftl.perma) || (ftl.threadlist && !ftl.settings)) {
  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>';
      tbtn.firstElementChild.href = 'javascript:void(0)';
      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);
      }
    };
  }
  block.watch('ready', add_top_btn);
}

if (ftl.editor) {
  var bbeeditor; // element to scroll to
  var bbetext; // textarea
  var bbeexpand; // expand button (mobile)
  var bbepanel; // parent of editor
  if (!ftl.mobile) {
    var fix_editor = function() {
      if (bbetext === undefined)
        bbetext = document.getElementsByClassName('mbbc-editor')[0];
      if (bbetext !== undefined) {
        // Save & restore the editor text
        if (history.state && history.state.saved_post)
          bbetext.value = history.state.saved_post;
        window.addEventListener('unload', function() {
          var state = history.state;
          state.saved_post = bbetext.value;
          history.replaceState(state, '');
        });

        // 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
        bbetext.style.boxSizing = 'border-box';
        bbetext.style.width = '100%';
        // Add a YouTube button
        var url_btn = document.getElementsByClassName('mbbc-url')[0];
        if (url_btn !== undefined) {
          var yt_btn = document.createElement('li');
          yt_btn.className = 'mbbc-youtube';
          yt_btn.addEventListener('click', function() {
            var sp = bbetext.selectionStart;
            var ep = bbetext.selectionEnd;
            var sel;
            if (ep < sp) {
              sp = ep;
              ep = bbetext.selectionStart;
            }
            if (ep !== sp) {
              sel = bbetext.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;
            bbetext.value = bbetext.value.slice(0, sp) +
                '[youtube]' + sel + '[/youtube]' +
                bbetext.value.slice(ep);
            bbetext.selectionStart = bbetext.selectionEnd = sp + 9 +
                (sel === '' ? 0 : 10 + sel.length);
            bbetext.focus();
          });
          url_btn.parentElement.insertBefore(yt_btn, url_btn);
        }
      }
    };

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

      bbeeditor = document.getElementById('bbEditor');
      if (bbeeditor !== null) {
        bbetext = bbeeditor.getElementsByClassName('mbbc-editor')[0];
        if (bbetext !== undefined)
          fix_editor();
        else
          new MutationObserver(function(es, mo) {
            mo.disconnect();
            fix_editor();
          }).observe(bbeeditor, { childList: true });
      }
    });
  } else {
    block.watch('ready', function() {
      bbetext = document.getElementById('RawContent');
      if (bbetext !== null) {
        bbepanel = bbetext.parentElement.parentElement.parentElement;
        bbeeditor = bbepanel.parentElement;
        bbeexpand = bbepanel.previousElementSibling.firstElementChild
            .firstElementChild;
      }
    });
  }
}

if (ftl.thread && !ftl.perma) {
  // Re-implement quotes in a way that sort of works
  var fix_quote_btns = (function() {

    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.
              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) {
      if (bbetext === null) {
        console.error('bbEditor text box not found!');
        return;
      }
      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 (bbetext.value.length > 0 &&
          bbetext.value[bbetext.value.length - 1] !== '\n')
        bbetext.value += '\n';
      if (thread_id !== undefined && post_id !== undefined)
        bbetext.value += wrap_tag('url', '/forums/-/t/' + thread_id + '?' +
            post_id, user + ' wrote') + ':\n';
      else
        bbetext.value += user + ' wrote:\n';
      bbetext.value += wrap_tag('quote', '', inner_quote(elm, sel, 0)
          .trim().replace(/[\r\n]{3,}/mg, '\n\n')) + '\n';

      if (bbeeditor)
        bbeeditor.scrollIntoView();

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

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

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

    block.watch('late', function() {
      fix_quote_btns(posts, replace_quotebtn);
    });
  } else { // mobile
    // Fix up YT iframe size: CSS doesn't work well here.
    var fr_resize = (function() {
      var frs;
      function handler() {
        for (var i = 0; i < frs.length; i++) {
          var fr = frs[i];
          fr.elm.style.height = Math.ceil(fr.elm.clientWidth * fr.ar) + 'px';
        }
      }
      return function(fr) {
        if (frs === undefined) {
          frs = [];
          window.addEventListener('resize', handler);
        }
        var ar = fr.clientHeight / fr.clientWidth;
        fr.style.maxWidth = fr.clientWidth + 'px';
        fr.style.width = '100%';
        fr.style.height = Math.ceil(fr.clientWidth * ar) + 'px';
        frs.push({elm: fr, ar: ar});
      };
    })();

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

    block.watch('ready', function() {
      var frs = document.getElementsByTagName('iframe');
      for (var i = 0; i < frs.length; i++)
        fr_resize(frs[i]);
    });
    block.watch('late', function() {
      fix_quote_btns(posts, replace_quotebtn_m);

      // 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);
      }
    });
  }
}
if (ftl.thread && !ftl.perma) {
  var scroll_to_hash = function() {
    function scroll(elm) {
      if (elm) {
        var sd = elm.style.display;
        elm.style.display = '';
        elm.scrollIntoView();
        elm.style.display = sd;
      }
    }
    if (location.hash === '#unread' || location.hash === '#last')
      last_viewed.get(thread_id, function(hash, lvp, lpn, lp) {
        block.watch('filter', function() {
          var i;
          var pid = 'POST' + lp;
          for (i = posts.length - 1; i >= 0; i--) {
            if (posts[i].content_elm.id === pid)
              break;
          }
          if (i < posts.length - 1)
            scroll(posts[i + 1].post_elms[0]);
          else if (hash === '#last')
            scroll(posts[posts.length - 1].post_elms[0]);
        });
      }, location.hash);
    else if (location.hash !== '')
      block.watch('filter', function() {
        scroll(document.getElementById(location.hash));
      });
    // Drop the hash so we won't run again
    history.replaceState(history.state, '', location.pathname);
  };
}

if (ftl.threadlist) {
  if (!ftl.mobile) {
    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);
      var skip_margin = false;

      if (pelm === undefined) {
        skip_margin = true;
        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 = document.createElement('a');
        lv.href = gen_page_link(elm.href, last_viewed_page);
        lv.innerHTML = 'Last viewed';
        lv.className = 'FormButton blue';
        lv.style.color = '#fff';
        if (!skip_margin)
          lv.style.marginLeft = '5px';
        pelm.appendChild(lv);
        skip_margin = false;
      }

      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 = document.createElement('a');
        np.style.marginLeft = '5px';
        if (unread) {
          np.innerHTML = 'Unread posts';
          np.className = 'FormButton red';
          np.style.color = '#fff';
          np.href = gen_page_link(elm.href,
              postnr_to_pagenr(last_post_nr, p.last), 'unread');
        } else {
          np.innerHTML = 'No unread posts';
          np.className = 'FormButton white';
          np.href = gen_page_link(elm.href,
              postnr_to_pagenr(last_post_nr, p.last), 'last');
        }
        if (!skip_margin)
          np.style.marginLeft = '5px';
        pelm.appendChild(np);
        skip_margin = false;
      }
    };

    var mangle_list = function() {
      var header = document.getElementsByClassName('ForumHeader')[0];
      var filter = !ftl.settings && !ftl.author && settings.get('hide_threads');
      var sku_re = new RegExp('fasttechcdn.com/[0-9]+/([0-9]+)/.*');
      if (header !== undefined) {
        var ch = header.parentElement.children;
        for (var i = 1; i < ch.length; i += 2) {
          var tr = ch[i];
          var tl = tr.getElementsByClassName('ThreadLink')[0];
          var tid;
          if (tl !== undefined &&
              (settings.get('track_posts') || settings.get('track_viewed'))) {
            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 p = img.parentElement;
              var a = document.createElement('a');
              a.href = '/products/' + sku[1];
              a.appendChild(img);
              p.appendChild(a);
            }
          }
          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';
            }
          }
        }
      }
    };

    block.watch('ready', mangle_list);
  } else { // mobile
    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 = document.createElement('div');
      d.style.marginLeft = '-5px';

      if (settings.get('track_viewed')) {
        var lv = 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';
        d.appendChild(lv);
      }

      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 = document.createElement('span');
        np.className = 'btn btn-default';
        np.style.margin = '5px';
        var npa = document.createElement('a');
        if (unread) {
          npa.innerHTML = 'Unread posts';
          npa.href = gen_page_link(elm.href,
              postnr_to_pagenr(last_post_nr, p.last), 'unread');
          npa.style.color = '#C64148';
        } else {
          npa.innerHTML = 'No unread posts';
          npa.style.color = '#888';
          np.href = gen_page_link(elm.href,
              postnr_to_pagenr(last_post_nr, p.last), 'last');
        }
        np.appendChild(npa);
        d.appendChild(np);
      }

      rh.appendChild(d);
    };

    var mangle_list = function() {
      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;
        if (l !== undefined &&
            (settings.get('track_posts') || settings.get('track_viewed'))) {
          var tid = parseInt(l.href.match(thread_id_re)[1]);
          if (!isNaN(tid))
            last_viewed.get(tid, 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';
        }
      }
    };
    block.watch('ready', function() {
      if (ftl.product) {
        // Wait for thread list to load before mangling
        var div = document.getElementById('forum');
        if (div !== null)
          new MutationObserver(function(es, mo) {
            mo.disconnect();
            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 have_itmenu = false;
  var have_itmanage = false;
  var update_itmanage = function(n, itm) {
    if (!have_itmenu)
      return;
    var div = document.getElementById('IgnorelistPopout2');
    if (div === null)
      return;

    if (!have_itmanage && itm) {
      var frag = document.createDocumentFragment();
      var hr = document.createElement('hr');
      hr.className = 'itmanage';
      frag.appendChild(hr);

      var l = 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';
      frag.appendChild(l);
      div.appendChild(frag);
      have_itmanage = true;
    } else if (have_itmanage) {
      var elms = div.getElementsByClassName('itmanage');
      for (var i = 0; i < elms.length; i++)
        elms[i].style.display = itm ? '' : 'none';
    }
  };
  var update_itmenu = function(n, itm) {
    if (!have_itmenu && itm) {
      var b = document.getElementsByClassName('ThreadCommandBar')[0];
      var sp = document.createElement('span');
      sp.id = 'Ignorelist';
      sp.className = 'ThreadCommand';
      sp.innerHTML = 'Settings';
      b.appendChild(sp);

      block.watch('late', function() {
        var div = document.createElement('div');
        div.id = 'IgnorelistPopout';
        div.className = 'PopoutPanel';
        div.align = 'left';
        settings_menu.register(div, thread_menu);
        b.appendChild(div);

        PM2('Ignorelist');
        PM2('RateThread');
        PM2('ForumTools');

        have_itmenu = true;
        if (settings.get('inthread_manage') !== undefined)
          update_itmanage(undefined, settings.get('inthread_manage'));
      });
    } else if (have_itmenu) {
      var elm = document.getElementById('Ignorelist2');
      if (elm !== null)
        elm.style.display = itm ? '' : 'none';
    }
  };

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

  block.watch('ready', function() {
    // Wire posts & ignorebuttons up
    ignore_buttons.set('+ ignore', '+ unignore');
    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 = document.createElement('a');
      new_a.href = 'javascript:void(0)';
      head.appendChild(new_a);
      post.ignbtn = new_a;
      post.badge = tl.getElementsByClassName('Badges')[0];
    }
    post_filter.register(posts);
    ignore_buttons.register(posts);
    user_tags.register(posts);

    settings.watch('inthread_manage', update_itmanage);
    settings.watch('inthread_menu', update_itmenu);
  });

  block.watch('late', function() {
    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.perma && thread_id !== undefined) {
    if (location.hash !== '')
      scroll_to_hash();
  }
}
if (ftl.mobile && ftl.thread) {
  post_filter.watch(gen_watch_filter(function(post) {
    var tbl = post.post_elms[0].parentElement;
    var ph = post.placeholder = document.createElement('div');
    ph.className = 'col-xs-12 ListRow';
    ph.style.paddingBottom = '5px';
    ph.style.textAlign = 'center';
    ph.innerHTML = '<a>Post by ' + strip_tags(post.user) +
        ' hidden.  Click to show.</a>';
    ph.firstElementChild.href = 'javascript:void(0)';
    handlers.hide_toggle(ph.firstElementChild, post);
    tbl.insertBefore(ph, post.post_elms[0]);
    return ph;
  }));
  settings.watch('placeholders', post_filter.update);

  ignore_buttons.set(' Ignore', ' Unignore');

  var have_itmenu = false;
  var have_itmanage = false;
  var update_itmanage = function(n, itm) {
    if (!have_itmenu)
      return;
    var menu = document.getElementById('hideSettings');
    if (menu === null)
      return;
    if (!have_itmanage && itm) {
      var frag = document.createDocumentFragment();
      var inner = menu.getElementsByClassName('panel-body')[0];
      var hr = document.createElement('hr');
      hr.className = 'itmanage';
      frag.appendChild(hr);
      var l = 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();
      frag.appendChild(l);
      inner.appendChild(frag);
      have_itmanage = true;
    } else if (have_itmanage) {
      var elms = menu.getElementsByClassName('itmanage');
      for (var i = 0; i < elms.length; i++)
        elms[i].style.display = itm ? '' : 'none';
    }
  };
  var update_itmenu = function(n, itm) {
    if (!have_itmenu && itm) {
      var div = 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>';
      block.watch('late', function() {
        var inner = div.getElementsByClassName('panel-body')[0];
        settings_menu.register(inner, thread_menu);
        document.getElementsByClassName('body-content')[0].appendChild(div);

        have_itmenu = true;
        if (settings.get('inthread_manage') !== undefined)
          update_itmanage(undefined, settings.get('inthread_manage'));
      });
    } else if (have_itmenu) {
      document.getElementById('itmenu').style.display = itm ? '' : 'none';
    }
  };

  block.watch('ready', function() {
    for (var i = 0; i < posts.length; i++) {
      var post = posts[i];
      var pe = post.content_elm.parentElement;
      post.post_elms = [pe];
      var menu = pe.firstElementChild.lastElementChild;

      var li = document.createElement('li');
      li.setAttribute('role', 'presentation');
      li.innerHTML = '<a tabindex="-1" role="menuitem">' +
          '<span class="glyphicon glyphicon-plus red"></span><span></span></a>';
      li.firstElementChild.href = 'javascript:void(0)';
      var rp = menu.lastElementChild;
      if (!rp.firstElementChild.lastChild.data.startsWith(' Report'))
        rp = rp.previousElementSibling;
      menu.insertBefore(li, rp);
      post.ignbtn = li.firstElementChild.lastElementChild;

      var badge = menu.firstElementChild.firstElementChild;
      var m = badge.innerHTML.match(/(.*)\((.*)\)/);
      badge.innerHTML = m[1] + '(<span>' + m[2] + '</span>)';
      post.badge = badge.lastElementChild;
    }
    post_filter.register(posts);
    ignore_buttons.register(posts);
    user_tags.register(posts);

    settings.watch('inthread_manage', update_itmanage);
    settings.watch('inthread_menu', update_itmenu);
  });

  block.watch('late', function() {
    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);
  });

  if (!ftl.perma && thread_id !== undefined) {
    if (location.hash !== '')
      scroll_to_hash();
  }
}
if (ftl.settings) {
  block.watch('ready', function() {
    var panel = document.getElementsByClassName('PageContentPanel')[0];
    var div = document.createElement('div');
    div.style.marginTop = '5px';
    var tbl = 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>';
    div.appendChild(tbl);
    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 Forum Enhancements Settings';
    cells[5].style.padding = '0px';
    var menu = document.createElement('div');
    menu.className = 'BGShadow';
    menu.style.padding = '10px';
    settings_menu.register(menu, full_menus);
    cells[5].appendChild(menu);

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

    panel.appendChild(div);
  });
}