/ js / catalog.js
catalog.js
   1  var $ = {};
   2  
   3  $.id = function(id) {
   4    return document.getElementById(id);
   5  };
   6  
   7  $.cls = function(klass, root) {
   8    return (root || document).getElementsByClassName(klass);
   9  };
  10  
  11  $.tag = function(tag, root) {
  12    return (root || document).getElementsByTagName(tag);
  13  };
  14  
  15  $.extend = function(destination, source) {
  16    for (var key in source) {
  17      destination[key] = source[key];
  18    }
  19  };
  20  
  21  $.on = function(n, e, h) {
  22    n.addEventListener(e, h, false);
  23  };
  24  
  25  $.off = function(n, e, h) {
  26    n.removeEventListener(e, h, false);
  27  };
  28  
  29  $.readCookie = function(name) {
  30    var i, c, ca, key;
  31    
  32    key = name + '=';
  33    ca = document.cookie.split(';');
  34    
  35    for (i = 0; c = ca[i]; ++i) {
  36      while (c.charAt(0) == ' ') {
  37        c = c.substring(1, c.length);
  38      }
  39      if (c.indexOf(key) === 0) {
  40        return decodeURIComponent(c.substring(key.length, c.length));
  41      }
  42    }
  43    return null;
  44  };
  45  
  46  if (!document.documentElement.classList) {
  47    $.hasClass = function(el, klass) {
  48      return (' ' + el.className + ' ').indexOf(' ' + klass + ' ') != -1;
  49    };
  50    
  51    $.addClass = function(el, klass) {
  52      el.className = (el.className === '') ? klass : el.className + ' ' + klass;
  53    };
  54    
  55    $.removeClass = function(el, klass) {
  56      el.className = (' ' + el.className + ' ').replace(' ' + klass + ' ', '');
  57    };
  58  }
  59  else {
  60    $.hasClass = function(el, klass) {
  61      return el.classList.contains(klass);
  62    };
  63    
  64    $.addClass = function(el, klass) {
  65      el.classList.add(klass);
  66    };
  67    
  68    $.removeClass = function(el, klass) {
  69      el.classList.remove(klass);
  70    };
  71  }
  72  
  73  $.toggleClass = function(el, klass) {
  74    if ($.hasClass(el, klass)) {
  75      $.removeClass(el, klass);
  76    }
  77    else {
  78      $.addClass(el, klass);
  79    }
  80  };
  81  
  82  var UA = {};
  83  
  84  UA.init = function() {
  85    document.head = document.head || $.tag('head')[0];
  86    
  87    this.hasContextMenu = 'HTMLMenuItemElement' in window;
  88    
  89    this.hasWebStorage = (function() {
  90      var kv = 'catalog';
  91      try {
  92        localStorage.setItem(kv, kv);
  93        localStorage.removeItem(kv);
  94        return true;
  95      } catch (e) {
  96        return false;
  97      }
  98    })();
  99    
 100    this.hasSessionStorage = (function() {
 101      var kv = 'catalog';
 102      try {
 103        sessionStorage.setItem(kv, kv);
 104        sessionStorage.removeItem(kv);
 105        return true;
 106      } catch (e) {
 107        return false;
 108      }
 109    })();
 110    
 111    this.hasCORS = 'withCredentials' in new XMLHttpRequest();
 112    
 113    this.isMobileDevice = /Mobile|Android|Dolfin|Opera Mobi|PlayStation Vita|Nintendo DS/.test(navigator.userAgent);
 114  };
 115  
 116  UA.dispatchEvent = function(name, detail) {
 117    var e = document.createEvent('Event');
 118    e.initEvent(name, false, false);
 119    if (detail) {
 120      e.detail = detail;
 121    }
 122    document.dispatchEvent(e);
 123  };
 124  
 125  var FC = function() {
 126    
 127    var self = this,
 128    
 129    options = {
 130      orderby: 'alt',
 131      large: false,
 132      extended: true,
 133      imgdel: '//s.4cdn.org/image/filedeleted-res.gif',
 134      imgspoiler: '//s.4cdn.org/image/spoiler',
 135      nofile: '//s.4cdn.org/image/nofile.png',
 136      smallsize: 150,
 137      tipdelay: 250,
 138      filterColors: [
 139        ['#E0B0FF', '#F2F3F4', '#7DF9FF', '#FFFF00'],
 140        ['#FBCEB1', '#FFBF00', '#ADFF2F', '#0047AB'],
 141        ['#00A550', '#007FFF', '#AF0A0F', '#B5BD68']
 142      ]
 143    },
 144    
 145    capcodeMap = {
 146      admin: 'Administrator',
 147      mod: 'Moderator',
 148      developer: 'Developer',
 149      manager: 'Manager',
 150      founder: 'Founder',
 151      verified: 'Verified'
 152    },
 153    
 154    keybinds = {
 155      83: focusQuickfilter, // S
 156      82: refreshWindow, // R
 157      88: cycleOrder // X
 158    },
 159    
 160    catalog = {},
 161    
 162    basicSettings = [ 'orderby', 'large', 'extended' ],
 163    
 164    activeTheme = {},
 165    
 166    activeStyleGroup,
 167    activeStyleSheet,
 168    
 169    activeFilters = {},
 170    
 171    hasTooltip = false,
 172    tooltipTimeout = null,
 173    
 174    pinnedThreads = {},
 175    
 176    hiddenThreads = {},
 177    hiddenThreadsCount = 0,
 178    
 179    filteredThreadsCount = 0,
 180    
 181    hasThreadWatcher = false,
 182    hasDropDownNav = false,
 183    hasClassicNav = false,
 184    hasAutoHideNav = false,
 185    altCaptcha = false,
 186    
 187    quickFilterPattern = false,
 188    
 189    hiddenMode = false,
 190    
 191    $threads,
 192    $qfCtrl,
 193    $teaserCtrl,
 194    $sizeCtrl,
 195    $orderCtrl,
 196    $filterPalette,
 197    
 198    ctxCmds;
 199    
 200    if (window.devicePixelRatio >= 2) {
 201      options.imgdel.replace('.', '@2x.');
 202      options.nofile.replace('.', '@2x.');
 203    }
 204    
 205    UA.init();
 206    
 207    loadTheme();
 208    
 209    self.init = function() {
 210      var extConfig, el, val, fn;
 211      
 212      FC.hasMobileLayout = checkMobileLayout();
 213      
 214      applyTheme(activeTheme, true);
 215      
 216      $threads = $.id('threads');
 217      $qfCtrl = $.id('qf-ctrl');
 218      $teaserCtrl = $.id('teaser-ctrl');
 219      $sizeCtrl = $.id('size-ctrl');
 220      $orderCtrl = $.id('order-ctrl');
 221      
 222      $.on($qfCtrl, 'click', toggleQuickfilter);
 223      $.on($.id('filters-clear-hidden'), 'click', toggleHiddenThreads);
 224      $.on($.id('filters-clear-hidden-bottom'), 'click', toggleHiddenThreads);
 225      $.on($.id('qf-clear'), 'click', toggleQuickfilter);
 226      $.on($.id('settingsWindowLink'), 'click', showThemeEditor);
 227      $.on($.id('settingsWindowLinkBot'), 'click', showThemeEditor);
 228      $.on($.id('settingsWindowLinkMobile'), 'click', showThemeEditor);
 229      $.on($.id('filters-ctrl'), 'click', showFilters);
 230      $.on($teaserCtrl, 'change', onTeaserChange);
 231      $.on($sizeCtrl, 'change', onSizeChange);
 232      $.on($orderCtrl, 'change', onOrderChange);
 233      $.on($threads, 'mouseover', onThreadMouseOver);
 234      $.on($threads, 'mouseout', onThreadMouseOut);
 235      $.on($.id('togglePostFormLinkMobile'), 'click', togglePostFormMobile);
 236      $.on(document, 'click', onClick);
 237      
 238      loadSettings();
 239      
 240      bindGlobalShortcuts();
 241      
 242      initGlobalMessage();
 243      
 244      if (UA.hasContextMenu) {
 245        buildContextMenu();
 246      }
 247      
 248      window.Config = {};
 249      
 250      if (UA.hasWebStorage) {
 251        if (extConfig = localStorage.getItem('4chan-settings')) {
 252          extConfig = JSON.parse(extConfig);
 253          
 254          window.Config = extConfig;
 255          
 256          if (!extConfig.disableAll) {
 257            CustomMenu.initCtrl(extConfig.dropDownNav, extConfig.classicNav);
 258            /*
 259            if (location.host === 'boards.4channel.org' && extConfig.showNWSBoards) {
 260              CustomMenu.showNWSBoards();
 261            }
 262            */
 263            if (extConfig.filter) {
 264              ThreadWatcher.hasFilters = true;
 265            }
 266            
 267            if (extConfig.threadWatcher) {
 268              hasThreadWatcher = true;
 269              ThreadWatcher.init();
 270            }
 271            
 272            if (extConfig.customMenu) {
 273              CustomMenu.apply(extConfig.customMenuList);
 274            }
 275            
 276            if (extConfig.dropDownNav !== false && !FC.hasMobileLayout) {
 277              hasDropDownNav = true;
 278              hasClassicNav = extConfig.classicNav;
 279              hasAutoHideNav = extConfig.autoHideNav;
 280              showDropDownNav();
 281            }
 282            
 283            altCaptcha = extConfig.altCaptcha;
 284          }
 285        }
 286        else if (UA.isMobileDevice && !FC.hasMobileLayout) {
 287          hasDropDownNav = true;
 288          showDropDownNav();
 289        }
 290        else {
 291          CustomMenu.initCtrl(false, false);
 292        }
 293        
 294        if (window.css_event && activeStyleSheet === '_special') {
 295          fn = window['fc_' + window.css_event + '_init'];
 296          fn && fn();
 297        }
 298      }
 299      
 300      if (el = document.forms.post.flag) {
 301        if ((val = $.readCookie('4chan_flag')) && (el = el.querySelector('option[value="' + val + '"]'))) {
 302          el.setAttribute('selected', 'selected');
 303        }
 304      }
 305      
 306      setOrder(options.orderby, true);
 307      setLarge(options.large, true);
 308      setExtended(options.extended, true);
 309      
 310      UA.dispatchEvent('4chanMainInit');
 311    };
 312    
 313    function showDropDownNav() {
 314      var el, top, bottom;
 315      
 316      top = $.id('boardNavDesktop');
 317      bottom = $.id('boardNavDesktopFoot');
 318      
 319      if (hasClassicNav) {
 320        el = document.createElement('div');
 321        el.className = 'pageJump';
 322        el.innerHTML = '<a href="#bottom">&#9660;</a>'
 323          + '<a href="javascript:void(0);" id="settingsWindowLinkClassic">Settings</a>'
 324          + '<a href="//www.' + $L.d(catalog.slug) + '" target="_top">Home</a></div>';
 325        
 326        top.appendChild(el);
 327        
 328        $.id('settingsWindowLinkClassic')
 329          .addEventListener('click', showThemeEditor, false);
 330        
 331        $.addClass(top, 'persistentNav');
 332      }
 333      else {
 334        top.style.display = 'none';
 335        $.removeClass($.id('boardNavMobile'), 'mobile');
 336      }
 337      
 338      if (hasAutoHideNav) {
 339        StickyNav.init(hasClassicNav);
 340      }
 341      
 342      bottom.style.display = 'none';
 343      
 344      $.addClass(document.body, 'hasDropDownNav');
 345    }
 346    
 347    function hideDropDownNav() {
 348      var el, top, bottom;
 349      
 350      top = $.id('boardNavDesktop');
 351      bottom = $.id('boardNavDesktopFoot');
 352      
 353      if (hasClassicNav) {
 354        if (el = $.cls('pageJump', top)[0]) {
 355          $.id('settingsWindowLinkClassic')
 356            .removeEventListener('click', showThemeEditor, false);
 357          top.removeChild(el);
 358        }
 359        
 360        $.removeClass(top, 'persistentNav');
 361      }
 362      else {
 363        top.style.display = '';
 364        $.addClass($.id('boardNavMobile'), 'mobile');
 365      }
 366      
 367      if (hasAutoHideNav) {
 368        StickyNav.destroy(hasClassicNav);
 369      }
 370      
 371      bottom.style.display = '';
 372      
 373      $.removeClass(document.body, 'hasDropDownNav');
 374    }
 375    
 376    self.loadCatalog = function(c) {
 377      var query;
 378      
 379      catalog = c;
 380      
 381      $.addClass(document.body, activeStyleSheet.toLowerCase().replace(/ /g, '_'));
 382      
 383      initStyleSwitcher();
 384      loadFilters();
 385      loadStorage();
 386      
 387      if (UA.hasSessionStorage && !location.hash && (query = sessionStorage.getItem('4chan-catalog-search'))) {
 388        if (catalog.slug != sessionStorage.getItem('4chan-catalog-search-board')) {
 389          sessionStorage.removeItem('4chan-catalog-search');
 390          sessionStorage.removeItem('4chan-catalog-search-board');
 391          query = null;
 392        }
 393      }
 394      else if (location.hash && (query = location.hash.match(/#s=(.+)/))) {
 395        query = decodeURIComponent(query[1].replace(/\+/g, ' '));
 396      }
 397      
 398      if (query) {
 399        toggleQuickfilter();
 400        $.id('qf-box').value = query;
 401        applyQuickfilter();
 402      }
 403      else {
 404        buildThreads();
 405      }
 406    };
 407    
 408    function initGlobalMessage() {
 409      var msg, btn, thisTs, oldTs;
 410      
 411      if (!UA.hasWebStorage || FC.hasMobileLayout) {
 412        return;
 413      }
 414      
 415      if ((msg = $.id('globalMessage')) && msg.textContent) {
 416        msg.nextElementSibling.style.clear = 'both';
 417        
 418        btn = document.createElement('span');
 419        btn.id = 'toggleMsgBtn';
 420        btn.setAttribute('data-cmd', 'toggleMsg');
 421        btn.title = 'Toggle announcement';
 422        
 423        oldTs = localStorage.getItem('4chan-global-msg');
 424        thisTs = msg.getAttribute('data-utc');
 425        
 426        if (oldTs && thisTs <= oldTs) {
 427          msg.style.display = 'none';
 428          btn.style.opacity = '0.5';
 429          btn.className = 'expandIcon';
 430        }
 431        else {
 432          btn.className = 'collapseIcon';
 433        }
 434        
 435        $.on(btn, 'click', toggleGlobalCatalogMessage);
 436        msg.parentNode.insertBefore(btn, msg);
 437      }
 438    }
 439    
 440    function toggleGlobalCatalogMessage() {
 441      var msg, btn;
 442      
 443      msg = $.id('globalMessage');
 444      btn = $.id('toggleMsgBtn');
 445      if (msg.style.display == 'none') {
 446        msg.style.display = '';
 447        btn.className = 'collapseIcon';
 448        btn.style.opacity = '1';
 449        localStorage.removeItem('4chan-global-msg');
 450      }
 451      else {
 452        msg.style.display = 'none';
 453        btn.className = 'expandIcon';
 454        btn.innerHTML = '<span class="mobile">View Important Announcement</span>';
 455        btn.style.opacity = '0.5';
 456        localStorage.setItem('4chan-global-msg', msg.getAttribute('data-utc'));
 457      }
 458      
 459      //StorageSync.sync('4chan-global-msg');
 460    }
 461    
 462    function togglePostFormMobile() {
 463      var el = document.getElementById('postForm');
 464      
 465      if (el.style.display == 'table') {
 466        el.style.display = '';
 467        this.textContent = 'Start a New Thread';
 468      }
 469      else {
 470        el.style.display = 'table';
 471        this.textContent = 'Close Post Form';
 472        window.initRecaptcha();
 473        window.initTCaptcha();
 474      }
 475    }
 476    
 477    function getRegexSpecials() {
 478      var specials = ['/', '.', '*', '+', '?', '(', ')', '[', ']', '{', '}', '\\' ];
 479      return new RegExp('(\\' + specials.join('|\\') + ')', 'g');
 480    }
 481    
 482    function getThreadPage(tid) {
 483      return (0 | (catalog.threads[tid].b / catalog.pagesize)) + 1;
 484    }
 485    
 486    function initStyleSwitcher() {
 487      var i, selector, nodes, ss;
 488      
 489      selector = $.id('styleSelector');
 490      nodes = selector.children;
 491      
 492      for (i = 0; ss = nodes[i]; ++i) {
 493        if (ss.value == activeStyleSheet) {
 494          selector.selectedIndex = i;
 495        }
 496      }
 497      
 498      $.on(selector, 'change', onStyleSheetChange);
 499    }
 500    
 501    function onStyleSheetChange() {
 502      var expires;
 503      
 504      if (this.value !== '_special') {
 505        expires = new Date();
 506        
 507        expires.setTime(expires.getTime() + 31536000000);
 508        
 509        document.cookie = activeStyleGroup + '=' + this.value + '; expires='
 510          + expires.toGMTString() + '; path=/; domain=' + $L.d(catalog.slug);
 511        
 512        if (window.css_event) {
 513          fn = window['fc_' + window.css_event + '_cleanup'];
 514          localStorage.setItem('4chan_stop_css_event', `${window.css_event}-${window.css_event_v}`);
 515        }
 516      }
 517      else if (window.css_event) {
 518        fn = window['fc_' + window.css_event + '_init'];
 519        localStorage.removeItem('4chan_stop_css_event');
 520      }
 521      
 522      //StorageSync.sync('4chan_stop_css_event');
 523      
 524      refreshWindow();
 525    }
 526    
 527    function refreshWindow(e) {
 528      if (e && e.shiftKey) {
 529        return;
 530      }
 531      location.href = location.href;
 532    }
 533    
 534    function debounce(delay, fn) {
 535      var timeout;
 536      
 537      return function() {
 538        var args = arguments, context = this;
 539        
 540        clearTimeout(timeout);
 541        timeout = setTimeout(function () {
 542          fn.apply(context, args);
 543        }, delay);
 544      };
 545    }
 546    
 547    function focusQuickfilter() {
 548      if ($.hasClass($qfCtrl, 'active')) {
 549        clearQuickfilter(true);
 550      }
 551      else {
 552        toggleQuickfilter();
 553      }
 554    }
 555    
 556    function toggleQuickfilter() {
 557      var qfBox, qfcnt = $.id('qf-cnt');
 558      if ($.hasClass($qfCtrl, 'active')) {
 559        clearQuickfilter();
 560        qfcnt.style.display = 'none';
 561        $.removeClass($qfCtrl, 'active');
 562      }
 563      else {
 564        qfcnt.style.display = 'inline';
 565        qfBox = $.id('qf-box');
 566        if (!qfcnt.hasAttribute('data-built')) {
 567          qfcnt.setAttribute('data-built', '1');
 568          $.on(qfBox, 'keyup', debounce(250, applyQuickfilter));
 569          $.on(qfBox, 'keydown', function(e) {
 570            if (e.keyCode == '27') {
 571              toggleQuickfilter();
 572            }
 573          });
 574        }
 575        qfBox.focus();
 576        qfBox.value = '';
 577        $.addClass($qfCtrl, 'active');
 578      }
 579    }
 580    
 581    function applyQuickfilter() {
 582      var regexEscape, qfstr;
 583      
 584      if ((qfstr = $.id('qf-box').value) !== '') {
 585        if (UA.hasSessionStorage) {
 586          sessionStorage.setItem('4chan-catalog-search', qfstr);
 587          sessionStorage.setItem('4chan-catalog-search-board', catalog.slug);
 588        }
 589        regexEscape = getRegexSpecials();
 590        $.id('search-term').textContent = $.id('search-term-bottom').textContent = qfstr;
 591        $.id('search-label').style.display = $.id('search-label-bottom').style.display = 'inline';
 592        qfstr = qfstr.replace(regexEscape, '\\$1');
 593        quickFilterPattern = new RegExp(qfstr, 'i');
 594        buildThreads();
 595      } else {
 596        clearQuickfilter();
 597      }
 598    }
 599    
 600    function clearQuickfilter(focus) {
 601      var qf = $.id('qf-box');
 602      $.id('search-label').style.display = $.id('search-label-bottom').style.display = 'none';
 603      if (focus) {
 604        qf.value = '';
 605        qf.focus();
 606      }
 607      else {
 608        if (UA.hasSessionStorage) {
 609          sessionStorage.removeItem('4chan-catalog-search');
 610        }
 611        quickFilterPattern = false;
 612        buildThreads();
 613      }
 614    }
 615    
 616    function buildContextMenu() {
 617      ctxCmds = {
 618        pin: toggleThreadPin,
 619        hide: toggleThreadHide,
 620        report: reportThread
 621      };
 622      
 623      $.id('ctxmenu-main').innerHTML = 
 624        '<menuitem label="Unpin all threads"></menuitem>';
 625      
 626      $.id('ctxmenu-thread').innerHTML = 
 627        '<menuitem label="Pin/Unpin" data-cmd="pin"></menuitem>' +
 628        '<menuitem label="Hide/Unhide" data-cmd="hide"></menuitem>' +
 629        '<menuitem label="Report" data-cmd="report"></menuitem>';
 630      
 631      $.on($.id('ctxmenu-main'), 'click', clearPinnedThreads);
 632      $.on($.id('ctxmenu-thread'), 'click', onThreadContextClick);
 633    }
 634    
 635    function bindGlobalShortcuts() {
 636      var el, tid;
 637      if (UA.hasWebStorage) {
 638        $.on($threads, 'mousedown', function(e) {
 639          el = e.target;
 640          if (el.className.indexOf('thumb') != -1) {
 641            tid = el.getAttribute('data-id');
 642            if (e.which == 3) {
 643              $threads.setAttribute('contextmenu', 'ctxmenu-thread');
 644              $.id('ctxmenu-thread').target = tid;
 645            }
 646            else if (e.which == 1 && e.altKey) {
 647              toggleThreadPin(tid);
 648              return false;
 649            }
 650            else if (e.which == 1 && e.shiftKey) {
 651              toggleThreadHide(tid);
 652              return false;
 653            }
 654          }
 655          else if (e.which == 3) {
 656            $threads.setAttribute('contextmenu', 'ctxmenu-main');
 657          }
 658        });
 659      }
 660      if (!activeTheme.nobinds) {
 661        $.on(document, 'keyup', processKeybind);
 662      }
 663    }
 664    
 665    function toggleThreadPin(tid) {
 666      if (pinnedThreads[tid] >= 0) {
 667        delete pinnedThreads[tid];
 668      }
 669      else {
 670        pinnedThreads[tid] = catalog.threads[tid].r || 0;
 671      }
 672      localStorage.setItem('4chan-pin-' + catalog.slug, JSON.stringify(pinnedThreads));
 673      buildThreads();
 674    }
 675    
 676    function toggleThreadHide(tid) {
 677      if (hiddenMode) {
 678        delete hiddenThreads[tid];
 679        --hiddenThreadsCount;
 680      }
 681      else {
 682        hiddenThreads[tid] = true;
 683        ++hiddenThreadsCount;
 684      }
 685      
 686      localStorage.setItem('4chan-hide-t-' + catalog.slug, JSON.stringify(hiddenThreads));
 687      
 688      $.id('thread-' + tid).style.display = 'none';
 689      
 690      setHiddenCount(hiddenThreadsCount);
 691      
 692      if (hiddenThreadsCount === 0) {
 693        setHiddenMode(false);
 694      }
 695    }
 696    
 697    function setHiddenMode(state) {
 698      hiddenMode = state;
 699      
 700      $.id('filters-clear-hidden').textContent =
 701        $.id('filters-clear-hidden-bottom').textContent = state ? 'Back' : 'Show';
 702      
 703      buildThreads();
 704    }
 705    
 706    function setProcessedCount(type, num) {
 707      var label = type + '-label', count = type + '-count';
 708      
 709      if (num > 0) {
 710        $.id(count).textContent = $.id(count + '-bottom').textContent = num;
 711        $.id(label).style.display = $.id(label + '-bottom').style.display = 'inline';
 712      }
 713      else {
 714        $.id(label).style.display = $.id(label + '-bottom').style.display = 'none';
 715      }
 716    }
 717    
 718    function setHiddenCount(num) {
 719      setProcessedCount('hidden', num);
 720    }
 721    
 722    function setFilteredCount(num) {
 723      setProcessedCount('filtered', num);
 724    }
 725    
 726    function reportThread(tid) {
 727      var height, altc;
 728      
 729      if (window.passEnabled || !window.grecaptcha) {
 730        height = 175;
 731      }
 732      else if (altCaptcha) {
 733        height = 320;
 734        altc = '&altc=1';
 735      }
 736      else {
 737        height = 510;
 738        altc = '';
 739      }
 740      
 741      window.open(
 742        'https://sys.' + $L.d(catalog.slug) + '/' + catalog.slug +
 743        '/imgboard.php?mode=report&no=' + tid + altc,
 744        Date.now(), 
 745        'toolbar=0,scrollbars=1,location=0,status=1,menubar=0,resizable=1,width=380,height=' + height
 746      );
 747    }
 748    
 749    function onThreadContextClick(e) {
 750      var cmd = e.target.getAttribute('data-cmd');
 751      ctxCmds[cmd]($.id('ctxmenu-thread').target);
 752    }
 753    
 754    function processKeybind(e) {
 755      var el = e.target;
 756      if (el.nodeName == 'TEXTAREA' || el.nodeName == 'INPUT') {
 757        return;
 758      }
 759      if (keybinds[e.keyCode]) {
 760        keybinds[e.keyCode](e);
 761      }
 762    }
 763    
 764    function toggleHiddenThreads(e) {
 765      var el;
 766      
 767      e.preventDefault();
 768      
 769      if (hiddenThreadsCount > 0) {
 770        if ((el = $.id('filters-clear-hidden')).textContent == 'Show') {
 771          setHiddenMode(true);
 772        }
 773        else {
 774          setHiddenMode(false);
 775        }
 776      }
 777    }
 778    
 779    function clearPinnedThreads() {
 780      pinnedThreads = {};
 781      localStorage.removeItem('4chan-pin-' + catalog.slug);
 782      buildThreads();
 783      return false;
 784    }
 785    
 786    function onClick(e) {
 787      var t = e.target, tid;
 788      
 789      if ((t = e.target) == document) {
 790        return;
 791      }
 792      
 793      if (tid = t.getAttribute('data-watch')) {
 794        ThreadWatcher.toggle(
 795          tid,
 796          catalog.slug,
 797          catalog.threads[tid].sub,
 798          catalog.threads[tid].teaser,
 799          catalog.threads[tid].lr.id
 800        );
 801      }
 802      else if (tid = t.getAttribute('data-hide')) {
 803        e.preventDefault();
 804        toggleThreadHide(tid);
 805      }
 806      else if (tid = t.getAttribute('data-pin')) {
 807        e.preventDefault();
 808        toggleThreadPin(tid);
 809      }
 810      else if (tid = t.getAttribute('data-report')) {
 811        e.preventDefault();
 812        reportThread(tid);
 813      }
 814      else if (tid = t.getAttribute('data-post-menu')) {
 815        e.preventDefault();
 816        PostMenu.open(t, tid, hasThreadWatcher, hiddenThreads[tid], pinnedThreads[tid]);
 817      }
 818      else if (t.hasAttribute('data-cm-edit')) {
 819        e.preventDefault();
 820        CustomMenu.showEditor(true);
 821      }
 822      else if (t.id == 'backdrop') {
 823        if (!panelHidden($.id('filters'))) {
 824          if (!panelHidden($.id('filters-protip'))) {
 825            closeFiltersHelp();
 826          }
 827          else {
 828            closeFilters();
 829          }
 830        }
 831        else if (!panelHidden($.id('theme'))) {
 832          closeThemeEditor();
 833        }
 834      }
 835      else if (e.target.id == 'filter-palette') {
 836        closeFilterPalette();
 837      }
 838    }
 839    
 840    function buildFilterPalette() {
 841      var i, j, table, palette, rows, cols, tr, td, foot;
 842      
 843      $filterPalette = $.id('filter-palette');
 844      
 845      table = $.id('filter-color-table');
 846      palette = $.tag('tbody', table)[0];
 847      rows = options.filterColors.length;
 848      
 849      if (rows > 0) {
 850        cols = options.filterColors[0].length;
 851        foot = $.tag('tfoot', table)[0];
 852        for (i = foot.children.length - 1; i >= 0; i--) {
 853          foot.children[i].firstElementChild.setAttribute('colspan', cols);
 854        }
 855      }
 856      for (i = 0; i < rows; ++i) {
 857        tr = document.createElement('tr');
 858        for (j = 0; j < cols; ++j) {
 859          td = document.createElement('td');
 860          td.innerHTML = '<span class="button clickbox" style="background:'
 861            + options.filterColors[i][j] + '"></span>';
 862          $.on(td.firstElementChild, 'click', selectFilterColor);
 863          tr.appendChild(td);
 864        }
 865        palette.appendChild(tr);
 866      }
 867    }
 868    
 869    function showFilterPalette(el) {
 870      var picker, pos = el.getBoundingClientRect();
 871      
 872      if (!$filterPalette) {
 873        buildFilterPalette();
 874      }
 875      
 876      $.removeClass($filterPalette, 'hidden');
 877      $filterPalette.setAttribute('data-target', el.id.split('-')[2]);
 878      
 879      picker = $filterPalette.firstElementChild;
 880      picker.style.cssText = 'top:' + pos.top + 'px;left:'
 881        + (pos.left - picker.clientWidth - 10) + 'px;';
 882    }
 883    
 884    function showFiltersHelp() {
 885      var el = $.id('filters-protip');
 886      el.style.top = window.pageYOffset + 50 + 'px';
 887      $.removeClass(el, 'hidden');
 888    }
 889    
 890    function closeFiltersHelp() {
 891      $.addClass($.id('filters-protip'), 'hidden');
 892    }
 893    
 894    function onFiltersClick(e) {
 895      var t = e.target;
 896      
 897      if (t.id == 'filters-close')
 898        closeFilters();
 899      else if (t.id == 'filters-add')
 900        addEmptyFilter();
 901      else if (t.id == 'filters-save') {
 902        saveFilters();
 903        closeFilters();
 904      }
 905      else if (t.hasAttribute('data-active'))
 906        toggleFilter(t, 'active');
 907      else if (t.hasAttribute('data-hide'))
 908        toggleFilter(t, 'hide', 'top');
 909      else if (t.hasAttribute('data-top'))
 910        toggleFilter(t, 'top', 'hide');
 911      else if ($.hasClass(t, 'filter-color'))
 912        showFilterPalette(t);
 913      else if (t.hasAttribute('data-target'))
 914        deleteFilter(t);
 915      else if (t.hasAttribute('data-up'))
 916        moveFilterUp(t);
 917    }
 918    
 919    function moveFilterUp(el) {
 920      var tr, prev;
 921      
 922      tr = el.parentNode.parentNode;
 923      prev = tr.previousElementSibling;
 924      
 925      if (prev) {
 926        tr.parentNode.insertBefore(tr, prev);
 927      }
 928    }
 929    
 930    function onFiltersSearch(e) {
 931      var i, el, nodes, cnt, str;
 932      
 933      if (e && (e.keyCode == 27)) {
 934        this.value = '';
 935      }
 936      
 937      str = this.value.toLowerCase();
 938      
 939      nodes = document.getElementsByClassName('filter-pattern');
 940      
 941      cnt = document.getElementById('filter-list');
 942      
 943      cnt.style.display = 'none';
 944      
 945      for (i = 0; el = nodes[i]; ++i) {
 946        if (el.value.toLowerCase().indexOf(str) === -1) {
 947          el.parentNode.parentNode.style.display = 'none';
 948        }
 949        else {
 950          el.parentNode.parentNode.style.display = '';
 951        }
 952      }
 953      
 954      cnt.style.display = '';
 955    }
 956    
 957    function showFilters() {
 958      var i, filtersPanel, rawFilters, filterList, filterId, el;
 959      
 960      filtersPanel = $.id('filters');
 961      
 962      if (!filtersPanel) {
 963        filtersPanel = FC.panelHTML.build('filters', 'panel hidden');
 964        FC.panelHTML.build('filters-protip', 'panel hidden');
 965        FC.panelHTML.build('filter-palette', 'hidden');
 966      }
 967      
 968      if (!filtersPanel.hasAttribute('data-built')) {
 969        $.on(filtersPanel, 'click', onFiltersClick);
 970        
 971        $.on($.id('filter-palette-close'), 'click', closeFilterPalette);
 972        $.on($.id('filter-palette-clear'), 'click', clearFilterColor);
 973        
 974        $.on($.id('filters-help-open'), 'click', showFiltersHelp);
 975        $.on($.id('filters-help-close'), 'click', closeFiltersHelp);
 976        
 977        $.on($.id('filter-rgb'), 'keyup', filterSetCustomColor);
 978        $.on($.id('filter-rgb-ok'), 'click', selectFilterColor);
 979        
 980        $.on($.id('filters-search'), 'keyup', onFiltersSearch);
 981        
 982        filtersPanel.setAttribute('data-built', '1');
 983      }
 984      else {
 985        $.id('filters-search').value = '';
 986      }
 987      
 988      rawFilters = localStorage.getItem('catalog-filters');
 989      filterId = 0;
 990      
 991      if (rawFilters) {
 992        filterList = $.id('filter-list');
 993        rawFilters = JSON.parse(rawFilters);
 994        for (i in rawFilters) {
 995          filterList.appendChild(buildFilter(rawFilters[i], filterId));
 996          ++filterId;
 997        }
 998        updateFilterHitCount();
 999      }
1000      
1001      filtersPanel.style.top = window.pageYOffset + 60 + 'px';
1002      
1003      $.removeClass(filtersPanel, 'hidden');
1004      
1005      if (el = $.cls('filter-active', filtersPanel)[0]) {
1006        el.focus();
1007      }
1008      
1009      toggleBackdrop();
1010    }
1011    
1012    function closeFilters() {
1013      var i, filterList, nodes;
1014      
1015      $.id('filters-msg').style.display = 'none';
1016      $.addClass($.id('filters'), 'hidden');
1017      
1018      filterList = $.id('filter-list');
1019      nodes = $.tag('tr', filterList);
1020      for (i = nodes.length - 1; i >= 0; i--) {
1021        filterList.removeChild(nodes[i]);
1022      }
1023      
1024      closeFilterPalette();
1025      toggleBackdrop();
1026    }
1027    
1028    function closeFilterPalette() {
1029      if ($filterPalette && !$.hasClass($filterPalette, 'hidden')) {
1030        $.addClass($filterPalette, 'hidden');
1031      }
1032    }
1033    
1034    // Loads patterns from the localStorage and builds regexps
1035    function loadFilters() {
1036      if (!UA.hasWebStorage) return;
1037      
1038      activeFilters = {};
1039      
1040      var rawFilters = localStorage.getItem('catalog-filters');
1041      if (!rawFilters) return;
1042      
1043      rawFilters = JSON.parse(rawFilters);
1044      
1045      var
1046        rf, fid, v, w, wordcount,
1047        wordSepS, wordSepE,
1048        regexType = /^\/(.*)\/(i?)$/,
1049        regexOrNorm = /\s*\|+\s*/g,
1050        regexWc = /\\\*/g, replWc = '[^\\s]*',
1051        regexEscape = getRegexSpecials(),
1052        match, inner, words, rawPattern, pattern, orOp, orCluster, type;
1053        
1054      wordSepS = '(?=.*\\b';
1055      wordSepE = '\\b)';
1056      
1057      try {
1058        for (fid in rawFilters) {
1059          rf = rawFilters[fid];
1060          if (rf.active && rf.pattern !== '') {
1061            if (rf.boards && rf.boards.split(' ').indexOf(catalog.slug) == -1) {
1062              continue;
1063            }
1064            rawPattern = rf.pattern;
1065            if (rawPattern.charAt(0) == '#') {
1066              type = (rawPattern.charAt(1) == '#') ? 2 : 1;
1067              pattern = new RegExp(rawPattern.slice(type).replace(regexEscape, '\\$1'));
1068            }
1069            else {
1070              type = 0;
1071              if (match = rawPattern.match(regexType)) {
1072                pattern = new RegExp(match[1], match[2]);
1073              }
1074              else if (rawPattern.charAt(0) == '"' && rawPattern.charAt(rawPattern.length - 1) == '"') {
1075                pattern = new RegExp(rawPattern.slice(1, -1).replace(regexEscape, '\\$1'));
1076              }
1077              else {
1078                words = rawPattern.replace(regexOrNorm, '|').split(' ');
1079                pattern = '';
1080                wordcount = words.length;
1081                for (w = 0; w < wordcount; ++w) {
1082                  if (words[w].indexOf('|') != -1) {
1083                    orOp = words[w].split('|');
1084                    orCluster = [];
1085                    for (v = orOp.length - 1; v >= 0; v--) {
1086                      if (orOp[v] !== '') {
1087                        orCluster.push(orOp[v].replace(regexEscape, '\\$1'));
1088                      }
1089                    }
1090                    inner = orCluster.join('|').replace(regexWc, replWc);
1091                    pattern += wordSepS + '(' + inner + ')' + wordSepE;
1092                  }
1093                  else {
1094                    inner = words[w].replace(regexEscape, '\\$1').replace(regexWc, replWc);
1095                    pattern += wordSepS + inner + wordSepE;
1096                  }
1097                }
1098                pattern = new RegExp('^' + pattern, 'i');
1099              }
1100            }
1101            //console.log('Resulting regex: ' + pattern);
1102            activeFilters[fid] = {
1103              type: type,
1104              pattern: pattern,
1105              boards: rf.boards,
1106              fid: fid,
1107              hidden: rf.hidden,
1108              color: rf.color,
1109              top: rf.top,
1110              hits: 0
1111            };
1112          }
1113        }
1114      }
1115      catch (err) {
1116        alert('There was an error processing one of the filters: '
1117          + err + ' in: ' + rf.pattern);
1118      }
1119    }
1120    
1121    function saveFilters() {
1122      var i, j, f, rawFilters, filterList, msg, rows, color;
1123      
1124      rawFilters = {};
1125      filterList = $.id('filter-list');
1126      rows = filterList.children;
1127      
1128      for (i = 0; j = rows[i]; ++i) {
1129        f = {
1130          active: $.cls('filter-active', j)[0].checked ? 1 : 0,
1131          pattern: $.cls('filter-pattern', j)[0].value,
1132          boards: $.cls('filter-boards', j)[0].value,
1133          hidden: $.cls('filter-hide', j)[0].checked ? 1 : 0,
1134          top: $.cls('filter-top', j)[0].checked ? 1 : 0
1135        };
1136        color = $.cls('filter-color', j)[0];
1137        if (!color.hasAttribute('data-nocolor')) {
1138          f.color = color.style.backgroundColor;
1139        }
1140        rawFilters[i] = f;
1141      }
1142      
1143      if (rawFilters[0]) {
1144        localStorage.setItem('catalog-filters', JSON.stringify(rawFilters));
1145      }
1146      else {
1147        localStorage.removeItem('catalog-filters');
1148      }
1149      
1150      msg = $.id('filters-msg');
1151      msg.innerHTML = 'Done';
1152      msg.className = 'msg-ok';
1153      msg.style.display = 'inline';
1154      setTimeout(function() { msg.style.display = 'none'; }, 2000);
1155      
1156      loadFilters();
1157      buildThreads();
1158      updateFilterHitCount();
1159    }
1160    
1161    function filterSetCustomColor() {
1162      var filterRgbOk;
1163      
1164      filterRgbOk = $.id('filter-rgb-ok');
1165      
1166      filterRgbOk.style.backgroundColor = this.value;
1167    }
1168    
1169    function buildFilter(filter, id) {
1170      var td, tr, span, input;
1171      
1172      tr = document.createElement('tr');
1173      tr.id = 'filter-' + id;
1174      
1175      // Move up
1176      td = document.createElement('td');
1177      span = document.createElement('span');
1178      span.setAttribute('data-up', id);
1179      span.className = 'pointer';
1180      span.innerHTML = '&uarr;';
1181      td.appendChild(span);
1182      tr.appendChild(td);
1183      
1184      // On
1185      td = document.createElement('td');
1186      input = document.createElement('input');
1187      input.type = 'checkbox';
1188      input.checked = !!filter.active;
1189      input.className = 'filter-active';
1190      td.appendChild(input);
1191      tr.appendChild(td);
1192      
1193      // Pattern
1194      td = document.createElement('td');
1195      input = document.createElement('input');
1196      input.type = 'text';
1197      input.value = filter.pattern;
1198      input.className = 'filter-pattern';
1199      td.appendChild(input);
1200      tr.appendChild(td);
1201      
1202      // Boards
1203      td = document.createElement('td');
1204      input = document.createElement('input');
1205      input.type = 'text';
1206      input.value = filter.boards;
1207      input.className = 'filter-boards';
1208      td.appendChild(input);
1209      tr.appendChild(td);
1210      
1211      // Color
1212      td = document.createElement('td');
1213      span = document.createElement('span');
1214      span.id = 'filter-color-' + id;
1215      span.title = 'Change Color';
1216      span.className = 'button clickbox filter-color';
1217      if (!filter.color) {
1218        span.setAttribute('data-nocolor', '1');
1219        span.innerHTML = '&#x2215;';
1220      }
1221      else {
1222        span.style.background = filter.color;
1223      }
1224      td.appendChild(span);
1225      tr.appendChild(td);
1226      
1227      // Hide
1228      td = document.createElement('td');
1229      input = document.createElement('input');
1230      input.type = 'checkbox';
1231      input.checked = !!filter.hidden;
1232      input.className = 'filter-hide';
1233      td.appendChild(input);
1234      tr.appendChild(td);
1235      
1236      // Top
1237      td = document.createElement('td');
1238      input = document.createElement('input');
1239      input.type = 'checkbox';
1240      input.checked = !!filter.top;
1241      input.className = 'filter-top';
1242      td.appendChild(input);
1243      tr.appendChild(td);
1244      
1245      // Del
1246      td = document.createElement('td');
1247      span = document.createElement('span');
1248      span.setAttribute('data-target', id);
1249      span.className = 'pointer';
1250      span.innerHTML = '&times;';
1251      td.appendChild(span);
1252      tr.appendChild(td);
1253      
1254      // Match count
1255      td = document.createElement('td');
1256      td.id = 'fhc-' + id;
1257      td.className = 'filter-hits';
1258      tr.appendChild(td);
1259      
1260      return tr;
1261    }
1262    
1263    function selectFilterColor(clear) {
1264      var target = $.id('filter-color-' + $filterPalette.getAttribute('data-target'));
1265      if (clear === true) {
1266        target.setAttribute('data-nocolor', '1');
1267        target.innerHTML = '&#x2215;';
1268        target.style.background = '';
1269      }
1270      else {
1271        target.removeAttribute('data-nocolor');
1272        target.innerHTML = '';
1273        target.style.background = this.style.backgroundColor;
1274      }
1275      closeFilterPalette();
1276    }
1277    
1278    function clearFilterColor() {
1279      selectFilterColor(true);
1280    }
1281    
1282    function addEmptyFilter() {
1283      var filter = {
1284        active: 1,
1285        pattern: '',
1286        boards: '',
1287        color: '',
1288        hidden: 0,
1289        top: 0,
1290        hits: 0
1291      };
1292      $.id('filter-list').appendChild(buildFilter(filter, getNextFilterId()));
1293    }
1294    
1295    function getNextFilterId() {
1296      var i, j, max, rows = $.id('filter-list').children;
1297      
1298      if (!rows.length) {
1299        return 0;
1300      }
1301      else {
1302        max = 0;
1303        for (i = 0; j = rows[i]; ++i) {
1304          j = +j.id.slice(7);
1305          if (j > max) {
1306            max = j;
1307          }
1308        }
1309        return max + 1;
1310      }
1311    }
1312    
1313    function deleteFilter(t) {
1314      var el = $.id('filter-' + t.getAttribute('data-target'));
1315      el.parentNode.removeChild(el);
1316    }
1317    
1318    function toggleFilter(el, type, xor) {
1319      var attr = 'data-' + type, xorEle;
1320      
1321      if (el.getAttribute(attr) == '0') {
1322        el.setAttribute(attr, '1');
1323        $.addClass(el, 'active');
1324        el.innerHTML = '&#x2714;';
1325        if (xor) {
1326          xorEle = $.cls('filter-' + xor, el.parentNode.parentNode)[0];
1327          xorEle.setAttribute('data-' + xor, '0');
1328          $.removeClass(xorEle, 'active');
1329          xorEle.innerHTML = '';
1330        }
1331      }
1332      else {
1333        el.setAttribute(attr, '0');
1334        $.removeClass(el, 'active');
1335        el.innerHTML = '';
1336      }
1337    }
1338    
1339    function updateFilterHitCount() {
1340      var i, j, rows = $.id('filter-list').children;
1341      for (i = 0; j = rows[i]; ++i) {
1342        $.id('fhc-' + j.id.slice(7))
1343          .innerHTML = activeFilters[i] ? 'x' + activeFilters[i].hits : '';
1344      }
1345    }
1346    
1347    function panelHidden(el) {
1348      return el && $.hasClass(el, 'hidden');
1349    }
1350    
1351    function showThemeEditor() {
1352      var themePanel, el, theme;
1353      
1354      if (!UA.hasWebStorage) {
1355        alert("Your browser doesn't support Local Storage");
1356        return;
1357      }
1358      
1359      themePanel = $.id('theme');
1360      
1361      if (!themePanel) {
1362        themePanel = FC.panelHTML.build('theme', 'panel hidden');
1363      }
1364      
1365      theme = localStorage.getItem('catalog-theme');
1366      theme = theme ? JSON.parse(theme) : {};
1367      
1368      $.id('theme-nobinds').checked = !!theme.nobinds;
1369      $.id('theme-nospoiler').checked = !!theme.nospoiler;
1370      $.id('theme-newtab').checked = !!theme.newtab;
1371      $.id('theme-tw').checked = hasThreadWatcher;
1372      $.id('theme-ddn').checked = hasDropDownNav;
1373      
1374      if (theme.css) {
1375        $.id('theme-css').value = theme.css;
1376      }
1377      
1378      $.on($.id('theme-save'), 'click', saveTheme);
1379      $.on($.id('theme-close'), 'click', closeThemeEditor);
1380        
1381      $.id('theme-msg').style.display = 'none';
1382      
1383      themePanel.style.top = window.pageYOffset + 60 + 'px';
1384      $.removeClass(themePanel, 'hidden');
1385      
1386      if (el = $.tag('input', themePanel)[0]) {
1387        el.focus();
1388      }
1389      
1390      toggleBackdrop();
1391      
1392      document.dispatchEvent(new CustomEvent('4chanCatalogThemeEditorReady'));
1393    }
1394    
1395    function closeThemeEditor() {
1396      $.off($.id('theme-save'), 'click', saveTheme);
1397      $.off($.id('theme-close'), 'click', closeThemeEditor);
1398      
1399      $.addClass($.id('theme'), 'hidden');
1400      toggleBackdrop();
1401    }
1402    
1403    function toggleBackdrop() {
1404      $.toggleClass($.id('backdrop'), 'hidden');
1405    }
1406    
1407    function loadTheme() {
1408      var customTheme;
1409      
1410      if (UA.hasWebStorage && (customTheme = localStorage.getItem('catalog-theme'))) {
1411        activeTheme = JSON.parse(customTheme);
1412      }
1413    }
1414    
1415    function applyTheme(customTheme, nocss) {
1416      if (customTheme.nobinds) {
1417        if (activeTheme.nobinds != customTheme.nobinds) {
1418          $.off(document, 'keyup', processKeybind);
1419        }
1420      }
1421      else {
1422        if (activeTheme.nobinds != customTheme.nobinds) {
1423          $.on(document, 'keyup', processKeybind);
1424        }
1425      }
1426      
1427      if (customTheme.nospoiler) {
1428        $.addClass(document.body, 'reveal-img-spoilers');
1429      }
1430      else {
1431        $.removeClass(document.body, 'reveal-img-spoilers');
1432      }
1433      
1434      if (!nocss) {
1435        self.applyCSS(customTheme);
1436      }
1437      
1438      document.dispatchEvent(new CustomEvent('4chanCatalogThemeApplied'));
1439    }
1440    
1441    self.applyCSS = function(customTheme, style_group, css_version) {
1442      var style, ss;
1443      
1444      if (!customTheme) {
1445        customTheme = activeTheme;
1446      }
1447      
1448      // Preferred stylesheet
1449      if (style_group !== undefined) {
1450        if (!(ss = $.readCookie(style_group))) {
1451          ss = style_group == 'nws_style' ? 'Yotsuba New' : 'Yotsuba B New';
1452        }
1453        
1454        activeStyleGroup = style_group;
1455        
1456        if (window.css_event && localStorage.getItem('4chan_stop_css_event') !== `${window.css_event}-${window.css_event_v}`) {
1457          activeStyleSheet = '_special'
1458          ss = window.css_event;
1459        }
1460        else {
1461          activeStyleSheet = ss;
1462        }
1463        
1464        style = document.createElement('link');
1465        style.type = 'text/css';
1466        style.id = 'base-css';
1467        style.rel = 'stylesheet';
1468        style.setAttribute('href', '//s.4cdn.org/css/catalog_'
1469          + ss.toLowerCase().replace(/ /g, '_') + '.' + css_version + '.css');
1470        document.head.insertBefore(style, $.id('mobile-css'));
1471      }
1472      
1473      // Custom CSS
1474      if (style = $.id('custom-css')) {
1475        document.head.removeChild(style);
1476      }
1477      
1478      if (customTheme.css) {
1479        style = document.createElement('style');
1480        style.type = 'text/css';
1481        style.id = 'custom-css';
1482        
1483        if (style.styleSheet) {
1484          style.styleSheet.cssText = customTheme.css;
1485        }
1486        else {
1487          style.innerHTML = customTheme.css;
1488        }
1489        document.head.appendChild(style);
1490      }
1491    };
1492    
1493    // Applies and saves the theme to localStorage
1494    function saveTheme() {
1495      var i, css, tw, ddn, extConfig, customTheme = {};
1496      
1497      if ($.id('theme-nobinds').checked) {
1498        customTheme.nobinds = true;
1499      }
1500      
1501      if ($.id('theme-nospoiler').checked) {
1502        customTheme.nospoiler = true;
1503      }
1504      
1505      if ($.id('theme-newtab').checked) {
1506        customTheme.newtab = true;
1507      }
1508      
1509      tw = $.id('theme-tw').checked;
1510      
1511      ddn = $.id('theme-ddn').checked;
1512      
1513      if (extConfig = localStorage.getItem('4chan-settings')) {
1514        extConfig = JSON.parse(extConfig);
1515      }
1516      else {
1517        extConfig = {};
1518      }
1519      
1520      if (tw != hasThreadWatcher) {
1521        if (tw) {
1522          ThreadWatcher.init();
1523          extConfig.disableAll = false;
1524        }
1525        else {
1526          ThreadWatcher.unInit();
1527        }
1528      }
1529      
1530      if (ddn != hasDropDownNav) {
1531        if (ddn) {
1532          showDropDownNav();
1533          extConfig.disableAll = false;
1534        }
1535        else {
1536          hideDropDownNav();
1537        }
1538      }
1539      
1540      extConfig.threadWatcher = tw;
1541      extConfig.dropDownNav = ddn;
1542      localStorage.setItem('4chan-settings', JSON.stringify(extConfig));
1543      //StorageSync.sync('4chan-settings');
1544      
1545      hasThreadWatcher = tw;
1546      hasDropDownNav = ddn;
1547      
1548      if ((css = $.id('theme-css').value) !== '') {
1549        customTheme.css = css;
1550      }
1551      
1552      applyTheme(customTheme);
1553      
1554      localStorage.removeItem('catalog-theme');
1555      
1556      for (i in customTheme) {
1557        localStorage.setItem('catalog-theme', JSON.stringify(customTheme));
1558        break;
1559      }
1560      
1561      //StorageSync.sync('catalog-theme');
1562      
1563      activeTheme = customTheme;
1564      
1565      buildThreads();
1566      closeThemeEditor();
1567    }
1568    
1569    function loadThreadList(key) {
1570      var i, threads, mod = false, ft = 0;
1571      
1572      if (threads = localStorage.getItem(key)) {
1573        ft = +Object.keys(catalog.threads).pop();
1574        threads = JSON.parse(threads);
1575        for (i in threads) {
1576          if (!catalog.threads[i] && i < ft) {
1577            delete threads[i];
1578            mod = true;
1579          }
1580        }
1581        for (i in threads) {
1582          if (mod) { localStorage.setItem(key, JSON.stringify(threads)); }
1583          return threads;
1584        }
1585        localStorage.removeItem(key);
1586      }
1587      return {};
1588    }
1589    
1590    function loadStorage() {
1591      if (UA.hasWebStorage) {
1592        hiddenThreads = loadThreadList('4chan-hide-t-' + catalog.slug);
1593        pinnedThreads = loadThreadList('4chan-pin-' + catalog.slug);
1594      }
1595    }
1596    
1597    function loadSettings() {
1598      var settings;
1599      if (UA.hasWebStorage && (settings = localStorage.getItem('catalog-settings'))) {
1600        $.extend(options, JSON.parse(settings));
1601      }
1602    }    
1603    
1604    function saveSettings() {
1605      var i, key, settings;
1606      if (!UA.hasWebStorage) {
1607        return;
1608      }
1609      settings = {};
1610      for (i = basicSettings.length - 1; i >= 0; i--) {
1611        key = basicSettings[i];
1612        settings[key] = options[key];
1613      }
1614      localStorage.setItem('catalog-settings', JSON.stringify(settings));
1615      //StorageSync.sync('catalog-settings');
1616    }
1617    
1618    function setExtended(mode, init) {
1619      var cls = '';
1620      if (mode) {
1621        $teaserCtrl.selectedIndex = 1;
1622        cls = 'extended-';
1623        options.extended = true;
1624      }
1625      else {
1626        $teaserCtrl.selectedIndex = 0;
1627        options.extended = false;
1628      }
1629      if (options.large) {
1630        cls += 'large';
1631      }
1632      else {
1633        cls += 'small';
1634      }
1635      $threads.className = cls;
1636      if (!init) {
1637        saveSettings();
1638      }
1639    }
1640    
1641    function setLarge(mode, init) {
1642      var cls = options.extended ? 'extended-' : '';
1643      if (mode) {
1644        $sizeCtrl.selectedIndex = 1;
1645        cls += 'large';
1646        options.large = true;
1647      }
1648      else {
1649        $sizeCtrl.selectedIndex = 0;
1650        cls += 'small';
1651        options.large = false;
1652      }
1653      $threads.className = cls;
1654      if (!init) {
1655        saveSettings();
1656        buildThreads();
1657      }
1658    }
1659    
1660    function setOrder(order, init) {
1661      var o = { alt: 0, absdate: 1, date: 2, r: 3 };
1662      if (o[order] !== undefined) {
1663        $orderCtrl.selectedIndex = o[order];
1664        options.orderby = order;
1665      }
1666      else {
1667        $orderCtrl.selectedIndex = 0;
1668        options.orderby = 'date';
1669      }
1670      if (!init) {
1671        saveSettings();
1672        buildThreads();
1673      }
1674    }
1675    
1676    function onTeaserChange() {
1677      setExtended($teaserCtrl.options[$teaserCtrl.selectedIndex].value == 'on');
1678    }
1679    
1680    function onOrderChange() {
1681      setOrder($orderCtrl.options[$orderCtrl.selectedIndex].value);
1682    }
1683    
1684    function onSizeChange() {
1685      setLarge($sizeCtrl.options[$sizeCtrl.selectedIndex].value == 'large');
1686    }
1687    
1688    function cycleOrder() {
1689      if (options.orderby == 'date') {
1690        setOrder('alt');
1691      }
1692      else if (options.orderby == 'alt') {
1693        setOrder('r');
1694      }
1695      else if (options.orderby == 'r') {
1696        setOrder('absdate');
1697      }
1698      else {
1699        setOrder('date');
1700      }
1701    }
1702    
1703    function sortThreadList(threadList) {
1704      var order = options.orderby;
1705      
1706      if (order == 'date') {
1707        threadList.sort(function(a, b) {
1708          if (a.id > b.id) return -1;
1709          if (a.id < b.id) return 1;
1710          return 0;
1711        });
1712      }
1713      else if (order == 'absdate' && !catalog.no_lr) {
1714        threadList.sort(function(a, b) {
1715          a = a.entry.lr.id;
1716          b = b.entry.lr.id;
1717          if (a > b) return -1;
1718          if (a < b) return 1;
1719          return 0;
1720        });
1721      }
1722      else if (order == 'r') {
1723        threadList.sort(function(a, b) {
1724          var 
1725            a = a.entry.r || 0,
1726            b = b.entry.r || 0;
1727          if (a > b) return -1;
1728          if (a < b) return 1;
1729          return 0;
1730        });
1731      }
1732      else { // alt
1733        threadList.sort(function(a, b) {
1734          if (a.entry.b < b.entry.b) return -1;
1735          if (a.entry.b > b.entry.b) return 1;
1736          return 0;
1737        });
1738      }
1739    }
1740    
1741    function getFilteredThreads() {
1742      var i, id, entry, hl, onTop, pinned, teaser, tripcode, af, threads, fid,
1743        filtered;
1744      
1745      filtered = 0;
1746      
1747      threads = [];
1748      
1749      threadloop: for (id in catalog.threads) {
1750        id = +id;
1751        entry = catalog.threads[id];
1752        hl = onTop = pinned = false;
1753        
1754        if (entry.sub) {
1755          teaser = '<b>' + entry.sub + '</b>';
1756          if (entry.teaser) {
1757            teaser += ': ' + entry.teaser;
1758          }
1759        }
1760        else {
1761          teaser = entry.teaser;
1762        }
1763        
1764        if (hiddenMode) {
1765          if (!hiddenThreads[id]) {
1766            continue;
1767          }
1768          ++hiddenThreadsCount;
1769        }
1770        else if(!quickFilterPattern) {
1771          if (hiddenThreads[id]) {
1772            ++hiddenThreadsCount;
1773            continue;
1774          }
1775          if (pinnedThreads[id] >= 0) {
1776            pinned = onTop = true;
1777          }
1778          else {
1779            if (entry.capcode) {
1780              tripcode = (entry.trip || '') + '!#' + entry.capcode;
1781            }
1782            else {
1783              tripcode = entry.trip;
1784            }
1785            for (fid in activeFilters) {
1786              af = activeFilters[fid];
1787              if ((af.type == 0 && (af.pattern.test(teaser) || af.pattern.test(entry.file)))
1788                || (af.type == 1 && af.pattern.test(tripcode))
1789                || (af.type == 2 && af.pattern.test(entry.author))) {
1790                if (af.hidden) {
1791                  ++filtered;
1792                  af.hits += 1;
1793                  continue threadloop;
1794                }
1795                hl = af;
1796                onTop = !!af.top;
1797                af.hits += 1;
1798                break;
1799              }
1800            }
1801          }
1802        }
1803        else if (!quickFilterPattern.test(teaser) && !quickFilterPattern.test(entry.file)) {
1804          continue;
1805        }
1806        
1807        if (pinnedThreads[id] >= 0) {
1808          pinned = onTop = true;
1809        }
1810        
1811        threads.push(
1812          {
1813            id: id,
1814            entry: entry,
1815            pinned: pinned,
1816            onTop: onTop,
1817            hl: hl
1818          }
1819        );
1820      }
1821      
1822      filteredThreadsCount = filtered;
1823      
1824      return threads;
1825    }
1826    
1827    function formatImageThreads(threads) {
1828      var
1829        i, k, id, entry, item, thread, hl, onTop, pinned, spoiler,
1830        rDiff, html, provider, contentUrl,
1831        pinhl, newtab, watchKey, teaser, topHtml, stickyHtml,
1832        ratio, maxSize, imgWidth, imgHeight, calcSize,
1833        capcodeReplies, capcodeReply, capcodeTitle, page;
1834      
1835      provider = '//boards.' + $L.d(catalog.slug) + '/' + catalog.slug + '/thread/';
1836      contentUrl = 'i.4cdn.org/' + catalog.slug + '/';
1837      
1838      calcSize = !options.large;
1839      newtab = activeTheme.newtab ? 'target="_blank" ' : '';
1840      
1841      if (catalog.custom_spoiler) {
1842        spoiler = options.imgspoiler + '-' + catalog.slug + catalog.custom_spoiler + '.png';
1843      }
1844      else {
1845        spoiler = options.imgspoiler + '.png';
1846      }
1847      
1848      html = '';
1849      topHtml = '';
1850      stickyHtml = '';
1851      
1852      for (i = 0; item = threads[i]; ++i) {
1853        id = item.id;
1854        entry = item.entry;
1855        hl = item.hl;
1856        onTop = item.onTop;
1857        pinned = item.pinned;
1858        
1859        if (entry.sub) {
1860          teaser = '<b>' + entry.sub + '</b>';
1861          if (entry.teaser) {
1862            teaser += ': ' + entry.teaser;
1863          }
1864        }
1865        else {
1866          teaser = entry.teaser;
1867        }
1868        
1869        thread = '<div id="thread-' + id + '" class="thread">';
1870        
1871        if (hasThreadWatcher) {
1872          watchKey = id + '-' + catalog.slug;
1873          thread += '<span id="leaf-' + id + '" data-watch="' + id + '" '
1874            + (ThreadWatcher.watched[watchKey] ?
1875              'title="Unwatch" class="unwatchIcon"></span>' :
1876              'title="Watch" class="watchIcon"></span>');
1877        }
1878        
1879        thread += '<a ' + newtab + 'href="' + provider + id + '"'
1880          + (entry.imgspoiler ? ' class="imgspoiler"' : '')
1881          + '><img loading="lazy" alt="" id="thumb-' + id + '" class="thumb';
1882        
1883        if (hl.color) {
1884          pinhl = ' hl" style="border-color: ' + hl.color;
1885        }
1886        else if (pinned) {
1887          pinhl = ' pinned';
1888        }
1889        else {
1890          pinhl = '';
1891        }
1892        
1893        if (entry.imgurl) {
1894          if (entry.imgspoiler && !activeTheme.nospoiler) {
1895            thread += pinhl + '" src="' + spoiler;
1896          }
1897          else {
1898            imgWidth = entry.tn_w;
1899            imgHeight = entry.tn_h;
1900            
1901            if (calcSize) {
1902              maxSize = options.smallsize;
1903              if (imgWidth > maxSize) {
1904                ratio = maxSize / imgWidth;
1905                imgWidth = maxSize;
1906                imgHeight = imgHeight * ratio;
1907              }
1908              if (imgHeight > maxSize) {
1909                ratio = maxSize / imgHeight;
1910                imgHeight = maxSize;
1911                imgWidth = imgWidth * ratio;
1912              }
1913            }
1914            thread += pinhl + '" width="' + imgWidth
1915              + '" height="' + imgHeight + '" src="//'
1916              + contentUrl + entry.imgurl + 's.jpg';
1917          }
1918        }
1919        else if (entry.imgdel) {
1920          thread += ' imgdel' + pinhl + '" src="' + options.imgdel;
1921        }
1922        else {
1923          thread += ' nofile' + pinhl + '" src="' + options.nofile;
1924        }
1925        
1926        thread += '" data-id="' + id + '" /></a>';
1927        
1928        if (entry.sticky || entry.closed || entry.capcodereps) {
1929          thread += '<div class="threadIcons">';
1930          if (entry.sticky) {
1931            thread += '<span title="Sticky" class="threadIcon stickyIcon"></span>';
1932          }
1933          if (entry.closed) {
1934            thread += '<span title="Closed" class="threadIcon closedIcon"></span>';
1935          }
1936          if (entry.capcodereps) {
1937            capcodeReplies = entry.capcodereps.split(',');
1938            for (k = 0; capcodeReply = capcodeReplies[k]; ++k) {
1939              if (capcodeTitle = capcodeMap[capcodeReply]) {
1940                thread += '<span title="'
1941                  + capcodeTitle + ' Replies" class="threadIcon '
1942                  + capcodeReply + 'Icon"></span>';
1943              }
1944            }
1945          }
1946          thread += '</div>';
1947        }
1948        
1949        thread += '<div title="(R)eplies / (I)mage Replies'
1950          + (onTop ? ' / (P)age' : '') + '" id="meta-' + id + '" class="meta">';
1951        
1952        if (entry.bumplimit) {
1953          thread += '<i>R: <b>' + entry.r + '</b></i>';
1954        }
1955        else {
1956          thread += 'R: <b>' + entry.r + '</b>';
1957        }
1958        if (pinned) {
1959          rDiff = entry.r - pinnedThreads[id];
1960          if (rDiff > 0) {
1961            thread += ' (+' + rDiff + ')';
1962            pinnedThreads[id] = entry.r;
1963          }
1964          else {
1965            thread += '(+0)';
1966          }
1967        }
1968        if (entry.i) {
1969          if (entry.imagelimit) {
1970            thread += ' / <i>I: <b>' + entry.i + '</b></i>';
1971          }
1972          else {
1973            thread += ' / I: <b>' + entry.i + '</b>';
1974          }
1975        }
1976        
1977        if (onTop && (page = getThreadPage(id)) >= 0) {
1978          thread += ' / P: <b>' + page + '</b>';
1979        }
1980        
1981        thread += '<a href="#" class="postMenuBtn" title="Thread Menu" '
1982          + 'data-post-menu="' + id + '">▶</a>';
1983        
1984        thread += '</div>';
1985        
1986        if (teaser) {
1987          thread += '<div class="teaser';
1988          if (hl.color) {
1989            thread += ' style="color:' + hl.color;
1990          }
1991          thread += '">' + teaser + '</div>';
1992        }
1993        
1994        if (window.partyHats) {
1995          thread = '<div class="party-cnt">' + thread
1996            + '</div><img class="party-hat" src="//s.4cdn.org/image/'
1997            + window.partyHats + '"></div>';
1998        }
1999        else {
2000          thread += '</div>';
2001        }
2002        
2003        if (entry.sticky) {
2004          stickyHtml += thread;
2005        }
2006        else if (onTop) {
2007          topHtml += thread;
2008        }
2009        else {
2010          html += thread;
2011        }
2012      }
2013      
2014      topHtml = stickyHtml + topHtml;
2015      
2016      if (quickFilterPattern && (html === '' && topHtml === '')) {
2017        html = '<div class="error">Nothing Found</div>';
2018      }
2019      else if (topHtml) {
2020        html = topHtml + html + '<div class="clear"></div>';
2021      }
2022      else {
2023        html += '<div class="clear"></div>';
2024      }
2025      
2026      return html;
2027    }
2028    
2029    function formatTextThreads(threads) {
2030      var
2031        i, id, entry, item, thread, hl, onTop, pinned,
2032        rDiff, html, provider,
2033        pinhl, newtab, topHtml, aTag;
2034      
2035      provider = '//boards.' + $L.d(catalog.slug) + '/' + catalog.slug + '/thread/';
2036      
2037      newtab = activeTheme.newtab ? 'target="_blank" ' : '';
2038      
2039      html = '';
2040      topHtml = '';
2041      
2042      for (i = 0; item = threads[i]; ++i) {
2043        id = item.id;
2044        entry = item.entry;
2045        hl = item.hl;
2046        onTop = item.onTop;
2047        pinned = item.pinned;
2048        
2049        if (hl.color) {
2050          pinhl = ' class="hl" style="box-shadow: -3px 0 ' + hl.color + '"';
2051        }
2052        else if (pinned) {
2053          pinhl = ' class="pinned"';
2054        }
2055        else {
2056          pinhl = '';
2057        }
2058        
2059        aTag = '<a ' + newtab + 'href="' + provider + id + '">';
2060        
2061        thread = '<tr id="thread-' + id + '"' + pinhl
2062          + '><td class="txt-no">' + aTag
2063          + '»</a></td><td class="txt-sub">' + aTag + entry.sub
2064          + '</a></td><td class="txt-rep">';
2065        
2066        if (entry.bumplimit) {
2067          thread += '<i>' + entry.r + '</i>';
2068        }
2069        else {
2070          thread += entry.r;
2071        }
2072        
2073        if (pinned) {
2074          rDiff = entry.r - pinnedThreads[id];
2075          if (rDiff > 0) {
2076            thread += ' (+' + rDiff + ')';
2077            pinnedThreads[id] = entry.r;
2078          }
2079          else {
2080            thread += '(+0)';
2081          }
2082        }
2083        
2084        thread += '</td><td class="txt-date" data-id="' + id + '">' + entry.date
2085          + '</td><td class="txt-ctrl"><a href="#" class="postMenuBtn" title="Thread Menu" '
2086          + 'data-post-menu="' + id + '">▶</a></td></tr>';
2087        
2088        if (onTop) {
2089          topHtml += thread;
2090        }
2091        else {
2092          html += thread;
2093        }
2094      }
2095      
2096      if (quickFilterPattern && (html === '' && topHtml === '')) {
2097        html = '<div class="error">Nothing Found</div>';
2098      }
2099      else if (topHtml) {
2100        html = topHtml + html + '<div class="clear"></div>';
2101      }
2102      else {
2103        html += '<div class="clear"></div>';
2104      }
2105      
2106      html = '<table><thead><tr><th class="txt-no"></th><th class="txt-sub">Subject</th><th class="txt-rep">Replies</th>'
2107        + '<th class="txt-date">Date</th><th class="txt-ctrl"></th></tr></thead><tbody>' + html + '</tbody></table>';
2108      
2109      return html;
2110    }
2111    
2112    function buildThreads() {
2113      var i, tip, fid, threads;
2114      
2115      if (catalog.count === 0) {
2116        return;
2117      }
2118      
2119      if ($threads.hasChildNodes()) {
2120        if (tip = document.getElementById('th-tip')) {
2121          document.body.removeChild(tip);
2122        }
2123        $threads.textContent = '';
2124      }
2125      
2126      hiddenThreadsCount = 0;
2127      filteredThreadsCount = 0;
2128      
2129      for (fid in activeFilters) {
2130        activeFilters[fid].hits = 0;
2131      }
2132      
2133      threads = getFilteredThreads();
2134      
2135      sortThreadList(threads);
2136      
2137      if (!window.text_only) {
2138        $threads.innerHTML = formatImageThreads(threads);
2139      }
2140      else {
2141        $threads.innerHTML = formatTextThreads(threads);
2142      }
2143      
2144      for (i in pinnedThreads) {
2145        localStorage.setItem('4chan-pin-' + catalog.slug, JSON.stringify(pinnedThreads));
2146        break;
2147      }
2148      
2149      setFilteredCount(filteredThreadsCount);
2150      
2151      setHiddenCount(hiddenThreadsCount);
2152    }
2153    
2154    function onThreadMouseOver(e) {
2155      var t = e.target;
2156      
2157      if ($.hasClass(t, 'thumb') || (window.text_only && $.hasClass(t, 'txt-date'))) {
2158        clearTimeout(tooltipTimeout);
2159        if (hasTooltip) {
2160          hideTooltip();
2161        }
2162        tooltipTimeout = setTimeout(showTooltip, options.tipdelay, t);
2163      }
2164    }
2165    
2166    function onThreadMouseOut() {
2167      clearTimeout(tooltipTimeout);
2168      if (hasTooltip) {
2169        hideTooltip();
2170      }
2171    }
2172    
2173    function showTooltip(t) {
2174      var now, tip, el, rect, docWidth, style, page, tid, thread, top,
2175        bottom, docHeight, left;
2176      
2177      now = Date.now() / 1000;
2178      
2179      rect = t.getBoundingClientRect();
2180      docWidth = document.documentElement.offsetWidth;
2181      
2182      tid = t.getAttribute('data-id');
2183      
2184      if (!tid) {
2185        return;
2186      }
2187      
2188      thread = catalog.threads[tid];
2189      
2190      if (page = getThreadPage(tid)) {
2191        page = '<span class="post-page">Page ' + page + '</span>';
2192      }
2193      else {
2194        page = '';
2195      }
2196      
2197      if (thread.sub && !window.text_only) {
2198        tip = '<span class="post-subject">' + thread.sub + '</span>';
2199      }
2200      else {
2201        tip = 'Posted';
2202      }
2203      
2204      tip += ' by <span class="'
2205        + (thread.capcode ? (thread.capcode + '-capcode ') : '')
2206        + 'post-author">' + (thread.author || catalog.anon);
2207      
2208      if (thread.trip) {
2209        tip += ' <span class="post-tripcode">' + thread.trip + '</span>';
2210      }
2211      
2212      if (thread.capcode) {
2213        tip += ' ## '
2214          + capcodeMap[thread.capcode];
2215      }
2216      
2217      tip += '</span> ';
2218      
2219      if (catalog.flags && thread.country) {
2220        tip += '<div class="flag flag-' + thread.country.toLowerCase() + '"></div> ';
2221      }
2222      
2223      tip += '<span class="post-ago">'
2224        + getDuration(now - thread.date)
2225        + ' ago</span>' + page;
2226      
2227      if ((!options.extended && thread.teaser) || window.text_only) {
2228        tip += '<p class="post-teaser">' + thread.teaser + '</p>';
2229      }
2230      
2231      if (thread.lr.date) {
2232        tip += '<div class="post-last">Last reply by <span class="'
2233        + (thread.lr.capcode ? (thread.lr.capcode + '-capcode ') : '')
2234        + 'post-author">' + thread.lr.author;
2235        
2236        if (thread.lr.trip) {
2237          tip += ' <span class="post-tripcode">' + thread.lr.trip + '</span>';
2238        }
2239        
2240        if (thread.lr.capcode) {
2241          tip += ' ## '
2242            + thread.lr.capcode.charAt(0).toUpperCase()
2243            + thread.lr.capcode.slice(1);
2244        }
2245        
2246        if (thread.lr.date) {
2247          tip += '</span> <span class="post-ago">'
2248            + getDuration(now - thread.lr.date)
2249            + ' ago</span>';
2250        }
2251        else {
2252          tip += '</span>';
2253        }
2254      }
2255      
2256      el = document.createElement('div');
2257      el.id = 'post-preview';
2258      el.innerHTML = tip;
2259      document.body.appendChild(el);
2260      
2261      if ((docWidth - rect.right) < (0 | (docWidth * 0.3))) {
2262        left = rect.left - el.offsetWidth - 5;
2263      }
2264      else {
2265        left = rect.left + rect.width + 5;
2266      }
2267      
2268      docHeight = document.documentElement.clientHeight;
2269      
2270      bottom = rect.top + el.offsetHeight;
2271      
2272      if (bottom > docHeight) {
2273        top = rect.top - (bottom - docHeight) - 20;
2274      }
2275      else {
2276        top = rect.top;
2277      }
2278      
2279      if (top < 0) {
2280        top = 3;
2281      }
2282      
2283      style = el.style;
2284      style.left = left + window.pageXOffset + 'px';
2285      style.top = top + window.pageYOffset + 'px';
2286      
2287      hasTooltip = true;
2288    }
2289    
2290    function hideTooltip() {
2291      document.body.removeChild($.id('post-preview'));
2292      hasTooltip = false;
2293    }
2294    
2295    function getDuration(delta, precise) {
2296      var count, head, tail;
2297      if (delta < 2) {
2298        return 'less than a second';
2299      }
2300      if (precise && delta < 300) {
2301        return (0 | delta) + ' seconds';
2302      }
2303      if (delta < 60) {
2304        return (0 | delta) + ' seconds';
2305      }
2306      if (delta < 3600) {
2307        count = 0 | (delta / 60);
2308        if (count > 1) {
2309          return count + ' minutes';
2310        }
2311        else {
2312          return 'one minute';
2313        }
2314      }
2315      if (delta < 86400) {
2316        count = 0 | (delta / 3600);
2317        if (count > 1) {
2318          head = count + ' hours';
2319        }
2320        else {
2321          head = 'one hour';
2322        }
2323        tail = 0 | (delta / 60 - count * 60);
2324        if (tail > 1) {
2325          head += ' and ' + tail + ' minutes';
2326        }
2327        return head;
2328      }
2329      count = 0 | (delta / 86400);
2330      if (count > 1) {
2331        head = count + ' days';
2332      }
2333      else {
2334        head = 'one day';
2335      }
2336      tail = 0 | (delta / 3600 - count * 24);
2337      if (tail > 1) {
2338        head += ' and ' + tail + ' hours';
2339      }
2340      return head;
2341    }
2342  };
2343  
2344  var Filter = {};
2345  
2346  Filter.init = function() {
2347    this.entities = document.createElement('div');
2348    Filter.load();
2349  };
2350  
2351  Filter.match = function(post, board) {
2352    var i, com, f, filters, hit;
2353    
2354    hit = false;
2355    filters = Filter.activeFilters;
2356    
2357    for (i = 0; f = filters[i]; ++i) {
2358      // boards
2359      if (!f.boards[board]) {
2360        continue;
2361      }
2362      // tripcode
2363      if (f.type == 0) {
2364        if (f.pattern === post.trip) {
2365          hit = true;
2366          break;
2367        }
2368      }
2369      // name
2370      else if (f.type == 1) {
2371        if (f.pattern === post.name) {
2372          hit = true;
2373          break;
2374        }
2375      }
2376      // comment
2377      else if (f.type == 2 && post.com) {
2378        if (com === undefined) {
2379          this.entities.innerHTML
2380            = post.com.replace(/<br>/g, '\n').replace(/[<[^>]+>/g, '');
2381          com = this.entities.textContent;
2382        }
2383        if (f.pattern.test(com)) {
2384          hit = true;
2385          break;
2386        }
2387      }
2388      // user id
2389      else if (f.type == 4) {
2390        if (f.pattern === post.id) {
2391          hit = true;
2392          break;
2393        }
2394      }
2395      // subject
2396      else if (f.type == 5) {
2397        if (f.pattern.test(post.sub)) {
2398          hit = true;
2399          break;
2400        }
2401      }
2402      // filename
2403      else if (f.type == 6) {
2404        if (f.pattern.test(post.filename)) {
2405          hit = true;
2406          break;
2407        }
2408      }
2409    }
2410    
2411    return hit;
2412  };
2413  
2414  FC.getDocTopOffset = function() {
2415    if (window.Config.dropDownNav && !window.Config.autoHideNav) {
2416      return $.id(
2417        window.Config.classicNav ? 'boardNavDesktop' : 'boardNavMobile'
2418      ).offsetHeight;
2419    }
2420    else {
2421      return 0;
2422    }
2423  };
2424  
2425  Filter.load = function() {
2426    var i, j, f, rawFilters, rawPattern, fid, regexEscape, regexType,
2427      wordSepS, wordSepE, words, inner, regexWildcard, replaceWildcard, boards,
2428      pattern, match, tmp;
2429    
2430    this.activeFilters = [];
2431    
2432    if (!(rawFilters = localStorage.getItem('4chan-filters'))) {
2433      return;
2434    }
2435    
2436    rawFilters = JSON.parse(rawFilters);
2437    
2438    regexEscape = new RegExp('(\\'
2439      + ['/', '.', '*', '+', '?', '(', ')', '[', ']', '{', '}', '\\', '^', '$' ].join('|\\')
2440      + ')', 'g');
2441    regexType = /^\/(.*)\/(i?)$/;
2442    wordSepS = '(?=.*\\b';
2443    wordSepE = '\\b)';
2444    regexWildcard = /\\\*/g;
2445    replaceWildcard = '[^\\s]*';
2446    
2447    try {
2448      for (fid = 0; f = rawFilters[fid]; ++fid) {
2449        if (f.active && f.pattern !== '') {
2450          // Boards
2451          if (f.boards) {
2452            tmp = f.boards.split(/[^a-z0-9]+/i);
2453            boards = {};
2454            for (i = 0; j = tmp[i]; ++i) {
2455              boards[j] = true;
2456            }
2457          }
2458          else {
2459            boards = false;
2460          }
2461          
2462          rawPattern = f.pattern;
2463          // Name, Tripcode or ID, string comparison
2464          if (!f.type || f.type == 1 || f.type == 4) {
2465            pattern = rawPattern;
2466          }
2467          // /RegExp/
2468          else if (match = rawPattern.match(regexType)) {
2469            pattern = new RegExp(match[1], match[2]);
2470          }
2471          // "Exact match"
2472          else if (rawPattern[0] == '"' && rawPattern[rawPattern.length - 1] == '"') {
2473            pattern = new RegExp(rawPattern.slice(1, -1).replace(regexEscape, '\\$1'));
2474          }
2475          // Full words, AND operator
2476          else {
2477            words = rawPattern.split(' ');
2478            pattern = '';
2479            for (i = 0, j = words.length; i < j; ++i) {
2480              inner = words[i]
2481                .replace(regexEscape, '\\$1')
2482                .replace(regexWildcard, replaceWildcard);
2483              pattern += wordSepS + inner + wordSepE;
2484            }
2485            pattern = new RegExp('^' + pattern, 'im');
2486          }
2487          //console.log('Resulting pattern: ' + pattern);
2488          this.activeFilters.push({
2489            type: f.type,
2490            pattern: pattern,
2491            boards: boards,
2492            color: f.color,
2493            hide: f.hide,
2494            auto: f.auto
2495          });
2496        }
2497      }
2498    }
2499    catch (e) {
2500      alert('There was an error processing one of the filters: '
2501        + e + ' in: ' + rawPattern);
2502    }
2503  };
2504  
2505  /**
2506   * Thread watcher
2507   */
2508  var ThreadWatcher = {
2509    hasFilters: false
2510  };
2511  
2512  ThreadWatcher.init = function() {
2513    var cnt, pos, el;
2514    
2515    if (this.hasFilters) {
2516      Filter.init();
2517    }
2518    
2519    this.listNode = null;
2520    this.charLimit = 45;
2521    this.watched = {};
2522    this.blacklisted = {};
2523    this.isRefreshing = false;
2524    
2525    if (FC.hasMobileLayout) {
2526      el = document.createElement('a');
2527      el.href = '#';
2528      el.textContent = 'TW';
2529      el.addEventListener('click', ThreadWatcher.toggleList, false);
2530      cnt = $.id('settingsWindowLinkMobile');
2531      cnt.parentNode.insertBefore(el, cnt);
2532      cnt.parentNode.insertBefore(document.createTextNode(' '), cnt);
2533    }
2534    
2535    cnt = document.createElement('div');
2536    cnt.id = 'threadWatcher';
2537    cnt.setAttribute('data-trackpos', 'TW-position');
2538    
2539    if (FC.hasMobileLayout) {
2540      cnt.style.display = 'none';
2541    }
2542    else {
2543      if (window.Config['TW-position']) {
2544        cnt.style.cssText = window.Config['TW-position'];
2545      }
2546      else {
2547        cnt.style.left = '10px';
2548        cnt.style.top = '75px';
2549      }
2550    }
2551    
2552    cnt.innerHTML = '<div class="drag" id="twHeader">'
2553      + (FC.hasMobileLayout ? ('<div id="twClose" class="icon closeIcon"></div>') : '')
2554      + 'Thread Watcher'
2555      + (UA.hasCORS ? ('<div id="twPrune" class="icon refreshIcon" title="Refresh"></div></div>') : '</div>');
2556    
2557    this.listNode = document.createElement('ul');
2558    this.listNode.id = 'watchList';
2559    
2560    this.load();
2561    
2562    this.build();
2563    
2564    cnt.appendChild(this.listNode);
2565    document.body.appendChild(cnt);
2566    cnt.addEventListener('mouseup', this.onClick, false);
2567    Draggable.set($.id('twHeader'));
2568    window.addEventListener('storage', this.syncStorage, false);
2569    
2570    if (!FC.hasMobileLayout && this.canAutoRefresh()) {
2571      this.refresh();
2572    }
2573  };
2574  
2575  ThreadWatcher.unInit = function() {
2576    var cnt;
2577    
2578    if (cnt = $.id('threadWatcher')) {
2579      cnt.removeEventListener('mouseup', this.onClick, false);
2580      Draggable.unset($.id('twHeader'));
2581      window.removeEventListener('storage', this.syncStorage, false);
2582      document.body.removeChild(cnt);
2583    }
2584  };
2585  
2586  ThreadWatcher.toggleList = function(e) {
2587    var el = $.id('threadWatcher');
2588    
2589    e && e.preventDefault();
2590    
2591    if (ThreadWatcher.canAutoRefresh()) {
2592      ThreadWatcher.refresh();
2593    }
2594    
2595    if (el.style.display == 'none') {
2596      el.style.top = (window.pageYOffset + 30) + 'px';
2597      el.style.display = '';
2598    }
2599    else {
2600      el.style.display = 'none';
2601    }
2602  };
2603  
2604  ThreadWatcher.syncStorage = function(e) {
2605    var key;
2606    
2607    if (!e.key) {
2608      return;
2609    }
2610    
2611    key = e.key.split('-');
2612    
2613    if (key[0] == '4chan' && key[1] == 'watch' && e.newValue != e.oldValue) {
2614      ThreadWatcher.load();
2615      ThreadWatcher.build();
2616    }
2617  };
2618  
2619  ThreadWatcher.load = function() {
2620    var storage;
2621    
2622    if (storage = localStorage.getItem('4chan-watch')) {
2623      this.watched = JSON.parse(storage);
2624    }
2625    if (storage = localStorage.getItem('4chan-watch-bl')) {
2626      this.blacklisted = JSON.parse(storage);
2627    }
2628  };
2629  
2630  ThreadWatcher.build = function() {
2631    var html, tuid, key, cls;
2632    
2633    html = '';
2634    
2635    for (key in this.watched) {
2636      tuid = key.split('-');
2637      html += '<li id="watch-' + key
2638        + '"><span class="pointer" data-cmd="unwatch" data-id="'
2639        + tuid[0] + '" data-board="' + tuid[1] + '">&times;</span> <a href="'
2640        + this.linkToThread(tuid[0], tuid[1], this.watched[key][1]) + '"';
2641      
2642      if (this.watched[key][1] == -1) {
2643        html += ' class="deadlink">';
2644      }
2645      else {
2646        cls = [];
2647        
2648        if (this.watched[key][3]) {
2649          cls.push('archivelink');
2650        }
2651        
2652        if (this.watched[key][4]) {
2653          cls.push('hasYouReplies');
2654          html += ' title="This thread has replies to your posts"';
2655        }
2656        
2657        if (this.watched[key][2]) {
2658          html += ' class="' + (cls[0] ? (cls.join(' ') + ' ') : '')
2659            + 'hasNewReplies">(' + this.watched[key][2] + ') ';
2660        }
2661        else {
2662          html += (cls[0] ? ('class="' + cls.join(' ') + '"') : '') + '>';
2663        }
2664      }
2665      
2666      html += '/' + tuid[1] + '/ - ' + this.watched[key][0] + '</a></li>';
2667    }
2668    
2669    ThreadWatcher.listNode.innerHTML = html;
2670  };
2671  
2672  ThreadWatcher.onClick = function(e) {
2673    var t = e.target;
2674    
2675    if (t.hasAttribute('data-id')) {
2676      ThreadWatcher.toggle(
2677        t.getAttribute('data-id'),
2678        t.getAttribute('data-board')
2679      );
2680    }
2681    else if (t.id == 'twPrune' && !ThreadWatcher.isRefreshing) {
2682      ThreadWatcher.refreshWithAutoWatch();
2683    }
2684    else if (t.id == 'twClose') {
2685      ThreadWatcher.toggleList();
2686    }
2687  };
2688  
2689  ThreadWatcher.generateLabel = function(sub, com, tid) {
2690    var label;
2691    
2692    if (label = sub) {
2693      label = label.slice(0, this.charLimit);
2694    }
2695    else if (label = com) {
2696      label = label.replace(/(?:<br>)+/g, ' ')
2697        .replace(/<[^>]*?>/g, '').slice(0, this.charLimit);
2698    }
2699    else {
2700      label = 'No.' + tid;
2701    }
2702    
2703    return label;
2704  };
2705  
2706  ThreadWatcher.toggle = function(tid, board, sub, com, lr) {
2707    var key, label, lastReply, icon;
2708    
2709    key = tid + '-' + board;
2710    icon = $.id('leaf-' + tid);
2711    
2712    if (this.watched[key]) {
2713      delete this.watched[key];
2714      if (icon) {
2715        icon.className = 'watchIcon';
2716        icon.title = 'Watch';
2717      }
2718    }
2719    else {
2720      label = ThreadWatcher.generateLabel(sub, com, tid);
2721      
2722      lastReply = lr || tid;
2723      
2724      this.watched[key] = [ label, lastReply, 0 ];
2725      
2726      icon.className = 'unwatchIcon';
2727      icon.title = 'Unwatch';
2728    }
2729    this.save();
2730    this.load();
2731    this.build();
2732  };
2733  
2734  ThreadWatcher.addRaw = function(post, board) {
2735    var key, label;
2736    
2737    key = post.no + '-' + board;
2738    
2739    if (this.watched[key]) {
2740      return;
2741    }
2742    
2743    label = ThreadWatcher.generateLabel(post.sub, post.com, post.no);
2744    
2745    this.watched[key] = [ label, 0, 0 ];
2746  };
2747  
2748  ThreadWatcher.save = function() {
2749    var i;
2750    
2751    ThreadWatcher.sortByBoard();
2752    
2753    localStorage.setItem('4chan-watch', JSON.stringify(ThreadWatcher.watched));
2754    
2755    //StorageSync.sync('4chan-watch');
2756    
2757    for (i in ThreadWatcher.blacklisted) {
2758      localStorage.setItem('4chan-watch-bl', JSON.stringify(ThreadWatcher.blacklisted));
2759      //StorageSync.sync('4chan-watch-bl');
2760      break;
2761    }
2762  };
2763  
2764  ThreadWatcher.sortByBoard = function() {
2765    var i, self, key, sorted, keys;
2766    
2767    self = ThreadWatcher;
2768    
2769    sorted = {};
2770    keys = [];
2771    
2772    for (key in self.watched) {
2773      keys.push(key);
2774    }
2775    
2776    keys.sort(function(a, b) {
2777      a = a.split('-')[1];
2778      b = b.split('-')[1];
2779      
2780      if (a < b) {
2781        return -1;
2782      }
2783      if (a > b) {
2784        return 1;
2785      }
2786      return 0;
2787    });
2788    
2789    for (i = 0; key = keys[i]; ++i) {
2790      sorted[key] = self.watched[key];
2791    }
2792    
2793    self.watched = sorted;
2794  };
2795  
2796  ThreadWatcher.canAutoRefresh = function() {
2797    var time;
2798    
2799    if (time = localStorage.getItem('4chan-tw-timestamp')) {
2800      return Date.now() - (+time) >= 60000;
2801    }
2802    return false;
2803  };
2804  
2805  ThreadWatcher.setRefreshTimestamp = function() {
2806    localStorage.setItem('4chan-tw-timestamp', Date.now());
2807    //StorageSync.sync('4chan-tw-timestamp');
2808  };
2809  
2810  ThreadWatcher.refreshWithAutoWatch = function() {
2811    var i, f, count, board, boards, img;
2812    
2813    if (!this.hasFilters) {
2814      this.refresh();
2815      return;
2816    }
2817    
2818    Filter.load();
2819    
2820    boards = {};
2821    count = 0;
2822    
2823    for (i = 0; f = Filter.activeFilters[i]; ++i) {
2824      if (!f.auto || !f.boards) {
2825        continue;
2826      }
2827      for (board in f.boards) {
2828        if (boards[board]) {
2829          continue;
2830        }
2831        boards[board] = true;
2832        ++count;
2833      }
2834    }
2835    
2836    if (!count) {
2837      this.refresh();
2838      return;
2839    }
2840    
2841    img = $.id('twPrune');
2842    img.className = 'icon rotateIcon';
2843    this.isRefreshing = true;
2844    
2845    this.fetchCatalogs(boards, count);
2846  };
2847  
2848  ThreadWatcher.fetchCatalogs = function(boards, count) {
2849    var to, board, catalogs, meta;
2850    
2851    catalogs = {};
2852    meta = { count: count };
2853    to = 0;
2854    
2855    for (board in boards) {
2856      setTimeout(ThreadWatcher.fetchCatalog, to, board, catalogs, meta);
2857      to += 200;
2858    }
2859  };
2860  
2861  ThreadWatcher.parseCatalogJSON = function(data) {
2862    var catalog;
2863    
2864    try {
2865      catalog = JSON.parse(data);
2866    }
2867    catch (e) {
2868      console.log(e);
2869      catalog = [];
2870    }
2871    
2872    return catalog;
2873  };
2874  
2875  ThreadWatcher.fetchCatalog = function(board, catalogs, meta) {
2876    var xhr;
2877    
2878    xhr = new XMLHttpRequest();
2879    xhr.open('GET', '//a.4cdn.org/' + board + '/catalog.json');
2880    xhr.onload = function() {
2881      meta.count--;
2882      catalogs[board] = ThreadWatcher.parseCatalogJSON(this.responseText);
2883      if (!meta.count) {
2884        ThreadWatcher.onCatalogsLoaded(catalogs);
2885      }
2886    };
2887    xhr.onerror = function() {
2888      meta.count--;
2889      if (!meta.count) {
2890        ThreadWatcher.onCatalogsLoaded(catalogs);
2891      }
2892    };
2893    xhr.send(null);
2894  };
2895  
2896  ThreadWatcher.onCatalogsLoaded = function(catalogs) {
2897    var i, j, board, page, pages, threads, thread, key, blacklisted;
2898    
2899    $.id('twPrune').className = 'icon rotateIcon';
2900    this.isRefreshing = false;
2901    
2902    blacklisted = {};
2903    
2904    for (board in catalogs) {
2905      pages = catalogs[board];
2906      for (i = 0; page = pages[i]; ++i) {
2907        threads = page.threads;
2908        for (j = 0; thread = threads[j]; ++j) {
2909          key = thread.no + '-' + board;
2910          if (this.blacklisted[key]) {
2911            blacklisted[key] = 1;
2912            continue;
2913          }
2914          if (Filter.match(thread, board)) {
2915            this.addRaw(thread, board);
2916          }
2917        }
2918      }
2919    }
2920    
2921    this.blacklisted = blacklisted;
2922    this.build(true);
2923    this.refresh();
2924  };
2925  
2926  ThreadWatcher.refresh = function() {
2927    var i, to, key, total, img;
2928    
2929    if (total = $.id('watchList').children.length) {
2930      i = to = 0;
2931      img = $.id('twPrune');
2932      img.className = 'icon rotateIcon';
2933      ThreadWatcher.isRefreshing = true;
2934      ThreadWatcher.setRefreshTimestamp();
2935      for (key in ThreadWatcher.watched) {
2936        setTimeout(ThreadWatcher.fetch, to, key, ++i == total ? img : null);
2937        to += 200;
2938      }
2939    }
2940  };
2941  
2942  ThreadWatcher.onRefreshEnd = function(img) {
2943    img.className = 'icon refreshIcon';
2944    this.isRefreshing = false;
2945    this.save();
2946    this.load();
2947    this.build();
2948  };
2949  
2950  ThreadWatcher.parseThreadJSON = function(data) {
2951    var thread;
2952    
2953    try {
2954      thread = JSON.parse(data).posts;
2955    }
2956    catch (e) {
2957      console.log(e);
2958      thread = [];
2959    }
2960    
2961    return thread;
2962  };
2963  
2964  ThreadWatcher.getTrackedReplies = function(board, tid) {
2965    var tracked = null;
2966    
2967    if (tracked = localStorage.getItem('4chan-track-' + board + '-' + tid)) {
2968      tracked = JSON.parse(tracked);
2969    }
2970    
2971    return tracked;
2972  };
2973  
2974  ThreadWatcher.fetch = function(key, img) {
2975    var tuid, xhr, li;
2976    
2977    li = $.id('watch-' + key);
2978    
2979    if (ThreadWatcher.watched[key][1] == -1) {
2980      delete ThreadWatcher.watched[key];
2981      li.parentNode.removeChild(li);
2982      if (img) {
2983        ThreadWatcher.onRefreshEnd(img);
2984      }
2985      return;
2986    }
2987    
2988    tuid = key.split('-'); // tid, board
2989    
2990    xhr = new XMLHttpRequest();
2991    xhr.onload = function() {
2992      var i, newReplies, posts, lastReply, trackedReplies, dummy, quotelinks, q, j;
2993      if (this.status == 200) {
2994        posts = ThreadWatcher.parseThreadJSON(this.responseText);
2995        lastReply = ThreadWatcher.watched[key][1];
2996        newReplies = 0;
2997        
2998        if (!ThreadWatcher.watched[key][4]) {
2999          trackedReplies = ThreadWatcher.getTrackedReplies(tuid[1], tuid[0]);
3000          
3001          if (trackedReplies) {
3002            dummy = document.createElement('div');
3003          }
3004        }
3005        else {
3006          trackedReplies = null;
3007        }
3008        
3009        for (i = posts.length - 1; i >= 1; i--) {
3010          if (posts[i].no <= lastReply) {
3011            break;
3012          }
3013          ++newReplies;
3014          
3015          if (trackedReplies) {
3016            dummy.innerHTML = posts[i].com;
3017            quotelinks = $.cls('quotelink', dummy);
3018            
3019            if (!quotelinks[0]) {
3020              continue;
3021            }
3022            
3023            for (j = 0; q = quotelinks[j]; ++j) {
3024              if (trackedReplies[q.textContent]) {
3025                ThreadWatcher.watched[key][4] = 1;
3026                trackedReplies = null;
3027                break;
3028              }
3029            }
3030          }
3031        }
3032        if (newReplies > ThreadWatcher.watched[key][2]) {
3033          ThreadWatcher.watched[key][2] = newReplies;
3034        }
3035        if (posts[0].archived) {
3036          ThreadWatcher.watched[key][3] = 1;
3037        }
3038      }
3039      else if (this.status == 404) {
3040        ThreadWatcher.watched[key][1] = -1;
3041      }
3042      if (img) {
3043        ThreadWatcher.onRefreshEnd(img);
3044      }
3045    };
3046    if (img) {
3047      xhr.onerror = xhr.onload;
3048    }
3049    xhr.open('GET', '//a.4cdn.org/' + tuid[1] + '/thread/' + tuid[0] + '.json');
3050    xhr.send(null);
3051  };
3052  
3053  ThreadWatcher.linkToThread = function(tid, board, post) {
3054    return '//' + location.host + '/'
3055      + board + '/thread/'
3056      + tid + (post > 0 ? ('#p' + post) : '');
3057  };
3058  
3059  /**
3060   * Draggable helper
3061   */
3062  var Draggable = {
3063    el: null,
3064    key: null,
3065    scrollX: null,
3066    scrollY: null,
3067    dx: null, dy: null, right: null, bottom: null,
3068    
3069    set: function(handle) {
3070      handle.addEventListener('mousedown', Draggable.startDrag, false);
3071    },
3072    
3073    unset: function(handle) {
3074      handle.removeEventListener('mousedown', Draggable.startDrag, false);
3075    },
3076    
3077    startDrag: function(e) {
3078      var self, doc, offs;
3079      
3080      if (this.parentNode.hasAttribute('data-shiftkey') && !e.shiftKey) {
3081        return;
3082      }
3083      
3084      e.preventDefault();
3085      
3086      self = Draggable;
3087      doc = document.documentElement;
3088      
3089      self.el = this.parentNode;
3090      
3091      self.key = self.el.getAttribute('data-trackpos');
3092      offs = self.el.getBoundingClientRect();
3093      self.dx = e.clientX - offs.left;
3094      self.dy = e.clientY - offs.top;
3095      self.right = doc.clientWidth - offs.width;
3096      self.bottom = doc.clientHeight - offs.height;
3097      
3098      if (getComputedStyle(self.el, null).position != 'fixed') {
3099        self.scrollX = window.pageXOffset;
3100        self.scrollY = window.pageYOffset;
3101      }
3102      else {
3103        self.scrollX = self.scrollY = 0;
3104      }
3105      
3106      self.offsetTop = FC.getDocTopOffset();
3107      
3108      document.addEventListener('mouseup', self.endDrag, false);
3109      document.addEventListener('mousemove', self.onDrag, false);
3110    },
3111    
3112    endDrag: function() {
3113      document.removeEventListener('mouseup', Draggable.endDrag, false);
3114      document.removeEventListener('mousemove', Draggable.onDrag, false);
3115      if (Draggable.key && window.Config) {
3116        window.Config[Draggable.key] = Draggable.el.style.cssText;
3117        localStorage.setItem('4chan-settings', JSON.stringify(window.Config));
3118        //StorageSync.sync('4chan-settings');
3119      }
3120      delete Draggable.el;
3121    },
3122    
3123    onDrag: function(e) {
3124      var left, top, style;
3125      
3126      left = e.clientX - Draggable.dx + Draggable.scrollX;
3127      top = e.clientY - Draggable.dy + Draggable.scrollY;
3128      style = Draggable.el.style;
3129      if (left < 1) {
3130        style.left = '0';
3131        style.right = '';
3132      }
3133      else if (Draggable.right < left) {
3134        style.left = '';
3135        style.right = '0';
3136      }
3137      else {
3138        style.left = (left / document.documentElement.clientWidth * 100) + '%';
3139        style.right = '';
3140      }
3141      if (top <= Draggable.offsetTop) {
3142        style.top = Draggable.offsetTop + 'px';
3143        style.bottom = '';
3144      }
3145      else if (Draggable.bottom < top &&
3146        Draggable.el.clientHeight < document.documentElement.clientHeight) {
3147        style.bottom = '0';
3148        style.top = '';
3149      }
3150      else {
3151        style.top = (top / document.documentElement.clientHeight * 100) + '%';
3152        style.bottom = '';
3153      }
3154    }
3155  };
3156  
3157  /**
3158   * Custom Menu
3159   */
3160  var CustomMenu = {
3161    dropDownNav: false,
3162    classicNav: false
3163  };
3164  
3165  CustomMenu.initCtrl = function(dropDownNav, classicNav) {
3166    var el, cnt;
3167    
3168    CustomMenu.dropDownNav = dropDownNav;
3169    CustomMenu.classicNav = classicNav;
3170    
3171    el = document.createElement('span');
3172    el.className = 'custom-menu-ctrl';
3173    el.innerHTML = '[<a data-cm-edit title="Edit Menu" href="#">Edit</a>]';
3174    
3175    if (CustomMenu.dropDownNav && !CustomMenu.classicNav && !FC.hasMobileLayout) {
3176      cnt = $.id('boardSelectMobile').parentNode;
3177      cnt.insertBefore(el, cnt.lastChild);
3178    }
3179    else {
3180      cnt = $.cls('boardList');
3181      cnt[0] && cnt[0].appendChild(el);
3182      cnt[1] && cnt[1].appendChild(el.cloneNode(true));
3183    }
3184  };
3185  /*
3186  CustomMenu.showNWSBoards = function() {
3187    var i, el, nodes, len;
3188    
3189    nodes = $.cls('nwsb');
3190    len = nodes.length;
3191    
3192    for (i = len - 1; el = nodes[i]; i--) {
3193      $.removeClass(el, 'nwsb');
3194    }
3195  };
3196  */
3197  CustomMenu.reset = function() {
3198    var i, el, full, custom, navs;
3199    
3200    full = $.cls('boardList');
3201    custom = $.cls('customBoardList');
3202    navs = $.cls('show-all-boards');
3203    
3204    for (i = 0; el = navs[i]; ++i) {
3205      el.removeEventListener('click', CustomMenu.reset, false);
3206    }
3207    
3208    for (i = custom.length - 1; el = custom[i]; i--) {
3209      full[i].style.display = null;
3210      el.parentNode.removeChild(el);
3211    }
3212  };
3213  
3214  CustomMenu.apply = function(str) {
3215    var i, el, cntBottom, board, navs, boardList, cnt;
3216    
3217    if (!str) {
3218      if (CustomMenu.dropDownNav && !CustomMenu.classicNav && !FC.hasMobileLayout) {
3219        if (el = $.cls('customBoardList')[0]) {
3220          el.parentNode.removeChild(el);
3221        }
3222      }
3223      return;
3224    }
3225    
3226    boardList = str.split(/[^0-9a-z]/i);
3227    
3228    cnt = document.createElement('span');
3229    cnt.className = 'customBoardList';
3230    
3231    for (i = 0; board = boardList[i]; ++i) {
3232      if (i) {
3233        cnt.appendChild(document.createTextNode(' / '));
3234      }
3235      else {
3236        cnt.appendChild(document.createTextNode('['));
3237      }
3238      el = document.createElement('a');
3239      el.textContent = board;
3240      el.href = '//boards.' + $L.d(board) + '/' + board + (board !== 'f' ? '/catalog' : '');
3241      cnt.appendChild(el);
3242    }
3243    
3244    cnt.appendChild(document.createTextNode(']'));
3245    
3246    if (CustomMenu.dropDownNav && !CustomMenu.classicNav && !FC.hasMobileLayout) {
3247      if (el = $.cls('customBoardList')[0]) {
3248        el.parentNode.removeChild(el);
3249      }
3250      navs = $.id('boardSelectMobile');
3251      navs && navs.parentNode.insertBefore(cnt, navs.nextSibling);
3252    }
3253    else {
3254      cnt.appendChild(document.createTextNode(' ['));
3255      el = document.createElement('a');
3256      el.textContent = '…';
3257      el.title = 'Show all';
3258      el.className = 'show-all-boards pointer';
3259      cnt.appendChild(el);
3260      cnt.appendChild(document.createTextNode('] '));
3261      
3262      cntBottom = cnt.cloneNode(true);
3263      
3264      navs = $.cls('boardList');
3265      
3266      for (i = 0; el = navs[i]; ++i) {
3267        el.style.display = 'none';
3268        el.parentNode.insertBefore(i ? cntBottom : cnt, el);
3269      }
3270      
3271      navs = $.cls('show-all-boards');
3272      
3273      for (i = 0; el = navs[i]; ++i) {
3274        el.addEventListener('click', CustomMenu.reset, false);
3275      }
3276    }
3277  };
3278  
3279  CustomMenu.onClick = function(e) {
3280    var t;
3281    
3282    if ((t = e.target) == document) {
3283      return;
3284    }
3285  
3286    if (t.hasAttribute('data-close')) {
3287      CustomMenu.closeEditor();
3288    }
3289    else if (t.hasAttribute('data-save')) {
3290      CustomMenu.save($.id('customMenu').hasAttribute('data-standalone'));
3291    }
3292  };
3293  
3294  CustomMenu.showEditor = function(standalone) {
3295    var cnt, extConfig;
3296    
3297    cnt = document.createElement('div');
3298    cnt.id = 'customMenu';
3299    cnt.className = 'panel';
3300    cnt.setAttribute('data-close', '1');
3301    
3302    if (standalone === true) {
3303      cnt.setAttribute('data-standalone', '1');
3304    }
3305    
3306    cnt.innerHTML = '\
3307  <div class="reply"><div class="panelHeader">Custom Board List\
3308  <span class="panelCtrl"><span data-close="1" class="icon closeIcon"></span></span></div>\
3309  <input placeholder="Example: jp tg mu" id="customMenuBox" type="text" value="">\
3310  <div class="center"><button data-save="1">Save</button></div></div>';
3311  
3312    document.body.appendChild(cnt);
3313    
3314    cnt.style.top = window.pageYOffset
3315      + (0 | (document.documentElement.clientHeight / 2) - (cnt.offsetHeight / 2)) + 'px';
3316    
3317    $.removeClass($.id('backdrop'), 'hidden');
3318    
3319    extConfig = CustomMenu.getConfig();
3320    
3321    if (extConfig.customMenuList) {
3322      $.id('customMenuBox').value = extConfig.customMenuList;
3323    }
3324    
3325    cnt.addEventListener('click', CustomMenu.onClick, false);
3326  };
3327  
3328  CustomMenu.closeEditor = function() {
3329    var el;
3330    
3331    if (el = $.id('customMenu')) {
3332      el.removeEventListener('click', CustomMenu.onClick, false);
3333      document.body.removeChild(el);
3334      $.addClass($.id('backdrop'), 'hidden');
3335    }
3336  };
3337  
3338  CustomMenu.save = function(standalone) {
3339    var input, extConfig;
3340  
3341    if (input = $.id('customMenuBox')) {
3342      if (standalone === true) {
3343        CustomMenu.apply(input.value);
3344        
3345        extConfig = CustomMenu.getConfig();
3346        
3347        extConfig.customMenu = true;
3348        extConfig.customMenuList = input.value;
3349        
3350        localStorage.setItem('4chan-settings', JSON.stringify(extConfig));
3351        //StorageSync.sync('4chan-settings');
3352      }
3353    }
3354    
3355    CustomMenu.closeEditor();
3356  };
3357  
3358  CustomMenu.getConfig = function() {
3359    var extConfig;
3360    
3361    if (extConfig = localStorage.getItem('4chan-settings')) {
3362      return JSON.parse(extConfig);
3363    }
3364    else {
3365      return {};
3366    }
3367  };
3368  
3369  function checkMobileLayout() {
3370    var mobile, desktop;
3371    
3372    if (window.matchMedia) {
3373      return window.matchMedia('(max-width: 480px)').matches
3374        && localStorage.getItem('4chan_never_show_mobile') != 'true';
3375    }
3376    
3377    mobile = $.id('boardNavMobile');
3378    desktop = $.id('boardNavDesktop');
3379      
3380    return mobile && desktop && mobile.offsetWidth > 0 && desktop.offsetWidth === 0;
3381  }
3382  
3383  var StickyNav = {
3384    thres: 5,
3385    pos: 0,
3386    timeout: null,
3387    el: null,
3388    
3389    init: function(classicNav) {
3390      this.el = classicNav ? $.id('boardNavDesktop') : $.id('boardNavMobile');
3391      $.addClass(this.el, 'autohide-nav');
3392      window.addEventListener('scroll', this.onScroll, false);
3393    },
3394    
3395    destroy: function(classicNav) {
3396      this.el = classicNav ? $.id('boardNavDesktop') : $.id('boardNavMobile');
3397      $.removeClass(this.el, 'autohide-nav');
3398      window.removeEventListener('scroll', this.onScroll, false);
3399    },
3400    
3401    onScroll: function() {
3402      clearTimeout(StickyNav.timeout);
3403      StickyNav.timeout = setTimeout(StickyNav.checkScroll, 50);
3404    },
3405    
3406    checkScroll: function() {
3407      var thisPos;
3408      
3409      thisPos = window.pageYOffset;
3410      
3411      if (Math.abs(StickyNav.pos - thisPos) <= StickyNav.thres) {
3412        return;
3413      }
3414      
3415      if (thisPos < StickyNav.pos) {
3416        StickyNav.el.style.top = '';
3417      }
3418      else {
3419        StickyNav.el.style.top = '-' + StickyNav.el.offsetHeight + 'px';
3420      }
3421      
3422      StickyNav.pos = thisPos;
3423    }
3424  };
3425  
3426  FC.panelHTML = {
3427    build: function(id, cls) {
3428      var el;
3429      
3430      el = document.createElement('div');
3431      el.id = id;
3432      el.className = cls;
3433      el.innerHTML = FC.panelHTML[id];
3434      
3435      document.body.appendChild(el);
3436      
3437      return el;
3438    },
3439    
3440    'theme': '<div class="panelHeader">Settings<span id="theme-close" class="icon closeIcon" title="Close"></span></div>\
3441    <h4>Options</h4>\
3442    <ul class="clickset">\
3443      <li class="desktop"><label><input id="theme-nobinds" type="checkbox"> Disable keybinds</label></li>\
3444      <li><label><input id="theme-nospoiler" type="checkbox"> Don\'t spoiler images</label></li>\
3445      <li><label><input id="theme-newtab" type="checkbox"> Open threads in a new tab</label></li>\
3446      <li class="desktop"><label><input id="theme-tw" type="checkbox"> Thread Watcher</label></li>\
3447      <li class="desktop"><label><input id="theme-ddn" type="checkbox"> Use drop-down navigation</label></li>\
3448    </ul>\
3449    <h4 class="desktop">Shortcuts</h4>\
3450    <ul class="clickset desktop">\
3451      <li><kbd>R</kbd> &mdash; Refresh current page</li>\
3452      <li><kbd>X</kbd> &mdash; Reorder threads</li>\
3453      <li><kbd>S</kbd> &mdash; Open search box, <kbd>Esc</kbd> to close</li>\
3454      <li><kbd>Shift</kbd> <kbd title="Left Mouse Button">LMB</kbd> &mdash; Hide threads</li>\
3455      <li><kbd>Alt</kbd> <kbd title="Left Mouse Button">LMB</kbd> &mdash; Pin threads</li>\
3456      <li><kbd title="Right Mouse Button">RMB</kbd> &mdash; Threads context menu (Firefox only)</li>\
3457    </ul>\
3458    <h4>Custom CSS</h4>\
3459    <textarea id="theme-css" rows="4" cols="45"></textarea>\
3460    <div id="theme-btns">\
3461      <span id="theme-msg"></span>\
3462      <div class="center"><button id="theme-save">Save Settings</button></div>\
3463    </div>',
3464    
3465    'filters-protip': '<div class="panelHeader">Filters &amp; Highlights Help<span id="filters-help-close" class="icon closeIcon" title="Close"></span></div>\
3466    <h4>Patterns</h4>\
3467    <ul><li><ul>\
3468        <li><strong>Matching whole words:</strong></li>\
3469        <li><code>feel</code> &mdash; will match <em>"feel"</em> but not <em>"feeling"</em>. This search is case-insensitive.</li>\
3470      </ul></li>\
3471      <li><ul>\
3472        <li><strong>AND operator:</strong></li>\
3473        <li><code>feel girlfriend</code> &mdash; will match <em>"feel"</em> AND <em>"girlfriend"</em> in any order.</li>\
3474      </ul></li>\
3475      <li><ul>\
3476        <li><strong>OR operator:</strong></li>\
3477        <li><code>feel|girlfriend</code> &mdash; will match <em>"feel"</em> OR <em>"girlfriend"</em>.</li>\
3478      </ul></li>\
3479      <li><ul>\
3480        <li><strong>Mixing both operators:</strong></li>\
3481        <li><code>girlfriend|boyfriend feel</code> &mdash; matches <em>"feel"</em> AND <em>"girlfriend"</em>, or <em>"feel"</em> AND <em>"boyfriend"</em>.</li>\
3482      </ul></li>\
3483      <li><ul>\
3484        <li><strong>Exact match search:</strong></li>\
3485        <li><code>"that feel when"</code> &mdash; place double quotes around the pattern to search for an exact string</li>\
3486      </ul></li>\
3487      <li><ul>\
3488        <li><strong>Wildcards:</strong></li>\
3489        <li><code>feel*</code> &mdash; matches expressions such as <em>"feel"</em>, <em>"feels"</em>, <em>"feeling"</em>, <em>"feeler"</em>, etc&hellip;</li>\
3490        <li><code>idolm*ster</code> &mdash; this can match <em>"idolmaster"</em> or <em>"idolm@ster"</em>, etc&hellip;</li>\
3491      </ul></li>\
3492      <li><ul>\
3493        <li><strong>Filtering by name or tripcode:</strong></li>\
3494        <li>Prefix the pattern with <code>#</code> to search by <em>tripcode</em>: <code>#!Ep8pui8Vw2</code></li>\
3495        <li>Prefix the pattern with <code>##</code> to search by <em>name</em>: <code>##Anonymous</code></li>\
3496        <li>To filter by <em>capcode</em>: <code>#!#admin</code>, <code>#!#mod</code>, <code>#!#developer</code></li>\
3497      </ul></li>\
3498      <li><ul>\
3499        <li><strong>It is also possible to filter by regular expression:</strong></li>\
3500        <li><code>/^(?=.*detachable)(?=.*hats).*$/i</code> &mdash; AND operator.</li>\
3501        <li><code>/^(?!.*touhou).*$/i</code> &mdash; NOT operator.</li>\
3502        <li><code>/^&amp;gt;/</code> &mdash; threads starting with a quote (<em>"&gt;"</em> character as an html entity).</li>\
3503        <li><code>/^$/</code> &mdash; threads with no text.</li>\
3504      </ul></li>\
3505    </ul>\
3506    <hr>\
3507    <h4>Controls</h4>\
3508    <ul>\
3509      <li><strong>On</strong> &mdash; enables or disables the filter.</li>\
3510      <li><strong>Boards</strong> &mdash; space separated list of boards on which the filter will be active. Leave blank to apply to all boards.</li>\
3511      <li><strong>Hide</strong> &mdash; hides matched threads.</li>\
3512      <li><strong>Top</strong> &mdash; puts matched threads on top of the list.</li>\
3513    </ul>',
3514    
3515    'filter-palette': '<div id="colorpicker" class="panel"><table id="filter-color-table"><tbody></tbody><tfoot>\
3516      <tr><td>Custom</td></tr>\
3517      <tr><td class="middle-txt"><input class="custom-rgb" type="text" name="custom-rgb" value="" id="filter-rgb"><span title="Select Color" id="filter-rgb-ok" class="button clickbox"></span></td></tr>\
3518      <tr><td>\
3519        <span class="btn-wrap"><span id="filter-palette-close" class="button">Close</span></span>\
3520        <span class="btn-wrap"><span id="filter-palette-clear" class="button">Clear</span></span>\
3521      </td></tr>\
3522      </tfoot></table></div>',
3523    
3524    'filters': '<div class="panelHeader"><input placeholder="Search" type="text" id="filters-search">Filters &amp; Highlights<span id="filters-help-open" class="icon helpIcon" title="Help"></span><span id="filters-close" class="icon closeIcon" title="Close"></span></div>\
3525    <table id="filter-table">\
3526      <thead><tr>\
3527        <th>Order</th>\
3528        <th>On</th>\
3529        <th>Pattern</th>\
3530        <th>Boards</th>\
3531        <th>Color</th>\
3532        <th>Hide</th>\
3533        <th>Top</th>\
3534        <th>Del</th>\
3535        <th></th>\
3536      </tr></thead>\
3537      <tbody id="filter-list"></tbody>\
3538      <tfoot><tr><td colspan="9">\
3539        <button id="filters-add" class="left">Add</button>\
3540        <span class="right"><span id="filters-msg"></span><button id="filters-save">Save</button></span>\
3541      </td></tr></tfoot></table>'
3542  };
3543  
3544  var PostMenu = {
3545    activeBtn: null
3546  };
3547  
3548  PostMenu.open = function(btn, pid, hasThreadWatcher, hidden, pinned) {
3549    var div, html, btnPos, left, limit, tr;
3550    
3551    if (PostMenu.activeBtn == btn) {
3552      PostMenu.close();
3553      return;
3554    }
3555    
3556    PostMenu.close();
3557    
3558    tr = btn.parentNode.parentNode;
3559    
3560    html = '<ul><li data-report="' + pid + '">Report thread</li>'
3561      + '<li data-pin="' + pid + '">'
3562        + (pinned ? 'Unpin' : 'Pin') + ' thread</li>'
3563      + '<li data-hide="' + pid + '">'
3564        + (hidden ? 'Unhide' : 'Hide') + ' thread</li>';
3565    
3566    if (hasThreadWatcher) {
3567      html += '<li data-watch="' + pid + '">'
3568        + (ThreadWatcher.watched[pid + '-' + catalog.slug] ? 'Remove from' : 'Add to')
3569        + ' watch list</li>';
3570    }
3571    
3572    div = document.createElement('div');
3573    div.id = 'post-menu';
3574    div.className = 'dd-menu';
3575    div.innerHTML = html + '</ul>';
3576    
3577    btnPos = btn.getBoundingClientRect();
3578    
3579    div.style.top = btnPos.bottom + 3 + window.pageYOffset + 'px';
3580    
3581    document.addEventListener('click', PostMenu.close, false);
3582    
3583    $.addClass(btn, 'menuOpen');
3584    PostMenu.activeBtn = btn;
3585    
3586    UA.dispatchEvent('4chanPostMenuReady', { postId: pid, isOP: true, node: div.firstElementChild });
3587    
3588    document.body.appendChild(div);
3589    
3590    left = btnPos.left + window.pageXOffset;
3591    limit = document.documentElement.clientWidth - div.offsetWidth;
3592    
3593    if (left > (limit - 75)) {
3594      div.className += ' dd-menu-left';
3595    }
3596    
3597    if (left > limit) {
3598      left = limit;
3599    }
3600    
3601    div.style.left = left + 'px';
3602  };
3603  
3604  PostMenu.close = function() {
3605    var el;
3606    
3607    if (el = $.id('post-menu')) {
3608      el.parentNode.removeChild(el);
3609      document.removeEventListener('click', PostMenu.close, false);
3610      $.removeClass(PostMenu.activeBtn, 'menuOpen');
3611      PostMenu.activeBtn = null;
3612    }
3613  };