diff --git a/src/features.coffee b/src/features.coffee deleted file mode 100644 index 822325e8a..000000000 --- a/src/features.coffee +++ /dev/null @@ -1,4382 +0,0 @@ -Header = - init: -> - headerEl = $.el 'div', - id: 'header' - innerHTML: """ -
- - - - - - -
-
-
- """.replace />\s+<' # get rid of spaces between elements - - @bar = $ '#header-bar', headerEl - @toggle = $ '#toggle-header-bar', @bar - - @menu = new UI.Menu 'header' - $.on $('.menu-button', @bar), 'click', @menuToggle - $.on @toggle, 'mousedown', @toggleBarVisibility - $.on window, 'load hashchange', Header.hashScroll - $.on d, 'CreateNotification', @createNotification - - catalogToggler = $.el 'label', - innerHTML: ' Use catalog board links' - headerToggler = $.el 'label', - innerHTML: ' Auto-hide header' - barPositionToggler = $.el 'label', - innerHTML: ' Bottom header' - - @catalogToggler = catalogToggler.firstElementChild - @headerToggler = headerToggler.firstElementChild - @barPositionToggler = barPositionToggler.firstElementChild - - $.on @catalogToggler, 'change', @toggleCatalogLinks - $.on @headerToggler, 'change', @toggleBarVisibility - $.on @barPositionToggler, 'change', @toggleBarPosition - - @setBarVisibility Conf['Header auto-hide'] - @setBarPosition Conf['Bottom header'] - - $.sync 'Header auto-hide', @setBarVisibility - $.sync 'Bottom header', @setBarPosition - - $.event 'AddMenuEntry', - type: 'header' - el: $.el 'span', textContent: 'Header' - order: 105 - subEntries: [ - {el: catalogToggler} - {el: headerToggler} - {el: barPositionToggler} - ] - - $.asap (-> d.body), -> - return unless Main.isThisPageLegit() - # Wait for #boardNavMobile instead of #boardNavDesktop, - # it might be incomplete otherwise. - $.asap (-> $.id 'boardNavMobile'), Header.setBoardList - $.prepend d.body, headerEl - - setBoardList: -> - nav = $.id 'boardNavDesktop' - if a = $ "a[href*='/#{g.BOARD}/']", nav - a.className = 'current' - fullBoardList = $ '#full-board-list', Header.bar - $.add fullBoardList, [nav.childNodes...] - - if Conf['Custom Board Navigation'] - Header.generateBoardList Conf['boardnav'] - $.sync 'boardnav', Header.generateBoardList - btn = $.el 'span', - className: 'hide-board-list-button brackets-wrap' - innerHTML: ' - ' - $.on btn, 'click', Header.toggleBoardList - $.add fullBoardList, btn - else - $.rm $ '#custom-board-list', Header.bar - fullBoardList.hidden = false - - Header.setCatalogLinks Conf['Header catalog links'] - $.sync 'Header catalog links', Header.setCatalogLinks - - generateBoardList: (text) -> - unless list = $ '#custom-board-list', Header.bar - # init'd with the custom board list disabled. - return - $.rmAll list - return unless text - as = $$('#full-board-list a', Header.bar)[0...-2] # ignore the Settings and Home links - nodes = text.match(/[\w@]+(-(all|title|full|index|catalog|text:"[^"]+"))*|[^\w@]+/g).map (t) -> - if /^[^\w@]/.test t - return $.tn t - if /^toggle-all/.test t - a = $.el 'a', - className: 'show-board-list-button' - textContent: (t.match(/-text:"(.+)"/) || [null, '+'])[1] - href: 'javascript:;' - $.on a, 'click', Header.toggleBoardList - return a - board = if /^current/.test t - g.BOARD.ID - else - t.match(/^[^-]+/)[0] - for a in as - if a.textContent is board - a = a.cloneNode true - if /-title/.test t - a.textContent = a.title - else if /-full/.test t - a.textContent = "/#{board}/ - #{a.title}" - else if /-(index|catalog|text)/.test t - if m = t.match /-(index|catalog)/ - a.setAttribute 'data-only', m[1] - a.href = "//boards.4chan.org/#{board}/" - a.href += 'catalog' if m[1] is 'catalog' - if m = t.match /-text:"(.+)"/ - a.textContent = m[1] - else if board is '@' - $.addClass a, 'navSmall' - return a - $.tn t - $.add list, nodes - - toggleBoardList: -> - {bar} = Header - custom = $ '#custom-board-list', bar - full = $ '#full-board-list', bar - showBoardList = !full.hidden - custom.hidden = !showBoardList - full.hidden = showBoardList - - setCatalogLinks: (useCatalog) -> - Header.catalogToggler.checked = useCatalog - as = $$ '#board-list a[href*="boards.4chan.org"]', Header.bar - path = if useCatalog then 'catalog' else '' - for a in as - continue if a.dataset.only - a.pathname = "/#{a.pathname.split('/')[1]}/#{path}" - return - toggleCatalogLinks: -> - $.cb.checked.call @ - Header.setCatalogLinks @checked - - setBarVisibility: (hide) -> - Header.headerToggler.checked = hide - $.event 'CloseMenu' - (if hide then $.addClass else $.rmClass) Header.bar, 'autohide' - toggleBarVisibility: (e) -> - return if e.type is 'mousedown' and e.button isnt 0 # not LMB - hide = if @nodeName is 'INPUT' - @checked - else - !$.hasClass Header.bar, 'autohide' - Conf['Header auto-hide'] = hide - $.set 'Header auto-hide', hide - Header.setBarVisibility hide - message = if hide - 'The header bar will automatically hide itself.' - else - 'The header bar will remain visible.' - new Notification 'info', message, 2 - - setBarPosition: (bottom) -> - Header.barPositionToggler.checked = bottom - $.event 'CloseMenu' - if bottom - $.addClass doc, 'bottom-header' - $.rmClass doc, 'top-header' - Header.bar.parentNode.className = 'bottom' - else - $.addClass doc, 'top-header' - $.rmClass doc, 'bottom-header' - Header.bar.parentNode.className = 'top' - toggleBarPosition: -> - $.cb.checked.call @ - Header.setBarPosition @checked - - hashScroll: -> - return unless post = $.id @location.hash[1..] - return if (Get.postFromRoot post).isHidden - Header.scrollToPost post - scrollToPost: (post) -> - {top} = post.getBoundingClientRect() - unless Conf['Bottom header'] - headRect = Header.toggle.getBoundingClientRect() - top += - headRect.top - headRect.height - <% if (type === 'crx') { %>d.body<% } else { %>doc<% } %>.scrollTop += top - - addShortcut: (el) -> - shortcut = $.el 'span', - className: 'shortcut' - $.add shortcut, el - $.prepend $('#shortcuts', Header.bar), shortcut - - menuToggle: (e) -> - Header.menu.toggle e, @, g - - createNotification: (e) -> - {type, content, lifetime, cb} = e.detail - notif = new Notification type, content, lifetime - cb notif if cb - -class Notification - constructor: (type, content, @timeout) -> - @add = add.bind @ - @close = close.bind @ - - @el = $.el 'div', - innerHTML: '×
' - @el.style.opacity = 0 - @setType type - $.on @el.firstElementChild, 'click', @close - if typeof content is 'string' - content = $.tn content - $.add @el.lastElementChild, content - - $.ready @add - - setType: (type) -> - @el.className = "notification #{type}" - - add = -> - if d.hidden - $.on d, 'visibilitychange', @add - return - $.off d, 'visibilitychange', @add - $.add $.id('notifications'), @el - @el.clientHeight # force reflow - @el.style.opacity = 1 - setTimeout @close, @timeout * $.SECOND if @timeout - - close = -> - $.rm @el - -Settings = - init: -> - # 4chan X settings link - link = $.el 'a', - className: 'settings-link' - textContent: '<%= meta.name %> Settings' - href: 'javascript:;' - $.on link, 'click', Settings.open - $.event 'AddMenuEntry', - type: 'header' - el: link - order: 111 - - # 4chan settings link - link = $.el 'a', - className: 'fourchan-settings-link' - textContent: '4chan Settings' - href: 'javascript:;' - $.on link, 'click', -> $.id('settingsWindowLink').click() - $.event 'AddMenuEntry', - type: 'header' - el: link - order: 110 - open: -> Conf['Enable 4chan\'s Extension'] - - $.get 'previousversion', null, (item) -> - if previous = item['previousversion'] - return if previous is g.VERSION - # Avoid conflicts between sync'd newer versions - # and out of date extension on this device. - prev = previous.match(/\d+/g).map Number - curr = g.VERSION.match(/\d+/g).map Number - - changelog = '<%= meta.repo %>blob/<%= meta.mainBranch %>/CHANGELOG.md' - el = $.el 'span', - innerHTML: "<%= meta.name %> has been updated to version #{g.VERSION}." - new Notification 'info', el, 30 - else - $.on d, '4chanXInitFinished', Settings.open - $.set - lastupdate: Date.now() - previousversion: g.VERSION - - Settings.addSection 'Main', Settings.main - Settings.addSection 'Filter', Settings.filter - Settings.addSection 'Sauce', Settings.sauce - Settings.addSection 'Rice', Settings.rice - Settings.addSection 'Keybinds', Settings.keybinds - $.on d, 'AddSettingsSection', Settings.addSection - $.on d, 'OpenSettings', (e) -> Settings.open e.detail - - return if Conf['Enable 4chan\'s Extension'] - settings = JSON.parse(localStorage.getItem '4chan-settings') or {} - return if settings.disableAll - settings.disableAll = true - localStorage.setItem '4chan-settings', JSON.stringify settings - - open: (openSection) -> - $.off d, '4chanXInitFinished', Settings.open - return if Settings.dialog - $.event 'CloseMenu' - - html = """ -
- -
-
-
- """ - - Settings.dialog = overlay = $.el 'div', - id: 'overlay' - innerHTML: html - - links = [] - for section in Settings.sections - link = $.el 'a', - className: "tab-#{section.hyphenatedTitle}" - textContent: section.title - href: 'javascript:;' - $.on link, 'click', Settings.openSection.bind section - links.push link, $.tn ' | ' - sectionToOpen = link if section.title is openSection - links.pop() - $.add $('.sections-list', overlay), links - (if sectionToOpen then sectionToOpen else links[0]).click() - - $.on $('.close', overlay), 'click', Settings.close - $.on overlay, 'click', Settings.close - $.on overlay.firstElementChild, 'click', (e) -> e.stopPropagation() - - d.body.style.width = "#{d.body.clientWidth}px" - $.addClass d.body, 'unscroll' - $.add d.body, overlay - close: -> - return unless Settings.dialog - d.body.style.removeProperty 'width' - $.rmClass d.body, 'unscroll' - $.rm Settings.dialog - delete Settings.dialog - - sections: [] - addSection: (title, open) -> - if typeof title isnt 'string' - {title, open} = title.detail - hyphenatedTitle = title.toLowerCase().replace /\s+/g, '-' - Settings.sections.push {title, hyphenatedTitle, open} - openSection: -> - if selected = $ '.tab-selected', Settings.dialog - $.rmClass selected, 'tab-selected' - $.addClass $(".tab-#{@hyphenatedTitle}", Settings.dialog), 'tab-selected' - section = $ 'section', Settings.dialog - $.rmAll section - section.className = "section-#{@hyphenatedTitle}" - @open section, g - section.scrollTop = 0 - - main: (section) -> - section.innerHTML = """ -
- - - -
-

- """ - $.on $('.export', section), 'click', Settings.export - $.on $('.import', section), 'click', Settings.import - $.on $('input', section), 'change', Settings.onImport - - items = {} - inputs = {} - for key, obj of Config.main - fs = $.el 'fieldset', - innerHTML: "#{key}" - for key, arr of obj - description = arr[1] - div = $.el 'div', - innerHTML: ": #{description}" - input = $ 'input', div - $.on input, 'change', $.cb.checked - items[key] = Conf[key] - inputs[key] = input - $.add fs, div - $.add section, fs - - $.get items, (items) -> - for key, val of items - inputs[key].checked = val - return - - div = $.el 'div', - innerHTML: ": Clear manually-hidden threads and posts on all boards. Refresh the page to apply." - button = $ 'button', div - hiddenNum = 0 - $.get 'hiddenThreads', boards: {}, (item) -> - for ID, board of item.hiddenThreads.boards - for ID, thread of board - hiddenNum++ - button.textContent = "Hidden: #{hiddenNum}" - $.get 'hiddenPosts', boards: {}, (item) -> - for ID, board of item.hiddenPosts.boards - for ID, thread of board - for ID, post of thread - hiddenNum++ - button.textContent = "Hidden: #{hiddenNum}" - $.on button, 'click', -> - @textContent = 'Hidden: 0' - $.get 'hiddenThreads', boards: {}, (item) -> - for boardID of item.hiddenThreads.boards - localStorage.removeItem "4chan-hide-t-#{boardID}" - $.delete ['hiddenThreads', 'hiddenPosts'] - $.after $('input[name="Stubs"]', section).parentNode.parentNode, div - export: (now, data) -> - unless typeof now is 'number' - now = Date.now() - data = - version: g.VERSION - date: now - Conf['WatchedThreads'] = {} - for db in DataBoards - Conf[db] = boards: {} - # Make sure to export the most recent data. - $.get Conf, (Conf) -> - data.Conf = Conf - Settings.export now, data - return - a = $.el 'a', - className: 'warning' - textContent: 'Save me!' - download: "<%= meta.name %> v#{g.VERSION}-#{now}.json" - href: "data:application/json;base64,#{btoa unescape encodeURIComponent JSON.stringify data, null, 2}" - target: '_blank' - <% if (type === 'userscript') { %> - # XXX Firefox won't let us download automatically. - p = $ '.imp-exp-result', Settings.dialog - $.rmAll p - $.add p, a - <% } else { %> - a.click() - <% } %> - import: -> - @nextElementSibling.click() - onImport: -> - return unless file = @files[0] - output = @parentNode.nextElementSibling - unless confirm 'Your current settings will be entirely overwritten, are you sure?' - output.textContent = 'Import aborted.' - return - reader = new FileReader() - reader.onload = (e) -> - try - data = JSON.parse e.target.result - Settings.loadSettings data - if confirm 'Import successful. Refresh now?' - window.location.reload() - catch err - output.textContent = 'Import failed due to an error.' - c.error err.stack - reader.readAsText file - loadSettings: (data) -> - version = data.version.split '.' - if version[0] is '2' - data = Settings.convertSettings data, - # General confs - 'Disable 4chan\'s extension': '' - 'Catalog Links': '' - 'Reply Navigation': '' - 'Show Stubs': 'Stubs' - 'Image Auto-Gif': 'Auto-GIF' - 'Expand From Current': '' - 'Unread Favicon': 'Unread Tab Icon' - 'Post in Title': 'Thread Excerpt' - 'Auto Hide QR': '' - 'Open Reply in New Tab': '' - 'Remember QR size': '' - 'Quote Inline': 'Quote Inlining' - 'Quote Preview': 'Quote Previewing' - 'Indicate OP quote': 'Mark OP Quotes' - 'Indicate Cross-thread Quotes': 'Mark Cross-thread Quotes' - # filter - 'uniqueid': 'uniqueID' - 'mod': 'capcode' - 'country': 'flag' - 'md5': 'MD5' - # keybinds - 'openEmptyQR': 'Open empty QR' - 'openQR': 'Open QR' - 'openOptions': 'Open settings' - 'close': 'Close' - 'spoiler': 'Spoiler tags' - 'code': 'Code tags' - 'submit': 'Submit QR' - 'watch': 'Watch' - 'update': 'Update' - 'unreadCountTo0': '' - 'expandAllImages': 'Expand images' - 'expandImage': 'Expand image' - 'zero': 'Front page' - 'nextPage': 'Next page' - 'previousPage': 'Previous page' - 'nextThread': 'Next thread' - 'previousThread': 'Previous thread' - 'expandThread': 'Expand thread' - 'openThreadTab': 'Open thread' - 'openThread': 'Open thread tab' - 'nextReply': 'Next reply' - 'previousReply': 'Previous reply' - 'hide': 'Hide' - # updater - 'Scrolling': 'Auto Scroll' - 'Verbose': '' - data.Conf.sauces = data.Conf.sauces.replace /\$\d/g, (c) -> - switch c - when '$1' - '%TURL' - when '$2' - '%URL' - when '$3' - '%MD5' - when '$4' - '%board' - else - c - for key, val of Config.hotkeys - continue unless key of data.Conf - data.Conf[key] = data.Conf[key].replace(/ctrl|alt|meta/g, (s) -> "#{s[0].toUpperCase()}#{s[1..]}").replace /(^|.+\+)[A-Z]$/g, (s) -> - "Shift+#{s[0...-1]}#{s[-1..].toLowerCase()}" - data.Conf.WatchedThreads = data.WatchedThreads - $.set data.Conf - convertSettings: (data, map) -> - for prevKey, newKey of map - data.Conf[newKey] = data.Conf[prevKey] if newKey - delete data.Conf[prevKey] - data - - filter: (section) -> - section.innerHTML = """ - -
- """ - select = $ 'select', section - $.on select, 'change', Settings.selectFilter - Settings.selectFilter.call select - selectFilter: -> - div = @nextElementSibling - if (name = @value) isnt 'guide' - $.rmAll div - ta = $.el 'textarea', - name: name - className: 'field' - spellcheck: false - $.get name, Conf[name], (item) -> - ta.value = item[name] - $.on ta, 'change', $.cb.value - $.add div, ta - return - div.innerHTML = """ -
Filter is disabled.
-

- Use regular expressions, one per line.
- Lines starting with a # will be ignored.
- For example, /weeaboo/i will filter posts containing the string `weeaboo`, case-insensitive.
- MD5 filtering uses exact string matching, not regular expressions. -

- - """ - - sauce: (section) -> - section.innerHTML = """ -
Sauce is disabled.
-
Lines starting with a # will be ignored.
-
You can specify a display text by appending ;text:[text] to the URL.
- - - """ - sauce = $ 'textarea', section - $.get 'sauces', Conf['sauces'], (item) -> - sauce.value = item['sauces'] - $.on sauce, 'change', $.cb.value - - rice: (section) -> - section.innerHTML = """ -
- Custom Board Navigation is disabled. -
-
In the following, board can translate to a board ID (a, b, etc...), the current board (current), or the Status/Twitter link (status, @).
-
Board link: board
-
Title link: board-title
-
Full text link: board-full
-
Custom text link: board-text:"VIP Board"
-
Index-only link: board-index
-
Catalog-only link: board-catalog
-
Combinations are possible: board-index-text:"VIP Index"
-
Full board list toggle: toggle-all
-
- -
- Time Formatting is disabled. -
:
-
Supported format specifiers:
-
Day: %a, %A, %d, %e
-
Month: %m, %b, %B
-
Year: %y
-
Hour: %k, %H, %l, %I, %p, %P
-
Minute: %M
-
Second: %S
-
- -
- Quote Backlinks formatting is disabled. -
:
-
- -
- File Info Formatting is disabled. -
:
-
Link: %l (truncated), %L (untruncated), %T (Unix timestamp)
-
Original file name: %n (truncated), %N (untruncated), %t (Unix timestamp)
-
Spoiler indicator: %p
-
Size: %B (Bytes), %K (KB), %M (MB), %s (4chan default)
-
Resolution: %r (Displays 'PDF' for PDF files)
-
- -
- Unread Tab Icon is disabled. - - -
- -
- - - - - -
- """ - items = {} - inputs = {} - for name in ['boardnav', 'time', 'backlink', 'fileInfo', 'favicon', 'usercss'] - input = $ "[name=#{name}]", section - items[name] = Conf[name] - inputs[name] = input - event = if name in ['favicon', 'usercss'] - 'change' - else - 'input' - $.on input, event, $.cb.value - $.get items, (items) -> - for key, val of items - input = inputs[key] - input.value = val - unless key in ['usercss'] - $.on input, event, Settings[key] - Settings[key].call input - return - $.on $('input[name="Custom CSS"]', section), 'change', Settings.togglecss - $.on $.id('apply-css'), 'click', Settings.usercss - boardnav: -> - Header.generateBoardList @value - time: -> - funk = Time.createFunc @value - @nextElementSibling.textContent = funk Time, new Date() - backlink: -> - @nextElementSibling.textContent = Conf['backlink'].replace /%id/, '123456789' - fileInfo: -> - data = - isReply: true - file: - URL: '//images.4chan.org/g/src/1334437723720.jpg' - name: 'd9bb2efc98dd0df141a94399ff5880b7.jpg' - size: '276 KB' - sizeInBytes: 276 * 1024 - dimensions: '1280x720' - isImage: true - isSpoiler: true - funk = FileInfo.createFunc @value - @nextElementSibling.innerHTML = funk FileInfo, data - favicon: -> - Favicon.switch() - Unread.update() if g.VIEW is 'thread' and Conf['Unread Tab Icon'] - @nextElementSibling.innerHTML = """ - - - - - """ - togglecss: -> - if $('textarea[name=usercss]', $.x 'ancestor::fieldset[1]', @).disabled = !@checked - CustomCSS.rmStyle() - else - CustomCSS.addStyle() - $.cb.checked.call @ - usercss: -> - CustomCSS.update() - - keybinds: (section) -> - section.innerHTML = """ -
Keybinds are disabled.
-
Allowed keys: a-z, 0-9, Ctrl, Shift, Alt, Meta, Enter, Esc, Up, Down, Right, Left.
-
Press Backspace to disable a keybind.
- - -
ActionsKeybinds
- """ - tbody = $ 'tbody', section - items = {} - inputs = {} - for key, arr of Config.hotkeys - tr = $.el 'tr', - innerHTML: "#{arr[1]}" - input = $ 'input', tr - input.name = key - input.spellcheck = false - items[key] = Conf[key] - inputs[key] = input - $.on input, 'keydown', Settings.keybind - $.add tbody, tr - $.get items, (items) -> - for key, val of items - inputs[key].value = val - return - keybind: (e) -> - return if e.keyCode is 9 # tab - e.preventDefault() - e.stopPropagation() - return unless (key = Keybinds.keyCode e)? - @value = key - $.cb.value.call @ - -PSAHiding = - init: -> - return if !Conf['Announcement Hiding'] - - $.addClass doc, 'hide-announcement' - - $.on d, '4chanXInitFinished', @setup - setup: -> - $.off d, '4chanXInitFinished', PSAHiding.setup - - unless psa = $.id 'globalMessage' - $.rmClass doc, 'hide-announcement' - return - - PSAHiding.btn = btn = $.el 'a', - title: 'Toggle announcement.' - innerHTML: '' - href: 'javascript:;' - $.on btn, 'click', PSAHiding.toggle - - text = PSAHiding.trim psa - $.get 'hiddenPSAs', [], (item) -> - PSAHiding.sync item['hiddenPSAs'] - $.before psa, btn - $.rmClass doc, 'hide-announcement' - - $.sync 'hiddenPSAs', PSAHiding.sync - toggle: (e) -> - hide = $.hasClass @, 'hide-announcement' - text = PSAHiding.trim $.id 'globalMessage' - $.get 'hiddenPSAs', [], ({hiddenPSAs}) -> - if hide - hiddenPSAs.push text - else - i = hiddenPSAs.indexOf text - hiddenPSAs.splice i, 1 - hiddenPSAs = hiddenPSAs[-5..] - PSAHiding.sync hiddenPSAs - $.set 'hiddenPSAs', hiddenPSAs - sync: (hiddenPSAs) -> - {btn} = PSAHiding - psa = $.id 'globalMessage' - [psa.hidden, btn.firstChild.textContent, btn.className] = if PSAHiding.trim(psa) in hiddenPSAs - [true, '[\u00A0+\u00A0]', 'show-announcement'] - else - [false, '[\u00A0-\u00A0]', 'hide-announcement'] - trim: (psa) -> - psa.textContent.replace(/\W+/g, '').toLowerCase() - -Fourchan = - init: -> - return if g.VIEW is 'catalog' - - board = g.BOARD.ID - if board is 'g' - $.globalEval """ - window.addEventListener('prettyprint', function(e) { - var pre = e.detail; - pre.innerHTML = prettyPrintOne(pre.innerHTML); - }, false); - """ - Post::callbacks.push - name: 'Parse /g/ code' - cb: @code - if board is 'sci' - # https://github.com/MayhemYDG/4chan-x/issues/645#issuecomment-13704562 - $.globalEval """ - window.addEventListener('jsmath', function(e) { - if (jsMath.loaded) { - // process one post - jsMath.ProcessBeforeShowing(e.detail); - } else { - // load jsMath and process whole document - jsMath.Autoload.Script.Push('ProcessBeforeShowing', [null]); - jsMath.Autoload.LoadJsMath(); - } - }, false); - """ - Post::callbacks.push - name: 'Parse /sci/ math' - cb: @math - code: -> - return if @isClone - for pre in $$ '.prettyprint', @nodes.comment - $.event 'prettyprint', pre, window - return - math: -> - return if @isClone or !$ '.math', @nodes.comment - $.event 'jsmath', @nodes.post, window - parseThread: (threadID, offset, limit) -> - # Fix /sci/ - # Fix /g/ - $.event '4chanParsingDone', - threadId: threadID - offset: offset - limit: limit - -CustomCSS = - init: -> - return if !Conf['Custom CSS'] - @addStyle() - addStyle: -> - @style = $.addStyle Conf['usercss'] - rmStyle: -> - if @style - $.rm @style - delete @style - update: -> - unless @style - @addStyle() - @style.textContent = Conf['usercss'] - -Filter = - filters: {} - init: -> - return if g.VIEW is 'catalog' or !Conf['Filter'] - - for key of Config.filter - @filters[key] = [] - for filter in Conf[key].split '\n' - continue if filter[0] is '#' - - unless regexp = filter.match /\/(.+)\/(\w*)/ - continue - - # Don't mix up filter flags with the regular expression. - filter = filter.replace regexp[0], '' - - # Do not add this filter to the list if it's not a global one - # and it's not specifically applicable to the current board. - # Defaults to global. - boards = filter.match(/boards:([^;]+)/)?[1].toLowerCase() or 'global' - if boards isnt 'global' and not (g.BOARD.ID in boards.split ',') - continue - - if key in ['uniqueID', 'MD5'] - # MD5 filter will use strings instead of regular expressions. - regexp = regexp[1] - else - try - # Please, don't write silly regular expressions. - regexp = RegExp regexp[1], regexp[2] - catch err - # I warned you, bro. - new Notification 'warning', err.message, 60 - continue - - # Filter OPs along with their threads, replies only, or both. - # Defaults to both. - op = filter.match(/[^t]op:(yes|no|only)/)?[1] or 'yes' - - # Overrule the `Show Stubs` setting. - # Defaults to stub showing. - stub = switch filter.match(/stub:(yes|no)/)?[1] - when 'yes' - true - when 'no' - false - else - Conf['Stubs'] - - # Highlight the post, or hide it. - # If not specified, the highlight class will be filter-highlight. - # Defaults to post hiding. - if hl = /highlight/.test filter - hl = filter.match(/highlight:(\w+)/)?[1] or 'filter-highlight' - # Put highlighted OP's thread on top of the board page or not. - # Defaults to on top. - top = filter.match(/top:(yes|no)/)?[1] or 'yes' - top = top is 'yes' # Turn it into a boolean - - @filters[key].push @createFilter regexp, op, stub, hl, top - - # Only execute filter types that contain valid filters. - unless @filters[key].length - delete @filters[key] - - return unless Object.keys(@filters).length - Post::callbacks.push - name: 'Filter' - cb: @node - - createFilter: (regexp, op, stub, hl, top) -> - test = - if typeof regexp is 'string' - # MD5 checking - (value) -> regexp is value - else - (value) -> regexp.test value - settings = - hide: !hl - stub: stub - class: hl - top: top - (value, isReply) -> - if isReply and op is 'only' or !isReply and op is 'no' - return false - unless test value - return false - settings - - node: -> - return if @isClone - for key of Filter.filters - value = Filter[key] @ - # Continue if there's nothing to filter (no tripcode for example). - continue if value is false - - for filter in Filter.filters[key] - unless result = filter value, @isReply - continue - - # Hide - if result.hide - if @isReply - PostHiding.hide @, result.stub - else if g.VIEW is 'index' - ThreadHiding.hide @thread, result.stub - else - continue - return - - # Highlight - $.addClass @nodes.root, result.class - if !@isReply and result.top and g.VIEW is 'index' - # Put the highlighted OPs' thread on top of the board page... - thisThread = @nodes.root.parentNode - # ...before the first non highlighted thread. - if firstThread = $ 'div[class="postContainer opContainer"]' - unless firstThread is @nodes.root - $.before firstThread.parentNode, [thisThread, thisThread.nextElementSibling] - - name: (post) -> - if 'name' of post.info - return post.info.name - false - uniqueID: (post) -> - if 'uniqueID' of post.info - return post.info.uniqueID - false - tripcode: (post) -> - if 'tripcode' of post.info - return post.info.tripcode - false - capcode: (post) -> - if 'capcode' of post.info - return post.info.capcode - false - email: (post) -> - if 'email' of post.info - return post.info.email - false - subject: (post) -> - if 'subject' of post.info - return post.info.subject or false - false - comment: (post) -> - if 'comment' of post.info - return post.info.comment - false - flag: (post) -> - if 'flag' of post.info - return post.info.flag - false - filename: (post) -> - if post.file - return post.file.name - false - dimensions: (post) -> - if post.file and post.file.isImage - return post.file.dimensions - false - filesize: (post) -> - if post.file - return post.file.size - false - MD5: (post) -> - if post.file - return post.file.MD5 - false - - menu: - init: -> - return if g.VIEW is 'catalog' or !Conf['Menu'] or !Conf['Filter'] - - div = $.el 'div', - textContent: 'Filter' - - entry = - type: 'post' - el: div - order: 50 - open: (post) -> - Filter.menu.post = post - true - subEntries: [] - - for type in [ - ['Name', 'name'] - ['Unique ID', 'uniqueID'] - ['Tripcode', 'tripcode'] - ['Capcode', 'capcode'] - ['E-mail', 'email'] - ['Subject', 'subject'] - ['Comment', 'comment'] - ['Flag', 'flag'] - ['Filename', 'filename'] - ['Image dimensions', 'dimensions'] - ['Filesize', 'filesize'] - ['Image MD5', 'MD5'] - ] - # Add a sub entry for each filter type. - entry.subEntries.push Filter.menu.createSubEntry type[0], type[1] - - $.event 'AddMenuEntry', entry - - createSubEntry: (text, type) -> - el = $.el 'a', - href: 'javascript:;' - textContent: text - el.setAttribute 'data-type', type - $.on el, 'click', Filter.menu.makeFilter - - return { - el: el - open: (post) -> - value = Filter[type] post - value isnt false - } - - makeFilter: -> - {type} = @dataset - # Convert value -> regexp, unless type is MD5 - value = Filter[type] Filter.menu.post - re = if type in ['uniqueID', 'MD5'] then value else value.replace /// - / - | \\ - | \^ - | \$ - | \n - | \. - | \( - | \) - | \{ - | \} - | \[ - | \] - | \? - | \* - | \+ - | \| - ///g, (c) -> - if c is '\n' - '\\n' - else if c is '\\' - '\\\\' - else - "\\#{c}" - - re = if type in ['uniqueID', 'MD5'] - "/#{re}/" - else - "/^#{re}$/" - - # Add a new line before the regexp unless the text is empty. - $.get type, Conf[type], (item) -> - save = item[type] - save = - if save - "#{save}\n#{re}" - else - re - $.set type, save - - # Open the settings and display & focus the relevant filter textarea. - Settings.open 'Filter' - section = $ '.section-container' - select = $ 'select[name=filter]', section - select.value = type - Settings.selectFilter.call select - ta = $ 'textarea', section - tl = ta.textLength - ta.setSelectionRange tl, tl - ta.focus() - -ThreadHiding = - init: -> - return if g.VIEW isnt 'index' or !Conf['Thread Hiding'] and !Conf['Thread Hiding Link'] - - @db = new DataBoard 'hiddenThreads' - @syncCatalog() - Thread::callbacks.push - name: 'Thread Hiding' - cb: @node - - node: -> - if data = ThreadHiding.db.get {boardID: @board.ID, threadID: @ID} - ThreadHiding.hide @, data.makeStub - return unless Conf['Thread Hiding'] - $.prepend @OP.nodes.root, ThreadHiding.makeButton @, 'hide' - - syncCatalog: -> - # Sync hidden threads from the catalog into the index. - hiddenThreads = ThreadHiding.db.get - boardID: g.BOARD.ID - defaultValue: {} - # XXX tmp fix - try - hiddenThreadsOnCatalog = JSON.parse(localStorage.getItem "4chan-hide-t-#{g.BOARD}") or {} - catch e - localStorage.setItem "4chan-hide-t-#{g.BOARD}", JSON.stringify {} - return ThreadHiding.syncCatalog() - - # Add threads that were hidden in the catalog. - for threadID of hiddenThreadsOnCatalog - unless threadID of hiddenThreads - hiddenThreads[threadID] = {} - - # Remove threads that were un-hidden in the catalog. - for threadID of hiddenThreads - unless threadID of hiddenThreadsOnCatalog - delete hiddenThreads[threadID] - - if (ThreadHiding.db.data.lastChecked or 0) > Date.now() - $.MINUTE - # Was cleaned just now. - ThreadHiding.cleanCatalog hiddenThreadsOnCatalog - - ThreadHiding.db.set - boardID: g.BOARD.ID - val: hiddenThreads - - cleanCatalog: (hiddenThreadsOnCatalog) -> - # We need to clean hidden threads on the catalog ourselves, - # otherwise if we don't visit the catalog regularly - # it will pollute the localStorage and our data. - $.cache "//api.4chan.org/#{g.BOARD}/threads.json", -> - return unless @status is 200 - threads = {} - for page in JSON.parse @response - for thread in page.threads - if thread.no of hiddenThreadsOnCatalog - threads[thread.no] = hiddenThreadsOnCatalog[thread.no] - if Object.keys(threads).length - localStorage.setItem "4chan-hide-t-#{g.BOARD}", JSON.stringify threads - else - localStorage.removeItem "4chan-hide-t-#{g.BOARD}" - - menu: - init: -> - return if g.VIEW isnt 'index' or !Conf['Menu'] or !Conf['Thread Hiding Link'] - - div = $.el 'div', - className: 'hide-thread-link' - textContent: 'Hide thread' - - apply = $.el 'a', - textContent: 'Apply' - href: 'javascript:;' - $.on apply, 'click', ThreadHiding.menu.hide - - makeStub = $.el 'label', - innerHTML: " Make stub" - - $.event 'AddMenuEntry', - type: 'post' - el: div - order: 20 - open: ({thread, isReply}) -> - if isReply or thread.isHidden - return false - ThreadHiding.menu.thread = thread - true - subEntries: [el: apply; el: makeStub] - hide: -> - makeStub = $('input', @parentNode).checked - {thread} = ThreadHiding.menu - ThreadHiding.hide thread, makeStub - ThreadHiding.saveHiddenState thread, makeStub - $.event 'CloseMenu' - - makeButton: (thread, type) -> - a = $.el 'a', - className: "#{type}-thread-button" - innerHTML: "[ #{if type is 'hide' then '-' else '+'} ]" - href: 'javascript:;' - a.setAttribute 'data-fullid', thread.fullID - $.on a, 'click', ThreadHiding.toggle - a - - saveHiddenState: (thread, makeStub) -> - hiddenThreadsOnCatalog = JSON.parse(localStorage.getItem "4chan-hide-t-#{g.BOARD}") or {} - if thread.isHidden - ThreadHiding.db.set - boardID: thread.board.ID - threadID: thread.ID - val: {makeStub} - hiddenThreadsOnCatalog[thread] = true - else - ThreadHiding.db.delete - boardID: thread.board.ID - threadID: thread.ID - delete hiddenThreadsOnCatalog[thread] - localStorage.setItem "4chan-hide-t-#{g.BOARD}", JSON.stringify hiddenThreadsOnCatalog - - toggle: (thread) -> - unless thread instanceof Thread - thread = g.threads[@dataset.fullid] - if thread.isHidden - ThreadHiding.show thread - else - ThreadHiding.hide thread - ThreadHiding.saveHiddenState thread - - hide: (thread, makeStub=Conf['Stubs']) -> - return if thread.isHidden - {OP} = thread - threadRoot = OP.nodes.root.parentNode - threadRoot.hidden = thread.isHidden = true - - unless makeStub - threadRoot.nextElementSibling.hidden = true #
- return - - numReplies = 0 - if span = $ '.summary', threadRoot - numReplies = +span.textContent.match /\d+/ - numReplies += $$('.opContainer ~ .replyContainer', threadRoot).length - numReplies = if numReplies is 1 then '1 reply' else "#{numReplies} replies" - opInfo = - if Conf['Anonymize'] - 'Anonymous' - else - $('.nameBlock', OP.nodes.info).textContent - - a = ThreadHiding.makeButton thread, 'show' - $.add a, $.tn " #{opInfo} (#{numReplies})" - thread.stub = $.el 'div', - className: 'stub' - $.add thread.stub, a - if Conf['Menu'] - $.add thread.stub, [$.tn(' '), Menu.makeButton OP] - $.before threadRoot, thread.stub - - show: (thread) -> - if thread.stub - $.rm thread.stub - delete thread.stub - threadRoot = thread.OP.nodes.root.parentNode - threadRoot.nextElementSibling.hidden = - threadRoot.hidden = thread.isHidden = false - -PostHiding = - init: -> - return if g.VIEW is 'catalog' or !Conf['Reply Hiding'] and !Conf['Reply Hiding Link'] - - @db = new DataBoard 'hiddenPosts' - Post::callbacks.push - name: 'Reply Hiding' - cb: @node - - node: -> - return if !@isReply or @isClone - if data = PostHiding.db.get {boardID: @board.ID, threadID: @thread.ID, postID: @ID} - if data.thisPost - PostHiding.hide @, data.makeStub, data.hideRecursively - else - Recursive.apply PostHiding.hide, @, data.makeStub, true - Recursive.add PostHiding.hide, @, data.makeStub, true - return unless Conf['Reply Hiding'] - $.replace $('.sideArrows', @nodes.root), PostHiding.makeButton @, 'hide' - - menu: - init: -> - return if g.VIEW is 'catalog' or !Conf['Menu'] or !Conf['Reply Hiding Link'] - - # Hide - div = $.el 'div', - className: 'hide-reply-link' - textContent: 'Hide reply' - - apply = $.el 'a', - textContent: 'Apply' - href: 'javascript:;' - $.on apply, 'click', PostHiding.menu.hide - - thisPost = $.el 'label', - innerHTML: ' This post' - replies = $.el 'label', - innerHTML: " Hide replies" - makeStub = $.el 'label', - innerHTML: " Make stub" - - $.event 'AddMenuEntry', - type: 'post' - el: div - order: 20 - open: (post) -> - if !post.isReply or post.isClone or post.isHidden - return false - PostHiding.menu.post = post - true - subEntries: [{el: apply}, {el: thisPost}, {el: replies}, {el: makeStub}] - - # Show - div = $.el 'div', - className: 'show-reply-link' - textContent: 'Show reply' - - apply = $.el 'a', - textContent: 'Apply' - href: 'javascript:;' - $.on apply, 'click', PostHiding.menu.show - - thisPost = $.el 'label', - innerHTML: ' This post' - replies = $.el 'label', - innerHTML: " Show replies" - - $.event 'AddMenuEntry', - type: 'post' - el: div - order: 20 - open: (post) -> - if !post.isReply or post.isClone or !post.isHidden - return false - unless data = PostHiding.db.get {boardID: post.board.ID, threadID: post.thread.ID, postID: post.ID} - return false - PostHiding.menu.post = post - thisPost.firstChild.checked = post.isHidden - replies.firstChild.checked = if data?.hideRecursively? then data.hideRecursively else Conf['Recursive Hiding'] - true - subEntries: [{el: apply}, {el: thisPost}, {el: replies}] - hide: -> - parent = @parentNode - thisPost = $('input[name=thisPost]', parent).checked - replies = $('input[name=replies]', parent).checked - makeStub = $('input[name=makeStub]', parent).checked - {post} = PostHiding.menu - if thisPost - PostHiding.hide post, makeStub, replies - else if replies - Recursive.apply PostHiding.hide, post, makeStub, true - Recursive.add PostHiding.hide, post, makeStub, true - else - return - PostHiding.saveHiddenState post, true, thisPost, makeStub, replies - $.event 'CloseMenu' - show: -> - parent = @parentNode - thisPost = $('input[name=thisPost]', parent).checked - replies = $('input[name=replies]', parent).checked - {post} = PostHiding.menu - if thisPost - PostHiding.show post, replies - else if replies - Recursive.apply PostHiding.show, post, true - Recursive.rm PostHiding.hide, post, true - else - return - if data = PostHiding.db.get {boardID: post.board.ID, threadID: post.thread.ID, postID: post.ID} - PostHiding.saveHiddenState post, !(thisPost and replies), !thisPost, data.makeStub, !replies - $.event 'CloseMenu' - - makeButton: (post, type) -> - a = $.el 'a', - className: "#{type}-reply-button" - innerHTML: "[ #{if type is 'hide' then '-' else '+'} ]" - href: 'javascript:;' - $.on a, 'click', PostHiding.toggle - a - - saveHiddenState: (post, isHiding, thisPost, makeStub, hideRecursively) -> - data = - boardID: post.board.ID - threadID: post.thread.ID - postID: post.ID - if isHiding - data.val = - thisPost: thisPost isnt false # undefined -> true - makeStub: makeStub - hideRecursively: hideRecursively - PostHiding.db.set data - else - PostHiding.db.delete data - - toggle: -> - post = Get.postFromNode @ - if post.isHidden - PostHiding.show post - else - PostHiding.hide post - PostHiding.saveHiddenState post, post.isHidden - - hide: (post, makeStub=Conf['Stubs'], hideRecursively=Conf['Recursive Hiding']) -> - return if post.isHidden - post.isHidden = true - - if hideRecursively - Recursive.apply PostHiding.hide, post, makeStub, true - Recursive.add PostHiding.hide, post, makeStub, true - - for quotelink in Get.allQuotelinksLinkingTo post - $.addClass quotelink, 'filtered' - - unless makeStub - post.nodes.root.hidden = true - return - - a = PostHiding.makeButton post, 'show' - postInfo = - if Conf['Anonymize'] - 'Anonymous' - else - $('.nameBlock', post.nodes.info).textContent - $.add a, $.tn " #{postInfo}" - post.nodes.stub = $.el 'div', - className: 'stub' - $.add post.nodes.stub, a - if Conf['Menu'] - $.add post.nodes.stub, [$.tn(' '), Menu.makeButton post] - $.prepend post.nodes.root, post.nodes.stub - - show: (post, showRecursively=Conf['Recursive Hiding']) -> - if post.nodes.stub - $.rm post.nodes.stub - delete post.nodes.stub - else - post.nodes.root.hidden = false - post.isHidden = false - if showRecursively - Recursive.apply PostHiding.show, post, true - Recursive.rm PostHiding.hide, post - for quotelink in Get.allQuotelinksLinkingTo post - $.rmClass quotelink, 'filtered' - return - -Recursive = - recursives: {} - init: -> - return if g.VIEW is 'catalog' - - Post::callbacks.push - name: 'Recursive' - cb: @node - - node: -> - return if @isClone - for quote in @quotes - if obj = Recursive.recursives[quote] - for recursive, i in obj.recursives - recursive @, obj.args[i]... - return - - add: (recursive, post, args...) -> - obj = Recursive.recursives[post.fullID] or= - recursives: [] - args: [] - obj.recursives.push recursive - obj.args.push args - - rm: (recursive, post) -> - return unless obj = Recursive.recursives[post.fullID] - for rec, i in obj.recursives - if rec is recursive - obj.recursives.splice i, 1 - obj.args.splice i, 1 - return - - apply: (recursive, post, args...) -> - {fullID} = post - for ID, post of g.posts - if fullID in post.quotes - recursive post, args... - return - -QuoteStrikeThrough = - init: -> - return if g.VIEW is 'catalog' or !Conf['Reply Hiding'] and !Conf['Reply Hiding Link'] and !Conf['Filter'] - - Post::callbacks.push - name: 'Strike-through Quotes' - cb: @node - - node: -> - return if @isClone - for quotelink in @nodes.quotelinks - {boardID, postID} = Get.postDataFromLink quotelink - if g.posts["#{boardID}.#{postID}"]?.isHidden - $.addClass quotelink, 'filtered' - return - -Menu = - init: -> - return if g.VIEW is 'catalog' or !Conf['Menu'] - - @menu = new UI.Menu 'post' - Post::callbacks.push - name: 'Menu' - cb: @node - - node: -> - button = Menu.makeButton @ - if @isClone - $.replace $('.menu-button', @nodes.info), button - return - $.add @nodes.info, [$.tn('\u00A0'), button] - - makeButton: do -> - a = null - (post) -> - a or= $.el 'a', - className: 'menu-button' - innerHTML: '[]' - href: 'javascript:;' - clone = a.cloneNode true - clone.setAttribute 'data-postid', post.fullID - clone.setAttribute 'data-clone', true if post.isClone - $.on clone, 'click', Menu.toggle - clone - - toggle: (e) -> - post = - if @dataset.clone - Get.postFromNode @ - else - g.posts[@dataset.postid] - Menu.menu.toggle e, @, post - -ReportLink = - init: -> - return if g.VIEW is 'catalog' or !Conf['Menu'] or !Conf['Report Link'] - - a = $.el 'a', - className: 'report-link' - href: 'javascript:;' - textContent: 'Report this post' - $.on a, 'click', ReportLink.report - $.event 'AddMenuEntry', - type: 'post' - el: a - order: 10 - open: (post) -> - ReportLink.post = post - !post.isDead - report: -> - {post} = ReportLink - url = "//sys.4chan.org/#{post.board}/imgboard.php?mode=report&no=#{post}" - id = Date.now() - set = "toolbar=0,scrollbars=0,location=0,status=1,menubar=0,resizable=1,width=685,height=200" - window.open url, id, set - -DeleteLink = - init: -> - return if g.VIEW is 'catalog' or !Conf['Menu'] or !Conf['Delete Link'] - - div = $.el 'div', - className: 'delete-link' - textContent: 'Delete' - postEl = $.el 'a', - className: 'delete-post' - href: 'javascript:;' - fileEl = $.el 'a', - className: 'delete-file' - href: 'javascript:;' - - postEntry = - el: postEl - open: -> - postEl.textContent = 'Post' - $.on postEl, 'click', DeleteLink.delete - true - fileEntry = - el: fileEl - open: ({file}) -> - return false if !file or file.isDead - fileEl.textContent = 'File' - $.on fileEl, 'click', DeleteLink.delete - true - - $.event 'AddMenuEntry', - type: 'post' - el: div - order: 40 - open: (post) -> - return false if post.isDead - DeleteLink.post = post - node = div.firstChild - node.textContent = 'Delete' - DeleteLink.cooldown.start post, node - true - subEntries: [postEntry, fileEntry] - - delete: -> - {post} = DeleteLink - return if DeleteLink.cooldown.counting is post - - $.off @, 'click', DeleteLink.delete - @textContent = "Deleting #{@textContent}..." - - pwd = - if m = d.cookie.match /4chan_pass=([^;]+)/ - decodeURIComponent m[1] - else - $.id('delPassword').value - - fileOnly = $.hasClass @, 'delete-file' - - form = - mode: 'usrdel' - onlyimgdel: fileOnly - pwd: pwd - form[post.ID] = 'delete' - - link = @ - $.ajax $.id('delform').action.replace("/#{g.BOARD}/", "/#{post.board}/"), - onload: -> DeleteLink.load link, post, fileOnly, @response - onerror: -> DeleteLink.error link - , - cred: true - form: $.formData form - load: (link, post, fileOnly, html) -> - tmpDoc = d.implementation.createHTMLDocument '' - tmpDoc.documentElement.innerHTML = html - if tmpDoc.title is '4chan - Banned' # Ban/warn check - s = 'Banned!' - else if msg = tmpDoc.getElementById 'errmsg' # error! - s = msg.textContent - $.on link, 'click', DeleteLink.delete - else - if tmpDoc.title is 'Updating index...' - # We're 100% sure. - (post.origin or post).kill fileOnly - s = 'Deleted' - link.textContent = s - error: (link) -> - link.textContent = 'Connection error, please retry.' - $.on link, 'click', DeleteLink.delete - - cooldown: - start: (post, node) -> - unless QR.db?.get {boardID: post.board.ID, threadID: post.thread.ID, postID: post.ID} - # Only start counting on our posts. - delete DeleteLink.cooldown.counting - return - DeleteLink.cooldown.counting = post - length = if post.board.ID is 'q' - 600 - else - 30 - seconds = Math.ceil (length * $.SECOND - (Date.now() - post.info.date)) / $.SECOND - DeleteLink.cooldown.count post, seconds, length, node - count: (post, seconds, length, node) -> - return if DeleteLink.cooldown.counting isnt post - unless 0 <= seconds <= length - if DeleteLink.cooldown.counting is post - node.textContent = 'Delete' - delete DeleteLink.cooldown.counting - return - setTimeout DeleteLink.cooldown.count, 1000, post, seconds - 1, length, node - node.textContent = "Delete (#{seconds})" - -DownloadLink = - init: -> - return if g.VIEW is 'catalog' or !Conf['Menu'] or !Conf['Download Link'] - - a = $.el 'a', - className: 'download-link' - textContent: 'Download file' - $.event 'AddMenuEntry', - type: 'post' - el: a - order: 70 - open: ({file}) -> - return false unless file - a.href = file.URL - a.download = file.name - true - -ArchiveLink = - init: -> - return if g.VIEW is 'catalog' or !Conf['Menu'] or !Conf['Archive Link'] - - div = $.el 'div', - textContent: 'Archive' - - entry = - type: 'post' - el: div - order: 90 - open: ({ID, thread, board}) -> - redirect = Redirect.to {postID: ID, threadID: thread.ID, boardID: board.ID} - redirect isnt "//boards.4chan.org/#{board}/" - subEntries: [] - - for type in [ - ['Post', 'post'] - ['Name', 'name'] - ['Tripcode', 'tripcode'] - ['E-mail', 'email'] - ['Subject', 'subject'] - ['Filename', 'filename'] - ['Image MD5', 'MD5'] - ] - # Add a sub entry for each type. - entry.subEntries.push @createSubEntry type[0], type[1] - - $.event 'AddMenuEntry', entry - - createSubEntry: (text, type) -> - el = $.el 'a', - textContent: text - target: '_blank' - - open = if type is 'post' - ({ID, thread, board}) -> - el.href = Redirect.to {postID: ID, threadID: thread.ID, boardID: board.ID} - true - else - (post) -> - value = Filter[type] post - # We want to parse the exact same stuff as the filter does already. - return false unless value - el.href = Redirect.to - boardID: post.board.ID - type: type - value: value - isSearch: true - true - - return { - el: el - open: open - } - -Keybinds = - init: -> - return if g.VIEW is 'catalog' or !Conf['Keybinds'] - - init = -> - $.off d, '4chanXInitFinished', init - $.on d, 'keydown', Keybinds.keydown - for node in $$ '[accesskey]' - node.removeAttribute 'accesskey' - return - $.on d, '4chanXInitFinished', init - - keydown: (e) -> - return unless key = Keybinds.keyCode e - {target} = e - if target.nodeName in ['INPUT', 'TEXTAREA'] - return unless /(Esc|Alt|Ctrl|Meta)/.test key - - threadRoot = Nav.getThread() - if op = $ '.op', threadRoot - thread = Get.postFromNode(op).thread - switch key - # QR & Options - when Conf['Toggle board list'] - if Conf['Custom Board Navigation'] - Header.toggleBoardList() - when Conf['Open empty QR'] - Keybinds.qr threadRoot - when Conf['Open QR'] - Keybinds.qr threadRoot, true - when Conf['Open settings'] - Settings.open() - when Conf['Close'] - if Settings.dialog - Settings.close() - else if (notifications = $$ '.notification').length - for notification in notifications - $('.close', notification).click() - else if QR.nodes - QR.close() - when Conf['Spoiler tags'] - return if target.nodeName isnt 'TEXTAREA' - Keybinds.tags 'spoiler', target - when Conf['Code tags'] - return if target.nodeName isnt 'TEXTAREA' - Keybinds.tags 'code', target - when Conf['Eqn tags'] - return if target.nodeName isnt 'TEXTAREA' - Keybinds.tags 'eqn', target - when Conf['Math tags'] - return if target.nodeName isnt 'TEXTAREA' - Keybinds.tags 'math', target - when Conf['Submit QR'] - QR.submit() if QR.nodes and !QR.status() - # Thread related - when Conf['Watch'] - ThreadWatcher.toggle thread - when Conf['Update'] - ThreadUpdater.update() - # Images - when Conf['Expand image'] - Keybinds.img threadRoot - when Conf['Expand images'] - Keybinds.img threadRoot, true - # Board Navigation - when Conf['Front page'] - window.location = "/#{g.BOARD}/0#delform" - when Conf['Open front page'] - $.open "/#{g.BOARD}/#delform" - when Conf['Next page'] - if form = $ '.next form' - window.location = form.action - when Conf['Previous page'] - if form = $ '.prev form' - window.location = form.action - # Thread Navigation - when Conf['Next thread'] - return if g.VIEW is 'thread' - Nav.scroll +1 - when Conf['Previous thread'] - return if g.VIEW is 'thread' - Nav.scroll -1 - when Conf['Expand thread'] - ExpandThread.toggle thread - when Conf['Open thread'] - Keybinds.open thread - when Conf['Open thread tab'] - Keybinds.open thread, true - # Reply Navigation - when Conf['Next reply'] - Keybinds.hl +1, threadRoot - when Conf['Previous reply'] - Keybinds.hl -1, threadRoot - when Conf['Hide'] - ThreadHiding.toggle thread if g.VIEW is 'index' - else - return - e.preventDefault() - e.stopPropagation() - - keyCode: (e) -> - key = switch kc = e.keyCode - when 8 # return - '' - when 13 - 'Enter' - when 27 - 'Esc' - when 37 - 'Left' - when 38 - 'Up' - when 39 - 'Right' - when 40 - 'Down' - else - if 48 <= kc <= 57 or 65 <= kc <= 90 # 0-9, A-Z - String.fromCharCode(kc).toLowerCase() - else - null - if key - if e.altKey then key = 'Alt+' + key - if e.ctrlKey then key = 'Ctrl+' + key - if e.metaKey then key = 'Meta+' + key - if e.shiftKey then key = 'Shift+' + key - key - - qr: (thread, quote) -> - return unless Conf['Quick Reply'] and QR.postingIsEnabled - QR.open() - if quote - QR.quote.call $ 'input', $('.post.highlight', thread) or thread - QR.nodes.com.focus() - - tags: (tag, ta) -> - value = ta.value - selStart = ta.selectionStart - selEnd = ta.selectionEnd - - ta.value = - value[...selStart] + - "[#{tag}]" + value[selStart...selEnd] + "[/#{tag}]" + - value[selEnd..] - - # Move the caret to the end of the selection. - range = "[#{tag}]".length + selEnd - ta.setSelectionRange range, range - - # Fire the 'input' event - $.event 'input', null, ta - - img: (thread, all) -> - if all - ImageExpand.cb.toggleAll() - else - post = Get.postFromNode $('.post.highlight', thread) or $ '.op', thread - ImageExpand.toggle post - - open: (thread, tab) -> - return if g.VIEW isnt 'index' - url = "/#{thread.board}/res/#{thread}" - if tab - $.open url - else - location.href = url - - hl: (delta, thread) -> - if Conf['Bottom header'] - topMargin = 0 - else - headRect = Header.toggle.getBoundingClientRect() - topMargin = headRect.top + headRect.height - if postEl = $ '.reply.highlight', thread - $.rmClass postEl, 'highlight' - rect = postEl.getBoundingClientRect() - if rect.bottom >= topMargin and rect.top <= doc.clientHeight # We're at least partially visible - root = postEl.parentNode - next = $.x 'child::div[contains(@class,"post reply")]', - if delta is +1 then root.nextElementSibling else root.previousElementSibling - unless next - @focus postEl - return - return unless g.VIEW is 'thread' or $.x('ancestor::div[parent::div[@class="board"]]', next) is thread - rect = next.getBoundingClientRect() - if rect.top < 0 or rect.bottom > doc.clientHeight - if delta is -1 - window.scrollBy 0, rect.top - topMargin - else - next.scrollIntoView false - @focus next - return - - replies = $$ '.reply', thread - replies.reverse() if delta is -1 - for reply in replies - rect = reply.getBoundingClientRect() - if delta is +1 and rect.top >= topMargin or delta is -1 and rect.bottom <= doc.clientHeight - @focus reply - return - - focus: (post) -> - $.addClass post, 'highlight' - -Nav = - init: -> - switch g.VIEW - when 'index' - return unless Conf['Index Navigation'] - when 'thread' - return unless Conf['Reply Navigation'] - else # catalog - return - - span = $.el 'span', - id: 'navlinks' - prev = $.el 'a', - textContent: '▲' - href: 'javascript:;' - next = $.el 'a', - textContent: '▼' - href: 'javascript:;' - - $.on prev, 'click', @prev - $.on next, 'click', @next - - $.add span, [prev, $.tn(' '), next] - append = -> - $.off d, '4chanXInitFinished', append - $.add d.body, span - $.on d, '4chanXInitFinished', append - - prev: -> - if g.VIEW is 'thread' - window.scrollTo 0, 0 - else - Nav.scroll -1 - - next: -> - if g.VIEW is 'thread' - window.scrollTo 0, d.body.scrollHeight - else - Nav.scroll +1 - - getThread: (full) -> - if Conf['Bottom header'] - topMargin = 0 - else - headRect = Header.toggle.getBoundingClientRect() - topMargin = headRect.top + headRect.height - threads = $$ '.thread:not([hidden])' - for thread, i in threads - rect = thread.getBoundingClientRect() - if rect.bottom > topMargin # not scrolled past - return if full then [threads, thread, i, rect, topMargin] else thread - return $ '.board' - - scroll: (delta) -> - [threads, thread, i, rect, topMargin] = Nav.getThread true - top = rect.top - topMargin - - # unless we're not at the beginning of the current thread - # (and thus wanting to move to beginning) - # or we're above the first thread and don't want to skip it - unless (delta is -1 and Math.ceil(top) < 0) or (delta is +1 and top > 1) - i += delta - - top = threads[i]?.getBoundingClientRect().top - topMargin - window.scrollBy 0, top - -Redirect = - image: (boardID, filename) -> - # Do not use g.BOARD, the image url can originate from a cross-quote. - switch boardID - when 'a', 'gd', 'jp', 'm', 'q', 'tg', 'vg', 'vp', 'vr', 'wsg' - "//archive.foolz.us/#{boardID}/full_image/#{filename}" - when 'u' - "//nsfw.foolz.us/#{boardID}/full_image/#{filename}" - when 'po' - "//archive.thedarkcave.org/#{boardID}/full_image/#{filename}" - when 'hr', 'tv' - "http://archive.4plebs.org/#{boardID}/full_image/#{filename}" - when 'ck', 'fa', 'lit', 's4s' - "//fuuka.warosu.org/#{boardID}/full_image/#{filename}" - when 'cgl', 'g', 'mu', 'w' - "//rbt.asia/#{boardID}/full_image/#{filename}" - when 'an', 'k', 'toy', 'x' - "http://archive.heinessen.com/#{boardID}/full_image/#{filename}" - when 'c' - "//archive.nyafuu.org/#{boardID}/full_image/#{filename}" - post: (boardID, postID) -> - # XXX foolz had HSTS set for 120 days, which broke XHR+CORS+Redirection when on HTTP. - # Remove necessary HTTPS procotol in September 2013. - switch boardID - when 'a', 'co', 'gd', 'jp', 'm', 'q', 'sp', 'tg', 'tv', 'v', 'vg', 'vp', 'vr', 'wsg' - "https://archive.foolz.us/_/api/chan/post/?board=#{boardID}&num=#{postID}" - when 'u' - "https://nsfw.foolz.us/_/api/chan/post/?board=#{boardID}&num=#{postID}" - when 'c', 'int', 'out', 'po' - "//archive.thedarkcave.org/_/api/chan/post/?board=#{boardID}&num=#{postID}" - when 'hr', 'x' - "http://archive.4plebs.org/_/api/chan/post/?board=#{boardID}&num=#{postID}" - # for fuuka-based archives: - # https://github.com/eksopl/fuuka/issues/27 - to: (data) -> - {boardID} = data - switch boardID - when 'a', 'co', 'gd', 'jp', 'm', 'q', 'sp', 'tg', 'tv', 'v', 'vg', 'vp', 'vr', 'wsg' - Redirect.path '//archive.foolz.us', 'foolfuuka', data - when 'u' - Redirect.path '//nsfw.foolz.us', 'foolfuuka', data - when 'int', 'out', 'po' - Redirect.path '//archive.thedarkcave.org', 'foolfuuka', data - when 'hr' - Redirect.path 'http://archive.4plebs.org', 'foolfuuka', data - when 'ck', 'fa', 'lit', 's4s' - Redirect.path '//fuuka.warosu.org', 'fuuka', data - when 'diy', 'g', 'sci' - Redirect.path '//archive.installgentoo.net', 'fuuka', data - when 'cgl', 'mu', 'w' - Redirect.path '//rbt.asia', 'fuuka', data - when 'an', 'fit', 'k', 'mlp', 'r9k', 'toy', 'x' - Redirect.path 'http://archive.heinessen.com', 'fuuka', data - when 'c' - Redirect.path '//archive.nyafuu.org', 'fuuka', data - else - if data.threadID then "//boards.4chan.org/#{boardID}/" else '' - path: (base, archiver, data) -> - if data.isSearch - {boardID, type, value} = data - type = if type is 'name' - 'username' - else if type is 'MD5' - 'image' - else - type - value = encodeURIComponent value - return if archiver is 'foolfuuka' - "#{base}/#{boardID}/search/#{type}/#{value}" - else if type is 'image' - "#{base}/#{boardID}/?task=search2&search_media_hash=#{value}" - else - "#{base}/#{boardID}/?task=search2&search_#{type}=#{value}" - - {boardID, threadID, postID} = data - # keep the number only if the location.hash was sent f.e. - path = if threadID - "#{boardID}/thread/#{threadID}" - else - "#{boardID}/post/#{postID}" - if archiver is 'foolfuuka' - path += '/' - if threadID and postID - path += if archiver is 'foolfuuka' - "##{postID}" - else - "#p#{postID}" - "#{base}/#{path}" - -Build = - spoilerRange: {} - shortFilename: (filename, isReply) -> - # FILENAME SHORTENING SCIENCE: - # OPs have a +10 characters threshold. - # The file extension is not taken into account. - threshold = if isReply then 30 else 40 - if filename.length - 4 > threshold - "#{filename[...threshold - 5]}(...).#{filename[-3..]}" - else - filename - postFromObject: (data, boardID) -> - o = - # id - postID: data.no - threadID: data.resto or data.no - boardID: boardID - # info - name: data.name - capcode: data.capcode - tripcode: data.trip - uniqueID: data.id - email: if data.email then encodeURI data.email.replace /"/g, '"' else '' - subject: data.sub - flagCode: data.country - flagName: data.country_name - date: data.now - dateUTC: data.time - comment: data.com - # thread status - isSticky: !!data.sticky - isClosed: !!data.closed - # file - if data.ext or data.filedeleted - o.file = - name: data.filename + data.ext - timestamp: "#{data.tim}#{data.ext}" - url: "//images.4chan.org/#{boardID}/src/#{data.tim}#{data.ext}" - height: data.h - width: data.w - MD5: data.md5 - size: data.fsize - turl: "//thumbs.4chan.org/#{boardID}/thumb/#{data.tim}s.jpg" - theight: data.tn_h - twidth: data.tn_w - isSpoiler: !!data.spoiler - isDeleted: !!data.filedeleted - Build.post o - post: (o, isArchived) -> - ### - This function contains code from 4chan-JS (https://github.com/4chan/4chan-JS). - @license: https://github.com/4chan/4chan-JS/blob/master/LICENSE - ### - { - postID, threadID, boardID - name, capcode, tripcode, uniqueID, email, subject, flagCode, flagName, date, dateUTC - isSticky, isClosed - comment - file - } = o - isOP = postID is threadID - - staticPath = '//static.4chan.org' - - if email - emailStart = '' - emailEnd = '' - else - emailStart = '' - emailEnd = '' - - subject = "#{subject or ''}" - - userID = - if !capcode and uniqueID - " (ID: " + - "#{uniqueID}) " - else - '' - - switch capcode - when 'admin', 'admin_highlight' - capcodeClass = " capcodeAdmin" - capcodeStart = " ## Admin" - capcode = " " - when 'mod' - capcodeClass = " capcodeMod" - capcodeStart = " ## Mod" - capcode = " " - when 'developer' - capcodeClass = " capcodeDeveloper" - capcodeStart = " ## Developer" - capcode = " " - else - capcodeClass = '' - capcodeStart = '' - capcode = '' - - flag = - if flagCode - " #{flagCode}" - else - '' - - if file?.isDeleted - fileHTML = - if isOP - "
" + - "File deleted." + - "
" - else - "
" + - "File deleted." + - "
" - else if file - ext = file.name[-3..] - if !file.twidth and !file.theight and ext is 'gif' # wtf ? - file.twidth = file.width - file.theight = file.height - - fileSize = $.bytesToString file.size - - fileThumb = file.turl - if file.isSpoiler - fileSize = "Spoiler Image, #{fileSize}" - unless isArchived - fileThumb = '//static.4chan.org/image/spoiler' - if spoilerRange = Build.spoilerRange[boardID] - # Randomize the spoiler image. - fileThumb += "-#{boardID}" + Math.floor 1 + spoilerRange * Math.random() - fileThumb += '.png' - file.twidth = file.theight = 100 - - if boardID.ID isnt 'f' - imgSrc = "" + - "#{fileSize}" - - # Ha ha, filenames! - # html -> text, translate WebKit's %22s into "s - a = $.el 'a', innerHTML: file.name - filename = a.textContent.replace /%22/g, '"' - - # shorten filename, get html - a.textContent = Build.shortFilename filename - shortFilename = a.innerHTML - - # get html - a.textContent = filename - filename = a.innerHTML.replace /'/g, ''' - - fileDims = if ext is 'pdf' then 'PDF' else "#{file.width}x#{file.height}" - fileInfo = "File: #{file.timestamp}" + - "-(#{fileSize}, #{fileDims}#{ - if file.isSpoiler - '' - else - ", #{shortFilename}" - }" + ")" - - fileHTML = "
#{fileInfo}
#{imgSrc}
" - else - fileHTML = '' - - tripcode = - if tripcode - " #{tripcode}" - else - '' - - sticky = - if isSticky - ' Sticky' - else - '' - closed = - if isClosed - ' Closed' - else - '' - - container = $.el 'div', - id: "pc#{postID}" - className: "postContainer #{if isOP then 'op' else 'reply'}Container" - innerHTML: \ - (if isOP then '' else "
>>
") + - "
" + - - "' + - - (if isOP then fileHTML else '') + - - "' + - - (if isOP then '' else fileHTML) + - - "
#{comment or ''}
" + - - '
' - - for quote in $$ '.quotelink', container - href = quote.getAttribute 'href' - continue if href[0] is '/' # Cross-board quote, or board link - quote.href = "/#{boardID}/res/#{href}" # Fix pathnames - - container - -Get = - threadExcerpt: (thread) -> - {OP} = thread - excerpt = OP.info.subject?.trim() or - OP.info.comment.replace(/\n+/g, ' // ') or - Conf['Anonymize'] and 'Anonymous' or - $('.nameBlock', OP.nodes.info).textContent.trim() - if excerpt.length > 70 - excerpt = "#{excerpt[...67]}..." - "/#{thread.board}/ - #{excerpt}" - postFromRoot: (root) -> - link = $ 'a[title="Highlight this post"]', root - boardID = link.pathname.split('/')[1] - postID = link.hash[2..] - index = root.dataset.clone - post = g.posts["#{boardID}.#{postID}"] - if index then post.clones[index] else post - postFromNode: (root) -> - Get.postFromRoot $.x 'ancestor::div[contains(@class,"postContainer")][1]', root - contextFromLink: (quotelink) -> - Get.postFromRoot $.x 'ancestor::div[parent::div[@class="thread"]][1]', quotelink - postDataFromLink: (link) -> - if link.hostname is 'boards.4chan.org' - path = link.pathname.split '/' - boardID = path[1] - threadID = path[3] - postID = link.hash[2..] - else # resurrected quote - boardID = link.dataset.boardid - threadID = link.dataset.threadid or 0 - postID = link.dataset.postid - return { - boardID: boardID - threadID: +threadID - postID: +postID - } - allQuotelinksLinkingTo: (post) -> - # Get quotelinks & backlinks linking to the given post. - quotelinks = [] - # First: - # In every posts, - # if it did quote this post, - # get all their backlinks. - for ID, quoterPost of g.posts - if post.fullID in quoterPost.quotes - for quoterPost in [quoterPost].concat quoterPost.clones - quotelinks.push.apply quotelinks, quoterPost.nodes.quotelinks - # Second: - # If we have quote backlinks: - # in all posts this post quoted - # and their clones, - # get all of their backlinks. - if Conf['Quote Backlinks'] - for quote in post.quotes - continue unless quotedPost = g.posts[quote] - for quotedPost in [quotedPost].concat quotedPost.clones - quotelinks.push.apply quotelinks, [quotedPost.nodes.backlinks...] - # Third: - # Filter out irrelevant quotelinks. - quotelinks.filter (quotelink) -> - {boardID, postID} = Get.postDataFromLink quotelink - boardID is post.board.ID and postID is post.ID - postClone: (boardID, threadID, postID, root, context) -> - if post = g.posts["#{boardID}.#{postID}"] - Get.insert post, root, context - return - - root.textContent = "Loading post No.#{postID}..." - if threadID - $.cache "//api.4chan.org/#{boardID}/res/#{threadID}.json", -> - Get.fetchedPost @, boardID, threadID, postID, root, context - else if url = Redirect.post boardID, postID - $.cache url, -> - Get.archivedPost @, boardID, postID, root, context - insert: (post, root, context) -> - # Stop here if the container has been removed while loading. - return unless root.parentNode - clone = post.addClone context - Main.callbackNodes Post, [clone] - - # Get rid of the side arrows. - {nodes} = clone - $.rmAll nodes.root - $.add nodes.root, nodes.post - - $.rmAll root - $.add root, nodes.root - fetchedPost: (req, boardID, threadID, postID, root, context) -> - # In case of multiple callbacks for the same request, - # don't parse the same original post more than once. - if post = g.posts["#{boardID}.#{postID}"] - Get.insert post, root, context - return - - {status} = req - if status not in [200, 304] - # The thread can die by the time we check a quote. - if url = Redirect.post boardID, postID - $.cache url, -> - Get.archivedPost @, boardID, postID, root, context - else - $.addClass root, 'warning' - root.textContent = - if status is 404 - "Thread No.#{threadID} 404'd." - else - "Error #{req.statusText} (#{req.status})." - return - - posts = JSON.parse(req.response).posts - Build.spoilerRange[boardID] = posts[0].custom_spoiler - for post in posts - break if post.no is postID # we found it! - if post.no > postID - # The post can be deleted by the time we check a quote. - if url = Redirect.post boardID, postID - $.cache url, -> - Get.archivedPost @, boardID, postID, root, context - else - $.addClass root, 'warning' - root.textContent = "Post No.#{postID} was not found." - return - - board = g.boards[boardID] or - new Board boardID - thread = g.threads["#{boardID}.#{threadID}"] or - new Thread threadID, board - post = new Post Build.postFromObject(post, boardID), thread, board - Main.callbackNodes Post, [post] - Get.insert post, root, context - archivedPost: (req, boardID, postID, root, context) -> - # In case of multiple callbacks for the same request, - # don't parse the same original post more than once. - if post = g.posts["#{boardID}.#{postID}"] - Get.insert post, root, context - return - - data = JSON.parse req.response - if data.error - $.addClass root, 'warning' - root.textContent = data.error - return - - # convert comment to html - bq = $.el 'blockquote', textContent: data.comment # set this first to convert text to HTML entities - # https://github.com/eksopl/fuuka/blob/master/Board/Yotsuba.pm#L413-452 - # https://github.com/eksopl/asagi/blob/master/src/main/java/net/easymodo/asagi/Yotsuba.java#L109-138 - bq.innerHTML = bq.innerHTML.replace /// - \n - | \[/?b\] - | \[/?spoiler\] - | \[/?code\] - | \[/?moot\] - | \[/?banned\] - ///g, (text) -> - switch text - when '\n' - '
' - when '[b]' - '' - when '[/b]' - '' - when '[spoiler]' - '' - when '[/spoiler]' - '' - when '[code]' - '
'
-          when '[/code]'
-            '
' - when '[moot]' - '
' - when '[/moot]' - '
' - when '[banned]' - '' - when '[/banned]' - '' - - comment = bq.innerHTML - # greentext - .replace(/(^|>)(>[^<$]*)(<|$)/g, '$1$2$3') - # quotes - .replace /((>){2}(>\/[a-z\d]+\/)?\d+)/g, '$1' - - threadID = data.thread_num - o = - # id - postID: "#{postID}" - threadID: "#{threadID}" - boardID: boardID - # info - name: data.name_processed - capcode: switch data.capcode - when 'M' then 'mod' - when 'A' then 'admin' - when 'D' then 'developer' - tripcode: data.trip - uniqueID: data.poster_hash - email: if data.email then encodeURI data.email else '' - subject: data.title_processed - flagCode: data.poster_country - flagName: data.poster_country_name_processed - date: data.fourchan_date - dateUTC: data.timestamp - comment: comment - # file - if data.media?.media_filename - o.file = - name: data.media.media_filename_processed - timestamp: data.media.media_orig - url: data.media.media_link or data.media.remote_media_link - height: data.media.media_h - width: data.media.media_w - MD5: data.media.media_hash - size: data.media.media_size - turl: data.media.thumb_link or "//thumbs.4chan.org/#{boardID}/thumb/#{data.media.preview_orig}" - theight: data.media.preview_h - twidth: data.media.preview_w - isSpoiler: data.media.spoiler is '1' - - board = g.boards[boardID] or - new Board boardID - thread = g.threads["#{boardID}.#{threadID}"] or - new Thread threadID, board - post = new Post Build.post(o, true), thread, board, - isArchived: true - Main.callbackNodes Post, [post] - Get.insert post, root, context - -Quotify = - init: -> - return if g.VIEW is 'catalog' or !Conf['Resurrect Quotes'] - - Post::callbacks.push - name: 'Resurrect Quotes' - cb: @node - node: -> - for deadlink in $$ '.deadlink', @nodes.comment - if @isClone - if $.hasClass deadlink, 'quotelink' - @nodes.quotelinks.push deadlink - else - Quotify.parseDeadlink.call @, deadlink - return - - parseDeadlink: (deadlink) -> - if deadlink.parentNode.className is 'prettyprint' - # Don't quotify deadlinks inside code tags, - # un-`span` them. - $.replace deadlink, [deadlink.childNodes...] - return - - quote = deadlink.textContent - return unless postID = quote.match(/\d+$/)?[0] - boardID = if m = quote.match /^>>>\/([a-z\d]+)/ - m[1] - else - @board.ID - quoteID = "#{boardID}.#{postID}" - - if post = g.posts[quoteID] - unless post.isDead - # Don't (Dead) when quotifying in an archived post, - # and we know the post still exists. - a = $.el 'a', - href: "/#{boardID}/#{post.thread}/res/#p#{postID}" - className: 'quotelink' - textContent: quote - else - # Replace the .deadlink span if we can redirect. - a = $.el 'a', - href: "/#{boardID}/#{post.thread}/res/#p#{postID}" - className: 'quotelink deadlink' - target: '_blank' - textContent: "#{quote}\u00A0(Dead)" - a.setAttribute 'data-boardid', boardID - a.setAttribute 'data-threadid', post.thread.ID - a.setAttribute 'data-postid', postID - else if redirect = Redirect.to {boardID, threadID: 0, postID} - # Replace the .deadlink span if we can redirect. - a = $.el 'a', - href: redirect - className: 'deadlink' - target: '_blank' - textContent: "#{quote}\u00A0(Dead)" - if Redirect.post boardID, postID - # Make it function as a normal quote if we can fetch the post. - $.addClass a, 'quotelink' - a.setAttribute 'data-boardid', boardID - a.setAttribute 'data-postid', postID - - unless quoteID in @quotes - @quotes.push quoteID - - unless a - deadlink.textContent = "#{quote}\u00A0(Dead)" - return - - $.replace deadlink, a - if $.hasClass a, 'quotelink' - @nodes.quotelinks.push a - -QuoteInline = - init: -> - return if g.VIEW is 'catalog' or !Conf['Quote Inlining'] - - Post::callbacks.push - name: 'Quote Inlining' - cb: @node - node: -> - for link in @nodes.quotelinks.concat [@nodes.backlinks...] - $.on link, 'click', QuoteInline.toggle - return - toggle: (e) -> - return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0 - e.preventDefault() - {boardID, threadID, postID} = Get.postDataFromLink @ - context = Get.contextFromLink @ - if $.hasClass @, 'inlined' - QuoteInline.rm @, boardID, threadID, postID, context - else - return if $.x "ancestor::div[@id='p#{postID}']", @ - QuoteInline.add @, boardID, threadID, postID, context - @classList.toggle 'inlined' - - findRoot: (quotelink, isBacklink) -> - if isBacklink - quotelink.parentNode.parentNode - else - $.x 'ancestor-or-self::*[parent::blockquote][1]', quotelink - add: (quotelink, boardID, threadID, postID, context) -> - isBacklink = $.hasClass quotelink, 'backlink' - inline = $.el 'div', - id: "i#{postID}" - className: 'inline' - $.after QuoteInline.findRoot(quotelink, isBacklink), inline - Get.postClone boardID, threadID, postID, inline, context - - return unless (post = g.posts["#{boardID}.#{postID}"]) and - context.thread is post.thread - - # Hide forward post if it's a backlink of a post in this thread. - # Will only unhide if there's no inlined backlinks of it anymore. - if isBacklink and Conf['Forward Hiding'] - $.addClass post.nodes.root, 'forwarded' - post.forwarded++ or post.forwarded = 1 - - # Decrease the unread count if this post - # is in the array of unread posts. - return unless Unread.posts - Unread.readSinglePost post - - rm: (quotelink, boardID, threadID, postID, context) -> - isBacklink = $.hasClass quotelink, 'backlink' - # Select the corresponding inlined quote, and remove it. - root = QuoteInline.findRoot quotelink, isBacklink - root = $.x "following-sibling::div[@id='i#{postID}'][1]", root - $.rm root - - # Stop if it only contains text. - return unless el = root.firstElementChild - - # Dereference clone. - post = g.posts["#{boardID}.#{postID}"] - post.rmClone el.dataset.clone - - # Decrease forward count and unhide. - if Conf['Forward Hiding'] and - isBacklink and - context.thread is g.threads["#{boardID}.#{threadID}"] and - not --post.forwarded - delete post.forwarded - $.rmClass post.nodes.root, 'forwarded' - - # Repeat. - while inlined = $ '.inlined', el - {boardID, threadID, postID} = Get.postDataFromLink inlined - QuoteInline.rm inlined, boardID, threadID, postID, context - $.rmClass inlined, 'inlined' - return - -QuotePreview = - init: -> - return if g.VIEW is 'catalog' or !Conf['Quote Previewing'] - - Post::callbacks.push - name: 'Quote Previewing' - cb: @node - node: -> - for link in @nodes.quotelinks.concat [@nodes.backlinks...] - $.on link, 'mouseover', QuotePreview.mouseover - return - mouseover: (e) -> - return if $.hasClass @, 'inlined' - - {boardID, threadID, postID} = Get.postDataFromLink @ - - qp = $.el 'div', - id: 'qp' - className: 'dialog' - $.add d.body, qp - Get.postClone boardID, threadID, postID, qp, Get.contextFromLink @ - - UI.hover - root: @ - el: qp - latestEvent: e - endEvents: 'mouseout click' - cb: QuotePreview.mouseout - asapTest: -> qp.firstElementChild - - return unless origin = g.posts["#{boardID}.#{postID}"] - - if Conf['Quote Highlighting'] - posts = [origin].concat origin.clones - # Remove the clone that's in the qp from the array. - posts.pop() - for post in posts - $.addClass post.nodes.post, 'qphl' - - quoterID = $.x('ancestor::*[@id][1]', @).id.match(/\d+$/)[0] - clone = Get.postFromRoot qp.firstChild - for quote in clone.nodes.quotelinks.concat [clone.nodes.backlinks...] - if quote.hash[2..] is quoterID - $.addClass quote, 'forwardlink' - return - mouseout: -> - # Stop if it only contains text. - return unless root = @el.firstElementChild - - clone = Get.postFromRoot root - post = clone.origin - post.rmClone root.dataset.clone - - return unless Conf['Quote Highlighting'] - for post in [post].concat post.clones - $.rmClass post.nodes.post, 'qphl' - return - -QuoteBacklink = - # Backlinks appending need to work for: - # - previous, same, and following posts. - # - existing and yet-to-exist posts. - # - newly fetched posts. - # - in copies. - # XXX what about order for fetched posts? - # - # First callback creates backlinks and add them to relevant containers. - # Second callback adds relevant containers into posts. - # This is is so that fetched posts can get their backlinks, - # and that as much backlinks are appended in the background as possible. - init: -> - return if g.VIEW is 'catalog' or !Conf['Quote Backlinks'] - - format = Conf['backlink'].replace /%id/g, "' + id + '" - @funk = Function 'id', "return '#{format}'" - @containers = {} - Post::callbacks.push - name: 'Quote Backlinking Part 1' - cb: @firstNode - Post::callbacks.push - name: 'Quote Backlinking Part 2' - cb: @secondNode - firstNode: -> - return if @isClone or !@quotes.length - a = $.el 'a', - href: "/#{@board}/res/#{@thread}#p#{@}" - className: if @isHidden then 'filtered backlink' else 'backlink' - textContent: QuoteBacklink.funk @ID - for quote in @quotes - containers = [QuoteBacklink.getContainer quote] - if (post = g.posts[quote]) and post.nodes.backlinkContainer - # Don't add OP clones when OP Backlinks is disabled, - # as the clones won't have the backlink containers. - for clone in post.clones - containers.push clone.nodes.backlinkContainer - for container in containers - link = a.cloneNode true - if Conf['Quote Previewing'] - $.on link, 'mouseover', QuotePreview.mouseover - if Conf['Quote Inlining'] - $.on link, 'click', QuoteInline.toggle - $.add container, [$.tn(' '), link] - return - secondNode: -> - if @isClone and (@origin.isReply or Conf['OP Backlinks']) - @nodes.backlinkContainer = $ '.container', @nodes.info - return - # Don't backlink the OP. - return unless @isReply or Conf['OP Backlinks'] - container = QuoteBacklink.getContainer @fullID - @nodes.backlinkContainer = container - $.add @nodes.info, container - getContainer: (id) -> - @containers[id] or= - $.el 'span', className: 'container' - -QuoteYou = - init: -> - return if g.VIEW is 'catalog' or !Conf['Mark Quotes of You'] or !Conf['Quick Reply'] - - # \u00A0 is nbsp - @text = '\u00A0(You)' - Post::callbacks.push - name: 'Mark Quotes of You' - cb: @node - node: -> - # Stop there if it's a clone. - return if @isClone - # Stop there if there's no quotes in that post. - return unless (quotes = @quotes).length - {quotelinks} = @nodes - - for quotelink in quotelinks - if QR.db.get Get.postDataFromLink quotelink - $.add quotelink, $.tn QuoteYou.text - return - -QuoteOP = - init: -> - return if g.VIEW is 'catalog' or !Conf['Mark OP Quotes'] - - # \u00A0 is nbsp - @text = '\u00A0(OP)' - Post::callbacks.push - name: 'Mark OP Quotes' - cb: @node - node: -> - # Stop there if it's a clone of a post in the same thread. - return if @isClone and @thread is @context.thread - # Stop there if there's no quotes in that post. - return unless (quotes = @quotes).length - {quotelinks} = @nodes - - # rm (OP) from cross-thread quotes. - if @isClone and @thread.fullID in quotes - for quotelink in quotelinks - quotelink.textContent = quotelink.textContent.replace QuoteOP.text, '' - - op = (if @isClone then @context else @).thread.fullID - # add (OP) to quotes quoting this context's OP. - return unless op in quotes - for quotelink in quotelinks - {boardID, postID} = Get.postDataFromLink quotelink - if "#{boardID}.#{postID}" is op - $.add quotelink, $.tn QuoteOP.text - return - -QuoteCT = - init: -> - return if g.VIEW is 'catalog' or !Conf['Mark Cross-thread Quotes'] - - # \u00A0 is nbsp - @text = '\u00A0(Cross-thread)' - Post::callbacks.push - name: 'Mark Cross-thread Quotes' - cb: @node - node: -> - # Stop there if it's a clone of a post in the same thread. - return if @isClone and @thread is @context.thread - # Stop there if there's no quotes in that post. - return unless (quotes = @quotes).length - {quotelinks} = @nodes - - {board, thread} = if @isClone then @context else @ - for quotelink in quotelinks - {boardID, threadID} = Get.postDataFromLink quotelink - continue unless threadID # deadlink - if @isClone - quotelink.textContent = quotelink.textContent.replace QuoteCT.text, '' - if boardID is @board.ID and threadID isnt thread.ID - $.add quotelink, $.tn QuoteCT.text - return - -Anonymize = - init: -> - return if g.VIEW is 'catalog' or !Conf['Anonymize'] - - Post::callbacks.push - name: 'Anonymize' - cb: @node - node: -> - return if @info.capcode or @isClone - {name, tripcode, email} = @nodes - if @info.name isnt 'Anonymous' - name.textContent = 'Anonymous' - if tripcode - $.rm tripcode - delete @nodes.tripcode - if @info.email - if /sage/i.test @info.email - email.href = 'mailto:sage' - else - $.replace email, name - delete @nodes.email - -Time = - init: -> - return if g.VIEW is 'catalog' or !Conf['Time Formatting'] - - @funk = @createFunc Conf['time'] - Post::callbacks.push - name: 'Time Formatting' - cb: @node - node: -> - return if @isClone - @nodes.date.textContent = Time.funk Time, @info.date - createFunc: (format) -> - code = format.replace /%([A-Za-z])/g, (s, c) -> - if c of Time.formatters - "' + Time.formatters.#{c}.call(date) + '" - else - s - Function 'Time', 'date', "return '#{code}'" - day: [ - 'Sunday' - 'Monday' - 'Tuesday' - 'Wednesday' - 'Thursday' - 'Friday' - 'Saturday' - ] - month: [ - 'January' - 'February' - 'March' - 'April' - 'May' - 'June' - 'July' - 'August' - 'September' - 'October' - 'November' - 'December' - ] - zeroPad: (n) -> if n < 10 then "0#{n}" else n - formatters: - a: -> Time.day[@getDay()][...3] - A: -> Time.day[@getDay()] - b: -> Time.month[@getMonth()][...3] - B: -> Time.month[@getMonth()] - d: -> Time.zeroPad @getDate() - e: -> @getDate() - H: -> Time.zeroPad @getHours() - I: -> Time.zeroPad @getHours() % 12 or 12 - k: -> @getHours() - l: -> @getHours() % 12 or 12 - m: -> Time.zeroPad @getMonth() + 1 - M: -> Time.zeroPad @getMinutes() - p: -> if @getHours() < 12 then 'AM' else 'PM' - P: -> if @getHours() < 12 then 'am' else 'pm' - S: -> Time.zeroPad @getSeconds() - y: -> @getFullYear() - 2000 - -RelativeDates = - INTERVAL: $.MINUTE / 2 - init: -> - return if g.VIEW is 'catalog' or !Conf['Relative Post Dates'] - - # Flush when page becomes visible again or when the thread updates. - $.on d, 'visibilitychange ThreadUpdate', @flush - - # Start the timeout. - @flush() - - Post::callbacks.push - name: 'Relative Post Dates' - cb: @node - node: -> - return if @isClone - - # Show original absolute time as tooltip so users can still know exact times - # Since "Time Formatting" runs its `node` before us, the title tooltip will - # pick up the user-formatted time instead of 4chan time when enabled. - dateEl = @nodes.date - dateEl.title = dateEl.textContent - - RelativeDates.setUpdate @ - - # diff is milliseconds from now. - relative: (diff, now, date) -> - unit = if (number = (diff / $.DAY)) >= 1 - years = now.getYear() - date.getYear() - months = now.getMonth() - date.getMonth() - days = now.getDate() - date.getDate() - if years > 1 - number = years - (months < 0 or months is 0 and days < 0) - 'year' - else if years is 1 and (months > 0 or months is 0 and days >= 0) - number = years - 'year' - else if (months = (months+12)%12 ) > 1 - number = months - (days < 0) - 'month' - else if months is 1 and days >= 0 - number = months - 'month' - else - 'day' - else if (number = (diff / $.HOUR)) >= 1 - 'hour' - else if (number = (diff / $.MINUTE)) >= 1 - 'minute' - else - # prevent "-1 seconds ago" - number = Math.max(0, diff) / $.SECOND - 'second' - - rounded = Math.round number - unit += 's' if rounded isnt 1 # pluralize - - "#{rounded} #{unit} ago" - - # Changing all relative dates as soon as possible incurs many annoying - # redraws and scroll stuttering. Thus, sacrifice accuracy for UX/CPU economy, - # and perform redraws when the DOM is otherwise being manipulated (and scroll - # stuttering won't be noticed), falling back to INTERVAL while the page - # is visible. - # - # Each individual dateTime element will add its update() function to the stale list - # when it is to be called. - stale: [] - flush: -> - # No point in changing the dates until the user sees them. - return if d.hidden - - now = new Date() - update now for update in RelativeDates.stale - RelativeDates.stale = [] - - # Reset automatic flush. - clearTimeout RelativeDates.timeout - RelativeDates.timeout = setTimeout RelativeDates.flush, RelativeDates.INTERVAL - - # Create function `update()`, closed over post, that, when called - # from `flush()`, updates the elements, and re-calls `setOwnTimeout()` to - # re-add `update()` to the stale list later. - setUpdate: (post) -> - setOwnTimeout = (diff) -> - delay = if diff < $.MINUTE - $.SECOND - (diff + $.SECOND / 2) % $.SECOND - else if diff < $.HOUR - $.MINUTE - (diff + $.MINUTE / 2) % $.MINUTE - else if diff < $.DAY - $.HOUR - (diff + $.HOUR / 2) % $.HOUR - else - $.DAY - (diff + $.DAY / 2) % $.DAY - setTimeout markStale, delay - - update = (now) -> - {date} = post.info - diff = now - date - relative = RelativeDates.relative diff, now, date - for singlePost in [post].concat post.clones - singlePost.nodes.date.firstChild.textContent = relative - setOwnTimeout diff - - markStale = -> RelativeDates.stale.push update - - # Kick off initial timeout. - update new Date() - -FileInfo = - init: -> - return if g.VIEW is 'catalog' or !Conf['File Info Formatting'] - - @funk = @createFunc Conf['fileInfo'] - Post::callbacks.push - name: 'File Info Formatting' - cb: @node - node: -> - return if !@file or @isClone - @file.text.innerHTML = FileInfo.funk FileInfo, @ - createFunc: (format) -> - code = format.replace /%(.)/g, (s, c) -> - if c of FileInfo.formatters - "' + FileInfo.formatters.#{c}.call(post) + '" - else - s - Function 'FileInfo', 'post', "return '#{code}'" - convertUnit: (size, unit) -> - if unit is 'B' - return "#{size.toFixed()} Bytes" - i = 1 + ['KB', 'MB'].indexOf unit - size /= 1024 while i-- - size = - if unit is 'MB' - Math.round(size * 100) / 100 - else - size.toFixed() - "#{size} #{unit}" - escape: (name) -> - name.replace /<|>/g, (c) -> - c is '<' and '<' or '>' - formatters: - t: -> @file.URL.match(/\d+\..+$/)[0] - T: -> "#{FileInfo.formatters.t.call @}" - l: -> "#{FileInfo.formatters.n.call @}" - L: -> "#{FileInfo.formatters.N.call @}" - n: -> - fullname = @file.name - shortname = Build.shortFilename @file.name, @isReply - if fullname is shortname - FileInfo.escape fullname - else - "#{FileInfo.escape shortname}#{FileInfo.escape fullname}" - N: -> FileInfo.escape @file.name - p: -> if @file.isSpoiler then 'Spoiler, ' else '' - s: -> @file.size - B: -> FileInfo.convertUnit @file.sizeInBytes, 'B' - K: -> FileInfo.convertUnit @file.sizeInBytes, 'KB' - M: -> FileInfo.convertUnit @file.sizeInBytes, 'MB' - r: -> if @file.isImage then @file.dimensions else 'PDF' - -Sauce = - init: -> - return if g.VIEW is 'catalog' or !Conf['Sauce'] - - links = [] - for link in Conf['sauces'].split '\n' - continue if link[0] is '#' - links.push @createSauceLink link.trim() - return unless links.length - @links = links - @link = $.el 'a', target: '_blank' - Post::callbacks.push - name: 'Sauce' - cb: @node - createSauceLink: (link) -> - link = link.replace /%(T?URL|MD5|board)/g, (parameter) -> - switch parameter - when '%TURL' - "' + encodeURIComponent(post.file.thumbURL) + '" - when '%URL' - "' + encodeURIComponent(post.file.URL) + '" - when '%MD5' - "' + encodeURIComponent(post.file.MD5) + '" - when '%board' - "' + encodeURIComponent(post.board) + '" - else - parameter - text = if m = link.match(/;text:(.+)$/) then m[1] else link.match(/(\w+)\.\w+\//)[1] - link = link.replace /;text:.+$/, '' - Function 'post', 'a', """ - a.href = '#{link}'; - a.textContent = '#{text}'; - return a; - """ - node: -> - return if @isClone or !@file - nodes = [] - for link in Sauce.links - # \u00A0 is nbsp - nodes.push $.tn('\u00A0'), link @, Sauce.link.cloneNode true - $.add @file.info, nodes - -ImageExpand = - init: -> - return if g.VIEW is 'catalog' or !Conf['Image Expansion'] - - @EAI = $.el 'a', - className: 'expand-all-shortcut' - textContent: 'EAI' - title: 'Expand All Images' - href: 'javascript:;' - $.on @EAI, 'click', ImageExpand.cb.toggleAll - Header.addShortcut @EAI - - Post::callbacks.push - name: 'Image Expansion' - cb: @node - node: -> - return unless @file?.isImage - {thumb} = @file - $.on thumb.parentNode, 'click', ImageExpand.cb.toggle - if @isClone and $.hasClass thumb, 'expanding' - # If we clone a post where the image is still loading, - # make it loading in the clone too. - ImageExpand.contract @ - ImageExpand.expand @ - return - if ImageExpand.on and !@isHidden - ImageExpand.expand @ - cb: - toggle: (e) -> - return if e.shiftKey or e.altKey or e.ctrlKey or e.metaKey or e.button isnt 0 - e.preventDefault() - ImageExpand.toggle Get.postFromNode @ - toggleAll: -> - $.event 'CloseMenu' - if ImageExpand.on = $.hasClass ImageExpand.EAI, 'expand-all-shortcut' - ImageExpand.EAI.className = 'contract-all-shortcut' - ImageExpand.EAI.title = 'Contract All Images' - func = ImageExpand.expand - else - ImageExpand.EAI.className = 'expand-all-shortcut' - ImageExpand.EAI.title = 'Expand All Images' - func = ImageExpand.contract - for ID, post of g.posts - for post in [post].concat post.clones - {file} = post - continue unless file and file.isImage and doc.contains post.nodes.root - if ImageExpand.on and - (!Conf['Expand spoilers'] and file.isSpoiler or - Conf['Expand from here'] and file.thumb.getBoundingClientRect().top < 0) - continue - $.queueTask func, post - return - setFitness: -> - {checked} = @ - (if checked then $.addClass else $.rmClass) doc, @name.toLowerCase().replace /\s+/g, '-' - return unless @name is 'Fit height' - if checked - $.on window, 'resize', ImageExpand.resize - unless ImageExpand.style - ImageExpand.style = $.addStyle null - ImageExpand.resize() - else - $.off window, 'resize', ImageExpand.resize - - toggle: (post) -> - {thumb} = post.file - unless post.file.isExpanded or $.hasClass thumb, 'expanding' - ImageExpand.expand post - return - ImageExpand.contract post - rect = post.nodes.root.getBoundingClientRect() - return unless rect.top <= 0 or rect.left <= 0 - # Scroll back to the thumbnail when contracting the image - # to avoid being left miles away from the relevant post. - {top} = rect - unless Conf['Bottom header'] - headRect = Header.toggle.getBoundingClientRect() - top += - headRect.top - headRect.height - root = <% if (type === 'crx') { %>d.body<% } else { %>doc<% } %> - root.scrollTop += top if rect.top < 0 - root.scrollLeft = 0 if rect.left < 0 - - contract: (post) -> - $.rmClass post.nodes.root, 'expanded-image' - $.rmClass post.file.thumb, 'expanding' - post.file.isExpanded = false - - expand: (post, src) -> - # Do not expand images of hidden/filtered replies, or already expanded pictures. - {thumb} = post.file - return if post.isHidden or post.file.isExpanded or $.hasClass thumb, 'expanding' - $.addClass thumb, 'expanding' - if post.file.fullImage - # Expand already-loaded/ing picture. - $.asap (-> post.file.fullImage.naturalHeight), -> - ImageExpand.completeExpand post - return - post.file.fullImage = img = $.el 'img', - className: 'full-image' - src: src or post.file.URL - $.on img, 'error', ImageExpand.error - $.asap (-> post.file.fullImage.naturalHeight), -> - ImageExpand.completeExpand post - $.after thumb, img - - completeExpand: (post) -> - {thumb} = post.file - return unless $.hasClass thumb, 'expanding' # contracted before the image loaded - post.file.isExpanded = true - unless post.nodes.root.parentNode - # Image might start/finish loading before the post is inserted. - # Don't scroll when it's expanded in a QP for example. - $.addClass post.nodes.root, 'expanded-image' - $.rmClass post.file.thumb, 'expanding' - return - prev = post.nodes.root.getBoundingClientRect() - $.queueTask -> - $.addClass post.nodes.root, 'expanded-image' - $.rmClass post.file.thumb, 'expanding' - return unless prev.top + prev.height <= 0 - root = <% if (type === 'crx') { %>d.body<% } else { %>doc<% } %> - curr = post.nodes.root.getBoundingClientRect() - root.scrollTop += curr.height - prev.height + curr.top - prev.top - - error: -> - post = Get.postFromNode @ - $.rm @ - delete post.file.fullImage - # Images can error: - # - before the image started loading. - # - after the image started loading. - unless $.hasClass(post.file.thumb, 'expanding') or $.hasClass post.nodes.root, 'expanded-image' - # Don't try to re-expend if it was already contracted. - return - ImageExpand.contract post - - src = @src.split '/' - if src[2] is 'images.4chan.org' - if URL = Redirect.image src[3], src[5] - setTimeout ImageExpand.expand, 10000, post, URL - return - if g.DEAD or post.isDead or post.file.isDead - return - - timeoutID = setTimeout ImageExpand.expand, 10000, post - # XXX CORS for images.4chan.org WHEN? - $.ajax "//api.4chan.org/#{post.board}/res/#{post.thread}.json", onload: -> - return if @status isnt 200 - for postObj in JSON.parse(@response).posts - break if postObj.no is post.ID - if postObj.no isnt post.ID - clearTimeout timeoutID - post.kill() - else if postObj.filedeleted - clearTimeout timeoutID - post.kill true - - menu: - init: -> - return if g.VIEW is 'catalog' or !Conf['Image Expansion'] - - el = $.el 'span', - textContent: 'Image Expansion' - className: 'image-expansion-link' - - {createSubEntry} = ImageExpand.menu - subEntries = [] - for key, conf of Config.imageExpansion - subEntries.push createSubEntry key, conf - - $.event 'AddMenuEntry', - type: 'header' - el: el - order: 80 - subEntries: subEntries - - createSubEntry: (type, config) -> - label = $.el 'label', - innerHTML: " #{type}" - input = label.firstElementChild - if type in ['Fit width', 'Fit height'] - $.on input, 'change', ImageExpand.cb.setFitness - if config - label.title = config[1] - input.checked = Conf[type] - $.event 'change', null, input - $.on input, 'change', $.cb.checked - el: label - - resize: -> - ImageExpand.style.textContent = ":root.fit-height .full-image {max-height:#{doc.clientHeight}px}" - -RevealSpoilers = - init: -> - return if g.VIEW is 'catalog' or !Conf['Reveal Spoilers'] - - Post::callbacks.push - name: 'Reveal Spoilers' - cb: @node - node: -> - return if @isClone or !@file?.isSpoiler - {thumb} = @file - thumb.removeAttribute 'style' - thumb.src = @file.thumbURL - -AutoGIF = - init: -> - return if g.VIEW is 'catalog' or !Conf['Auto-GIF'] or g.BOARD.ID in ['gif', 'wsg'] - - Post::callbacks.push - name: 'Auto-GIF' - cb: @node - node: -> - return if @isClone or @isHidden or @thread.isHidden or !@file?.isImage - {thumb, URL} = @file - return unless /gif$/.test(URL) and !/spoiler/.test thumb.src - if @file.isSpoiler - # Revealed spoilers do not have height/width set, this fixes auto-gifs dimensions. - {style} = thumb - style.maxHeight = style.maxWidth = if @isReply then '125px' else '250px' - gif = $.el 'img' - $.on gif, 'load', -> - # Replace the thumbnail once the GIF has finished loading. - thumb.src = URL - gif.src = URL - -ImageHover = - init: -> - return if g.VIEW is 'catalog' or !Conf['Image Hover'] - - Post::callbacks.push - name: 'Image Hover' - cb: @node - node: -> - return unless @file?.isImage - $.on @file.thumb, 'mouseover', ImageHover.mouseover - mouseover: (e) -> - post = Get.postFromNode @ - el = $.el 'img', - id: 'ihover' - src: post.file.URL - el.setAttribute 'data-fullid', post.fullID - $.add d.body, el - UI.hover - root: @ - el: el - latestEvent: e - endEvents: 'mouseout click' - asapTest: -> el.naturalHeight - $.on el, 'error', ImageHover.error - error: -> - return unless doc.contains @ - post = g.posts[@dataset.fullid] - - src = @src.split '/' - if src[2] is 'images.4chan.org' - if URL = Redirect.image src[3], src[5].replace /\?.+$/, '' - @src = URL - return - if g.DEAD or post.isDead or post.file.isDead - return - - timeoutID = setTimeout (=> @src = post.file.URL + '?' + Date.now()), 3000 - # XXX CORS for images.4chan.org WHEN? - $.ajax "//api.4chan.org/#{post.board}/res/#{post.thread}.json", onload: -> - return if @status isnt 200 - for postObj in JSON.parse(@response).posts - break if postObj.no is post.ID - if postObj.no isnt post.ID - clearTimeout timeoutID - post.kill() - else if postObj.filedeleted - clearTimeout timeoutID - post.kill true - -ExpandComment = - init: -> - return if g.VIEW isnt 'index' or !Conf['Comment Expansion'] - - Post::callbacks.push - name: 'Comment Expansion' - cb: @node - node: -> - if a = $ '.abbr > a', @nodes.comment - $.on a, 'click', ExpandComment.cb - cb: (e) -> - e.preventDefault() - post = Get.postFromNode @ - ExpandComment.expand post - expand: (post) -> - if post.nodes.longComment and !post.nodes.longComment.parentNode - $.replace post.nodes.shortComment, post.nodes.longComment - post.nodes.comment = post.nodes.longComment - return - return unless a = $ '.abbr > a', post.nodes.comment - a.textContent = "Post No.#{post} Loading..." - $.cache "//api.4chan.org#{a.pathname}.json", -> ExpandComment.parse @, a, post - contract: (post) -> - return unless post.nodes.shortComment - a = $ '.abbr > a', post.nodes.shortComment - a.textContent = 'here' - $.replace post.nodes.longComment, post.nodes.shortComment - post.nodes.comment = post.nodes.shortComment - parse: (req, a, post) -> - {status} = req - if status not in [200, 304] - a.textContent = "Error #{req.statusText} (#{status})" - return - - posts = JSON.parse(req.response).posts - if spoilerRange = posts[0].custom_spoiler - Build.spoilerRange[g.BOARD] = spoilerRange - - for postObj in posts - break if postObj.no is post.ID - if postObj.no isnt post.ID - a.textContent = "Post No.#{post} not found." - return - - {comment} = post.nodes - clone = comment.cloneNode false - clone.innerHTML = postObj.com - for quote in $$ '.quotelink', clone - href = quote.getAttribute 'href' - continue if href[0] is '/' # Cross-board quote, or board link - quote.href = "/#{post.board}/res/#{href}" # Fix pathnames - post.nodes.shortComment = comment - $.replace comment, clone - post.nodes.comment = post.nodes.longComment = clone - post.parseComment() - post.parseQuotes() - if Conf['Resurrect Quotes'] - Quotify.node.call post - if Conf['Quote Previewing'] - QuotePreview.node.call post - if Conf['Quote Inlining'] - QuoteInline.node.call post - if Conf['Mark OP Quotes'] - QuoteOP.node.call post - if Conf['Mark Cross-thread Quotes'] - QuoteCT.node.call post - if g.BOARD.ID is 'g' - Fourchan.code.call post - if g.BOARD.ID is 'sci' - Fourchan.math.call post - -ExpandThread = - init: -> - return if g.VIEW isnt 'index' or !Conf['Thread Expansion'] - - Thread::callbacks.push - name: 'Thread Expansion' - cb: @node - node: -> - return unless span = $ '.summary', @OP.nodes.root.parentNode - a = $.el 'a', - textContent: "+ #{span.textContent}" - className: 'summary' - href: 'javascript:;' - $.on a, 'click', ExpandThread.cbToggle - $.replace span, a - - cbToggle: -> - op = Get.postFromRoot @previousElementSibling - ExpandThread.toggle op.thread - - toggle: (thread) -> - threadRoot = thread.OP.nodes.root.parentNode - a = $ '.summary', threadRoot - - switch thread.isExpanded - when false, undefined - thread.isExpanded = 'loading' - for post in $$ '.thread > .postContainer', threadRoot - ExpandComment.expand Get.postFromRoot post - unless a - thread.isExpanded = true - return - thread.isExpanded = 'loading' - a.textContent = a.textContent.replace '+', '× Loading...' - $.cache "//api.4chan.org/#{thread.board}/res/#{thread}.json", -> - ExpandThread.parse @, thread, a - - when 'loading' - thread.isExpanded = false - return unless a - a.textContent = a.textContent.replace '× Loading...', '+' - - when true - thread.isExpanded = false - if a - a.textContent = a.textContent.replace '-', '+' - #goddamit moot - num = if thread.isSticky - 1 - else switch g.BOARD.ID - # XXX boards config - when 'b', 'vg', 'q' then 3 - when 't' then 1 - else 5 - replies = $$('.thread > .replyContainer', threadRoot)[...-num] - for reply in replies - if Conf['Quote Inlining'] - # rm clones - inlined.click() while inlined = $ '.inlined', reply - $.rm reply - for post in $$ '.thread > .postContainer', threadRoot - ExpandComment.contract Get.postFromRoot post - return - - parse: (req, thread, a) -> - return if a.textContent[0] is '+' - {status} = req - if status not in [200, 304] - a.textContent = "Error #{req.statusText} (#{status})" - $.off a, 'click', ExpandThread.cb.toggle - return - - thread.isExpanded = true - a.textContent = a.textContent.replace '× Loading...', '-' - - posts = JSON.parse(req.response).posts - if spoilerRange = posts[0].custom_spoiler - Build.spoilerRange[g.BOARD] = spoilerRange - - replies = posts[1..] - posts = [] - nodes = [] - for reply in replies - if post = thread.posts[reply.no] - nodes.push post.nodes.root - continue - node = Build.postFromObject reply, thread.board - post = new Post node, thread, thread.board - link = $ 'a[title="Highlight this post"]', node - link.href = "res/#{thread}#p#{post}" - link.nextSibling.href = "res/#{thread}#q#{post}" - posts.push post - nodes.push node - Main.callbackNodes Post, posts - $.after a, nodes - - # Enable 4chan features. - if Conf['Enable 4chan\'s Extension'] - $.globalEval "Parser.parseThread(#{thread.ID}, 1, #{nodes.length})" - else - Fourchan.parseThread thread.ID, 1, nodes.length - -ThreadExcerpt = - init: -> - return if g.VIEW isnt 'thread' or !Conf['Thread Excerpt'] - - Thread::callbacks.push - name: 'Thread Excerpt' - cb: @node - node: -> - d.title = Get.threadExcerpt @ - -Unread = - init: -> - return if g.VIEW isnt 'thread' or !Conf['Unread Count'] and !Conf['Unread Tab Icon'] - - @db = new DataBoard 'lastReadPosts', @sync - @hr = $.el 'hr', - id: 'unread-line' - @posts = [] - @postsQuotingYou = [] - - Thread::callbacks.push - name: 'Unread' - cb: @node - - node: -> - Unread.thread = @ - Unread.title = d.title - posts = [] - for ID, post of @posts - posts.push post if post.isReply - Unread.lastReadPost = Unread.db.get - boardID: @board.ID - threadID: @ID - defaultValue: 0 - Unread.addPosts posts - $.on d, 'ThreadUpdate', Unread.onUpdate - $.on d, 'scroll visibilitychange', Unread.read - $.on d, 'visibilitychange', Unread.setLine if Conf['Unread Line'] - $.on window, 'load', Unread.scroll if Conf['Scroll to Last Read Post'] - - scroll: -> - # Let the header's onload callback handle it. - return if (hash = location.hash.match /\d+/) and hash[0] of @posts - if Unread.posts.length - # Scroll to before the first unread post. - while root = $.x 'preceding-sibling::div[contains(@class,"postContainer")][1]', Unread.posts[0].nodes.root - break unless (Get.postFromRoot root).isHidden - root.scrollIntoView false - else if posts.length - # Scroll to the last read post. - Header.scrollToPost posts[posts.length - 1].nodes.root - - sync: -> - lastReadPost = Unread.db.get - boardID: Unread.thread.board.ID - threadID: Unread.thread.ID - defaultValue: 0 - return unless Unread.lastReadPost < lastReadPost - Unread.lastReadPost = lastReadPost - Unread.readArray Unread.posts - Unread.readArray Unread.postsQuotingYou - Unread.setLine() - Unread.update() - - addPosts: (newPosts) -> - for post in newPosts - {ID} = post - if ID <= Unread.lastReadPost or post.isHidden - continue - if QR.db - data = - boardID: post.board.ID - threadID: post.thread.ID - postID: post.ID - continue if QR.db.get data - Unread.posts.push post - Unread.addPostQuotingYou post - if Conf['Unread Line'] - # Force line on visible threads if there were no unread posts previously. - Unread.setLine Unread.posts[0] in newPosts - Unread.read() - Unread.update() - - addPostQuotingYou: (post) -> - return unless QR.db - for quotelink in post.nodes.quotelinks - if QR.db.get Get.postDataFromLink quotelink - Unread.postsQuotingYou.push post - return - - onUpdate: (e) -> - if e.detail[404] - Unread.update() - else - Unread.addPosts e.detail.newPosts - - readSinglePost: (post) -> - return if (i = Unread.posts.indexOf post) is -1 - Unread.posts.splice i, 1 - if i is 0 - Unread.lastReadPost = post.ID - Unread.saveLastReadPost() - if (i = Unread.postsQuotingYou.indexOf post) isnt -1 - Unread.postsQuotingYou.splice i, 1 - Unread.update() - - readArray: (arr) -> - for post, i in arr - break if post.ID > Unread.lastReadPost - arr.splice 0, i - - read: (e) -> - return if d.hidden or !Unread.posts.length - height = doc.clientHeight - for post, i in Unread.posts - {bottom} = post.nodes.root.getBoundingClientRect() - break if bottom > height # post is not completely read - return unless i - - Unread.lastReadPost = Unread.posts[i - 1].ID - Unread.saveLastReadPost() - Unread.posts.splice 0, i - Unread.readArray Unread.postsQuotingYou - Unread.update() if e - - saveLastReadPost: $.debounce 2 * $.SECOND, -> - Unread.db.set - boardID: Unread.thread.board.ID - threadID: Unread.thread.ID - val: Unread.lastReadPost - - setLine: (force) -> - return unless d.hidden or force is true - if post = Unread.posts[0] - {root} = post.nodes - if root isnt $ '.thread > .replyContainer', root.parentNode # not the first reply - $.before root, Unread.hr - else - $.rm Unread.hr - - update: <% if (type === 'crx') { %>(dontrepeat) <% } %>-> - count = Unread.posts.length - - if Conf['Unread Count'] - d.title = "#{if count or !Conf['Hide Unread Count at (0)'] then "(#{count}) " else ''}#{if g.DEAD then "/#{g.BOARD}/ - 404" else "#{Unread.title}"}" - <% if (type === 'crx') { %> - # XXX Chrome bug where it doesn't always update the tab title. - # crbug.com/124381 - # Call it one second later, - # but don't display outdated unread count. - unless dontrepeat - setTimeout -> - d.title = '' - Unread.update true - , $.SECOND - <% } %> - - return unless Conf['Unread Tab Icon'] - - Favicon.el.href = - if g.DEAD - if Unread.postsQuotingYou.length - Favicon.unreadDeadY - else if count - Favicon.unreadDead - else - Favicon.dead - else - if count - if Unread.postsQuotingYou.length - Favicon.unreadY - else - Favicon.unread - else - Favicon.default - - <% if (type !== 'crx') { %> - # `favicon.href = href` doesn't work on Firefox. - # `favicon.href = href` isn't enough on Opera. - # Opera won't always update the favicon if the href didn't change. - $.add d.head, Favicon.el - <% } %> - -Favicon = - init: -> - $.ready -> - Favicon.el = $ 'link[rel="shortcut icon"]', d.head - Favicon.el.type = 'image/x-icon' - {href} = Favicon.el - Favicon.SFW = /ws\.ico$/.test href - Favicon.default = href - Favicon.switch() - - switch: -> - switch Conf['favicon'] - when 'ferongr' - Favicon.unreadDead = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/ferongr/unreadDead.gif", {encoding: "base64"}) %>' - Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/ferongr/unreadDeadY.png", {encoding: "base64"}) %>' - Favicon.unreadSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/ferongr/unreadSFW.gif", {encoding: "base64"}) %>' - Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/ferongr/unreadSFWY.png", {encoding: "base64"}) %>' - Favicon.unreadNSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/ferongr/unreadNSFW.gif", {encoding: "base64"}) %>' - Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/ferongr/unreadNSFWY.png", {encoding: "base64"}) %>' - when 'xat-' - Favicon.unreadDead = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadDead.png", {encoding: "base64"}) %>' - Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadDeadY.png", {encoding: "base64"}) %>' - Favicon.unreadSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadSFW.png", {encoding: "base64"}) %>' - Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadSFWY.png", {encoding: "base64"}) %>' - Favicon.unreadNSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadNSFW.png", {encoding: "base64"}) %>' - Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadNSFWY.png", {encoding: "base64"}) %>' - when 'Mayhem' - Favicon.unreadDead = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadDead.png", {encoding: "base64"}) %>' - Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadDeadY.png", {encoding: "base64"}) %>' - Favicon.unreadSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadSFW.png", {encoding: "base64"}) %>' - Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadSFWY.png", {encoding: "base64"}) %>' - Favicon.unreadNSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadNSFW.png", {encoding: "base64"}) %>' - Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadNSFWY.png", {encoding: "base64"}) %>' - when 'Original' - Favicon.unreadDead = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/Original/unreadDead.gif", {encoding: "base64"}) %>' - Favicon.unreadDeadY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Original/unreadDeadY.png", {encoding: "base64"}) %>' - Favicon.unreadSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/Original/unreadSFW.gif", {encoding: "base64"}) %>' - Favicon.unreadSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Original/unreadSFWY.png", {encoding: "base64"}) %>' - Favicon.unreadNSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/Original/unreadNSFW.gif", {encoding: "base64"}) %>' - Favicon.unreadNSFWY = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Original/unreadNSFWY.png", {encoding: "base64"}) %>' - if Favicon.SFW - Favicon.unread = Favicon.unreadSFW - Favicon.unreadY = Favicon.unreadSFWY - else - Favicon.unread = Favicon.unreadNSFW - Favicon.unreadY = Favicon.unreadNSFWY - - empty: 'data:image/gif;base64,<%= grunt.file.read("img/favicons/empty.gif", {encoding: "base64"}) %>' - dead: 'data:image/gif;base64,<%= grunt.file.read("img/favicons/dead.gif", {encoding: "base64"}) %>' - - -ThreadStats = - init: -> - return if g.VIEW isnt 'thread' or !Conf['Thread Stats'] - @dialog = UI.dialog 'thread-stats', 'bottom: 0; left: 0;', """ -
0 / 0
- """ - - @postCountEl = $ '#post-count', @dialog - @fileCountEl = $ '#file-count', @dialog - - Thread::callbacks.push - name: 'Thread Stats' - cb: @node - node: -> - postCount = 0 - fileCount = 0 - for ID, post of @posts - postCount++ - fileCount++ if post.file - ThreadStats.thread = @ - ThreadStats.update postCount, fileCount - $.on d, 'ThreadUpdate', ThreadStats.onUpdate - $.add d.body, ThreadStats.dialog - onUpdate: (e) -> - return if e.detail[404] - {postCount, fileCount} = e.detail - ThreadStats.update postCount, fileCount - update: (postCount, fileCount) -> - {thread, postCountEl, fileCountEl} = ThreadStats - postCountEl.textContent = postCount - fileCountEl.textContent = fileCount - (if thread.postLimit and !thread.isSticky then $.addClass else $.rmClass) postCountEl, 'warning' - (if thread.fileLimit and !thread.isSticky then $.addClass else $.rmClass) fileCountEl, 'warning' - -ThreadUpdater = - init: -> - return if g.VIEW isnt 'thread' or !Conf['Thread Updater'] - - html = '' - for name, conf of Config.updater.checkbox - checked = if Conf[name] then 'checked' else '' - html += "
" - - checked = if Conf['Auto Update'] then 'checked' else '' - html = """ -
- #{html} -
-
-
- """ - - @dialog = UI.dialog 'updater', 'bottom: 0; right: 0;', html - @timer = $ '#update-timer', @dialog - @status = $ '#update-status', @dialog - - Thread::callbacks.push - name: 'Thread Updater' - cb: @node - - node: -> - ThreadUpdater.thread = @ - ThreadUpdater.root = @OP.nodes.root.parentNode - ThreadUpdater.lastPost = +ThreadUpdater.root.lastElementChild.id.match(/\d+/)[0] - ThreadUpdater.outdateCount = 0 - ThreadUpdater.lastModified = '0' - - for input in $$ 'input', ThreadUpdater.dialog - if input.type is 'checkbox' - $.on input, 'change', $.cb.checked - switch input.name - when 'Scroll BG' - $.on input, 'change', ThreadUpdater.cb.scrollBG - ThreadUpdater.cb.scrollBG() - when 'Auto Update This' - $.on input, 'change', ThreadUpdater.cb.autoUpdate - $.event 'change', null, input - when 'Interval' - $.on input, 'change', ThreadUpdater.cb.interval - ThreadUpdater.cb.interval.call input - when 'Update' - $.on input, 'click', ThreadUpdater.update - - $.on window, 'online offline', ThreadUpdater.cb.online - $.on d, 'QRPostSuccessful', ThreadUpdater.cb.post - $.on d, 'visibilitychange', ThreadUpdater.cb.visibility - - ThreadUpdater.cb.online() - $.add d.body, ThreadUpdater.dialog - - ### - http://freesound.org/people/pierrecartoons1979/sounds/90112/ - cc-by-nc-3.0 - ### - beep: 'data:audio/wav;base64,<%= grunt.file.read("audio/beep.wav", {encoding: "base64"}) %>' - - cb: - online: -> - if ThreadUpdater.online = navigator.onLine - ThreadUpdater.outdateCount = 0 - ThreadUpdater.set 'timer', ThreadUpdater.getInterval() - ThreadUpdater.update() if Conf['Auto Update This'] - ThreadUpdater.set 'status', null, null - else - ThreadUpdater.set 'timer', null - ThreadUpdater.set 'status', 'Offline', 'warning' - ThreadUpdater.cb.autoUpdate() - post: (e) -> - return unless Conf['Auto Update This'] and e.detail.threadID is ThreadUpdater.thread.ID - ThreadUpdater.outdateCount = 0 - setTimeout ThreadUpdater.update, 1000 if ThreadUpdater.seconds > 2 - visibility: -> - return if d.hidden - # Reset the counter when we focus this tab. - ThreadUpdater.outdateCount = 0 - if ThreadUpdater.seconds > ThreadUpdater.interval - ThreadUpdater.set 'timer', ThreadUpdater.getInterval() - scrollBG: -> - ThreadUpdater.scrollBG = if Conf['Scroll BG'] - -> true - else - -> not d.hidden - autoUpdate: -> - if Conf['Auto Update This'] and ThreadUpdater.online - ThreadUpdater.timeoutID = setTimeout ThreadUpdater.timeout, 1000 - else - clearTimeout ThreadUpdater.timeoutID - interval: -> - val = Math.max 5, parseInt @value, 10 - ThreadUpdater.interval = @value = val - $.cb.value.call @ - load: -> - {req} = ThreadUpdater - switch req.status - when 200 - g.DEAD = false - ThreadUpdater.parse JSON.parse(req.response).posts - ThreadUpdater.lastModified = req.getResponseHeader 'Last-Modified' - ThreadUpdater.set 'timer', ThreadUpdater.getInterval() - when 404 - g.DEAD = true - ThreadUpdater.set 'timer', null - ThreadUpdater.set 'status', '404', 'warning' - clearTimeout ThreadUpdater.timeoutID - ThreadUpdater.thread.kill() - $.event 'ThreadUpdate', - 404: true - thread: ThreadUpdater.thread - else - ThreadUpdater.outdateCount++ - ThreadUpdater.set 'timer', ThreadUpdater.getInterval() - ### - Status Code 304: Not modified - By sending the `If-Modified-Since` header we get a proper status code, and no response. - This saves bandwidth for both the user and the servers and avoid unnecessary computation. - ### - # XXX 304 -> 0 in Opera - [text, klass] = if req.status in [0, 304] - [null, null] - else - ["#{req.statusText} (#{req.status})", 'warning'] - ThreadUpdater.set 'status', text, klass - delete ThreadUpdater.req - - getInterval: -> - i = ThreadUpdater.interval - j = Math.min ThreadUpdater.outdateCount, 10 - unless d.hidden - # Lower the max refresh rate limit on visible tabs. - j = Math.min j, 7 - ThreadUpdater.seconds = Math.max i, [0, 5, 10, 15, 20, 30, 60, 90, 120, 240, 300][j] - - set: (name, text, klass) -> - el = ThreadUpdater[name] - if node = el.firstChild - # Prevent the creation of a new DOM Node - # by setting the text node's data. - node.data = text - else - el.textContent = text - el.className = klass if klass isnt undefined - - timeout: -> - ThreadUpdater.timeoutID = setTimeout ThreadUpdater.timeout, 1000 - unless n = --ThreadUpdater.seconds - ThreadUpdater.update() - else if n <= -60 - ThreadUpdater.set 'status', 'Retrying', null - ThreadUpdater.update() - else if n > 0 - ThreadUpdater.set 'timer', n - - update: -> - return unless ThreadUpdater.online - ThreadUpdater.seconds = 0 - ThreadUpdater.set 'timer', '...' - if ThreadUpdater.req - # abort() triggers onloadend, we don't want that. - ThreadUpdater.req.onloadend = null - ThreadUpdater.req.abort() - url = "//api.4chan.org/#{ThreadUpdater.thread.board}/res/#{ThreadUpdater.thread}.json" - ThreadUpdater.req = $.ajax url, onloadend: ThreadUpdater.cb.load, - headers: 'If-Modified-Since': ThreadUpdater.lastModified - - updateThreadStatus: (title, OP) -> - titleLC = title.toLowerCase() - return if ThreadUpdater.thread["is#{title}"] is !!OP[titleLC] - unless ThreadUpdater.thread["is#{title}"] = !!OP[titleLC] - message = if title is 'Sticky' - 'The thread is not a sticky anymore.' - else - 'The thread is not closed anymore.' - new Notification 'info', message, 30 - $.rm $ ".#{titleLC}Icon", ThreadUpdater.thread.OP.nodes.info - return - message = if title is 'Sticky' - 'The thread is now a sticky.' - else - 'The thread is now closed.' - new Notification 'info', message, 30 - icon = $.el 'img', - src: "//static.4chan.org/image/#{titleLC}.gif" - alt: title - title: title - className: "#{titleLC}Icon" - root = $ '[title="Quote this post"]', ThreadUpdater.thread.OP.nodes.info - if title is 'Closed' - root = $('.stickyIcon', ThreadUpdater.thread.OP.nodes.info) or root - $.after root, [$.tn(' '), icon] - - parse: (postObjects) -> - OP = postObjects[0] - Build.spoilerRange[ThreadUpdater.thread.board] = OP.custom_spoiler - - ThreadUpdater.updateThreadStatus 'Sticky', OP - ThreadUpdater.updateThreadStatus 'Closed', OP - ThreadUpdater.thread.postLimit = !!OP.bumplimit - ThreadUpdater.thread.fileLimit = !!OP.imagelimit - - nodes = [] # post container elements - posts = [] # post objects - index = [] # existing posts - files = [] # existing files - count = 0 # new posts count - # Build the index, create posts. - for postObject in postObjects - num = postObject.no - index.push num - files.push num if postObject.fsize - continue if num <= ThreadUpdater.lastPost - # Insert new posts, not older ones. - count++ - node = Build.postFromObject postObject, ThreadUpdater.thread.board - nodes.push node - posts.push new Post node, ThreadUpdater.thread, ThreadUpdater.thread.board - - deletedPosts = [] - deletedFiles = [] - # Check for deleted posts/files. - for ID, post of ThreadUpdater.thread.posts - # XXX tmp fix for 4chan's racing condition - # giving us false-positive dead posts. - # continue if post.isDead - ID = +ID - if post.isDead and ID in index - post.resurrect() - else unless ID in index - post.kill() - deletedPosts.push post - else if post.file and !post.file.isDead and ID not in files - post.kill true - deletedFiles.push post - - unless count - ThreadUpdater.set 'status', null, null - ThreadUpdater.outdateCount++ - else - ThreadUpdater.set 'status', "+#{count}", 'new' - ThreadUpdater.outdateCount = 0 - if Conf['Beep'] and d.hidden and Unread.posts and !Unread.posts.length - unless ThreadUpdater.audio - ThreadUpdater.audio = $.el 'audio', src: ThreadUpdater.beep - ThreadUpdater.audio.play() - - ThreadUpdater.lastPost = posts[count - 1].ID - Main.callbackNodes Post, posts - - scroll = Conf['Auto Scroll'] and ThreadUpdater.scrollBG() and - ThreadUpdater.root.getBoundingClientRect().bottom - doc.clientHeight < 25 - $.add ThreadUpdater.root, nodes - if scroll - if Conf['Bottom Scroll'] - <% if (type === 'crx') { %>d.body<% } else { %>doc<% } %>.scrollTop = d.body.clientHeight - else - Header.scrollToPost nodes[0] - - $.queueTask -> - # Enable 4chan features. - threadID = ThreadUpdater.thread.ID - {length} = $$ '.thread > .postContainer', ThreadUpdater.root - if Conf['Enable 4chan\'s Extension'] - $.globalEval "Parser.parseThread(#{threadID}, #{-count})" - else - Fourchan.parseThread threadID, length - count, length - - $.event 'ThreadUpdate', - 404: false - thread: ThreadUpdater.thread - newPosts: posts - deletedPosts: deletedPosts - deletedFiles: deletedFiles - postCount: OP.replies + 1 - fileCount: OP.images + (!!ThreadUpdater.thread.OP.file and !ThreadUpdater.thread.OP.file.isDead) - -ThreadWatcher = - init: -> - return if g.VIEW is 'catalog' or !Conf['Thread Watcher'] - @dialog = UI.dialog 'watcher', 'top: 50px; left: 0px;', - '
Thread Watcher
' - - $.on d, 'QRPostSuccessful', @cb.post - $.on d, '4chanXInitFinished', @ready - $.sync 'WatchedThreads', @refresh - - Thread::callbacks.push - name: 'Thread Watcher' - cb: @node - - node: -> - favicon = $.el 'img', - className: 'favicon' - $.on favicon, 'click', ThreadWatcher.cb.toggle - $.before $('input', @OP.nodes.post), favicon - return if g.VIEW isnt 'thread' - $.get 'AutoWatch', 0, (item) => - return if item['AutoWatch'] isnt @ID - ThreadWatcher.watch @ - $.delete 'AutoWatch' - - ready: -> - $.off d, '4chanXInitFinished', ThreadWatcher.ready - return unless Main.isThisPageLegit() - ThreadWatcher.refresh() - $.add d.body, ThreadWatcher.dialog - - refresh: (watched) -> - unless watched - $.get 'WatchedThreads', {}, (item) -> - ThreadWatcher.refresh item['WatchedThreads'] - return - nodes = [$('.move', ThreadWatcher.dialog)] - for board of watched - for id, props of watched[board] - x = $.el 'a', - textContent: '×' - href: 'javascript:;' - $.on x, 'click', ThreadWatcher.cb.x - link = $.el 'a', props - link.title = link.textContent - - div = $.el 'div' - $.add div, [x, $.tn(' '), link] - nodes.push div - - $.rmAll ThreadWatcher.dialog - $.add ThreadWatcher.dialog, nodes - - watched = watched[g.BOARD] or {} - for ID, thread of g.BOARD.threads - favicon = $ '.favicon', thread.OP.nodes.post - favicon.src = if ID of watched - Favicon.default - else - Favicon.empty - return - - cb: - toggle: -> - ThreadWatcher.toggle Get.postFromNode(@).thread - x: -> - thread = @nextElementSibling.pathname.split '/' - ThreadWatcher.unwatch thread[1], thread[3] - post: (e) -> - {board, postID, threadID} = e.detail - if postID is threadID - if Conf['Auto Watch'] - $.set 'AutoWatch', threadID - else if Conf['Auto Watch Reply'] - ThreadWatcher.watch board.threads[threadID] - - toggle: (thread) -> - if $('.favicon', thread.OP.nodes.post).src is Favicon.empty - ThreadWatcher.watch thread - else - ThreadWatcher.unwatch thread.board, thread.ID - - unwatch: (board, threadID) -> - $.get 'WatchedThreads', {}, (item) -> - watched = item['WatchedThreads'] - delete watched[board][threadID] - delete watched[board] unless Object.keys(watched[board]).length - ThreadWatcher.refresh watched - $.set 'WatchedThreads', watched - - watch: (thread) -> - $.get 'WatchedThreads', {}, (item) -> - watched = item['WatchedThreads'] - watched[thread.board] or= {} - watched[thread.board][thread] = - href: "/#{thread.board}/res/#{thread}" - textContent: Get.threadExcerpt thread - ThreadWatcher.refresh watched - $.set 'WatchedThreads', watched