Header = init: -> @menu = new UI.Menu 'header' @headerEl = $.el 'div', id: 'header' innerHTML: '
' headerBar = $('#header-bar', @headerEl) if $.get 'autohideHeaderBar', false $.addClass headerBar, 'autohide' menuButton = $.el 'a', className: 'menu-button' innerHTML: '[]' href: 'javascript:;' $.on menuButton, 'click', @menuToggle boardListButton = $.el 'span', className: 'show-board-list-button' innerHTML: '[+]' title: 'Toggle the board list.' $.on boardListButton, 'click', @toggleBoardList boardTitle = $.el 'a', className: 'board-name' innerHTML: "/#{g.BOARD}/ - ..." href: "/#{g.BOARD}/#{if g.VIEW is 'catalog' then 'catalog' else ''}" boardList = $.el 'span', className: 'board-list' hidden: true toggleBar = $.el 'div', id: 'toggle-header-bar' title: 'Toggle the header bar position.' $.on toggleBar, 'click', @toggleBar $.prepend headerBar, [menuButton, boardListButton, $.tn(' '), boardTitle, boardList, toggleBar] $.asap (-> d.body), -> $.prepend d.body, Header.headerEl $.asap (-> $.id 'boardNavDesktop'), @setBoardList setBoardList: -> if nav = $.id 'boardNavDesktop' if a = $ "a[href*='/#{g.BOARD}/']", nav a.className = 'current' $('.board-title', Header.headerEl).textContent = a.title $.add $('.board-list', Header.headerEl), Array::slice.call nav.childNodes toggleBoardList: -> node = @firstElementChild.firstChild if showBoardList = $.hasClass @, 'show-board-list-button' @className = 'hide-board-list-button' node.data = node.data.replace '+', '-' else @className = 'show-board-list-button' node.data = node.data.replace '-', '+' {headerEl} = Header $('.board-name', headerEl).hidden = showBoardList $('.board-list', headerEl).hidden = !showBoardList toggleBar: -> message = if isAutohiding = $.id('header-bar').classList.toggle 'autohide' 'The header bar will automatically hide itself.' else 'The header bar will remain visible.' new Notification 'info', message, 2 $.set 'autohideHeaderBar', isAutohiding menuToggle: (e) -> Header.menu.toggle e, @, g class Notification constructor: (@type, content, timeout) -> @el = $.el 'div', className: "notification #{type}" innerHTML: '×
' $.on @el.firstElementChild, 'click', @close.bind @ if typeof content is 'string' content = $.tn content $.add @el.lastElementChild, content if timeout setTimeout @close.bind(@), timeout * $.SECOND el = @el $.ready -> $.add $.id('notifications'), el setType: (type) -> $.rmClass @el, @type $.addClass @el, type @type = type close: -> $.rm @el if @el.parentNode Settings = init: -> # 4chan X settings link link = $.el 'a', className: 'settings-link' textContent: '4chan X Settings' href: 'javascript:;' $.on link, 'click', Settings.open $.event 'AddMenuEntry', type: 'header' el: link # 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 open: -> !Conf['Disable 4chan\'s extension'] return unless Conf['Disable 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: -> Header.menu.close() # Here be settings 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 replies only. op = filter.match(/[^t]op:(yes|no|only)/)?[1] or 'no' # 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: 'Thread Hiding' 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 ReplyHiding.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 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}$/" unless Filter.menu.post.isReply re += ';op:yes' # Add a new line before the regexp unless the text is empty. save = $.get type, '' save = if save "#{save}\n#{re}" else re $.set type, save # Open the options and display & focus the relevant filter textarea. # Options.dialog() # select = $ 'select[name=filter]', $.id 'options' # select.value = type # $.event select, new Event 'change' # $.id('filter_tab').checked = true # ta = select.nextElementSibling # tl = ta.textLength # ta.setSelectionRange tl, tl # ta.focus() ThreadHiding = init: -> return if g.VIEW isnt 'index' or !Conf['Thread Hiding'] @getHiddenThreads() @syncFromCatalog() @clean() Thread::callbacks.push name: 'Thread Hiding' cb: @node node: -> if data = ThreadHiding.hiddenThreads.threads[@] ThreadHiding.hide @, data.makeStub return unless Conf['Thread/Reply Hiding Buttons'] $.prepend @posts[@].nodes.root, ThreadHiding.makeButton @, 'hide' getHiddenThreads: -> hiddenThreads = $.get "hiddenThreads.#{g.BOARD}" unless hiddenThreads hiddenThreads = threads: {} lastChecked: Date.now() $.set "hiddenThreads.#{g.BOARD}", hiddenThreads ThreadHiding.hiddenThreads = hiddenThreads syncFromCatalog: -> # Sync hidden threads from the catalog into the index. hiddenThreadsOnCatalog = JSON.parse(localStorage.getItem "4chan-hide-t-#{g.BOARD}") or {} {threads} = ThreadHiding.hiddenThreads # Add threads that were hidden in the catalog. for threadID of hiddenThreadsOnCatalog continue if threadID of threads threads[threadID] = {} # Remove threads that were un-hidden in the catalog. for threadID of threads continue if threadID of threads delete threads[threadID] $.set "hiddenThreads.#{g.BOARD}", ThreadHiding.hiddenThreads clean: -> {hiddenThreads} = ThreadHiding {lastChecked} = hiddenThreads hiddenThreads.lastChecked = now = Date.now() return if lastChecked > now - $.DAY unless Object.keys(hiddenThreads.threads).length $.set "hiddenThreads.#{g.BOARD}", hiddenThreads return $.ajax "//api.4chan.org/#{g.BOARD}/catalog.json", onload: -> threads = {} for obj in JSON.parse @response for thread in obj.threads if thread.no of hiddenThreads.threads threads[thread.no] = hiddenThreads.threads[thread.no] hiddenThreads.threads = threads $.set "hiddenThreads.#{g.BOARD}", hiddenThreads menu: init: -> return if g.VIEW isnt 'index' or !Conf['Menu'] or !Conf['Thread Hiding'] 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 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 Menu.close() makeButton: (thread, type) -> a = $.el 'a', className: "#{type}-thread-button" innerHTML: "[ #{if type is 'hide' then '-' else '+'} ]" href: 'javascript:;' $.on a, 'click', -> ThreadHiding.toggle thread a saveHiddenState: (thread, makeStub) -> # Get fresh hidden threads. hiddenThreads = ThreadHiding.getHiddenThreads() hiddenThreadsCatalog = JSON.parse(localStorage.getItem "4chan-hide-t-#{g.BOARD}") or {} if thread.isHidden hiddenThreads.threads[thread] = {makeStub} hiddenThreadsCatalog[thread] = true else delete hiddenThreads.threads[thread] delete hiddenThreadsCatalog[thread] $.set "hiddenThreads.#{g.BOARD}", hiddenThreads localStorage.setItem "4chan-hide-t-#{g.BOARD}", JSON.stringify hiddenThreadsCatalog toggle: (thread) -> if thread.isHidden ThreadHiding.show thread else ThreadHiding.hide thread ThreadHiding.saveHiddenState thread hide: (thread, makeStub=Conf['Stubs']) -> return if thread.hidden op = thread.posts[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.posts[thread].nodes.root.parentNode threadRoot.nextElementSibling.hidden = threadRoot.hidden = thread.isHidden = false ReplyHiding = init: -> return if g.VIEW is 'catalog' or !Conf['Reply Hiding'] @getHiddenPosts() @clean() Post::callbacks.push name: 'Reply Hiding' cb: @node node: -> return if !@isReply or @isClone if thread = ReplyHiding.hiddenPosts.threads[@thread] if data = thread[@] if data.thisPost ReplyHiding.hide @, data.makeStub, data.hideRecursively else Recursive.hide @, data.makeStub return unless Conf['Thread/Reply Hiding Buttons'] $.replace $('.sideArrows', @nodes.root), ReplyHiding.makeButton @, 'hide' getHiddenPosts: -> hiddenPosts = $.get "hiddenPosts.#{g.BOARD}" unless hiddenPosts hiddenPosts = threads: {} lastChecked: Date.now() $.set "hiddenPosts.#{g.BOARD}", hiddenPosts ReplyHiding.hiddenPosts = hiddenPosts clean: -> {hiddenPosts} = ReplyHiding {lastChecked} = hiddenPosts hiddenPosts.lastChecked = now = Date.now() return if lastChecked > now - $.DAY unless Object.keys(hiddenPosts.threads).length $.set "hiddenPosts.#{g.BOARD}", hiddenPosts return $.ajax "//api.4chan.org/#{g.BOARD}/catalog.json", onload: -> threads = {} for obj in JSON.parse @response for thread in obj.threads if thread.no of hiddenPosts.threads threads[thread.no] = hiddenPosts.threads[thread.no] hiddenPosts.threads = threads $.set "hiddenPosts.#{g.BOARD}", hiddenPosts menu: init: -> return if g.VIEW is 'catalog' or !Conf['Menu'] or !Conf['Reply Hiding'] div = $.el 'div', className: 'hide-reply-link' textContent: 'Hide reply' apply = $.el 'a', textContent: 'Apply' href: 'javascript:;' $.on apply, 'click', ReplyHiding.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 open: (post) -> if !post.isReply or post.isClone return false ReplyHiding.menu.post = post true subEntries: [{el: apply}, {el: thisPost}, {el: replies}, {el: makeStub}] hide: -> parent = @parentNode thisPost = $('input[name=thisPost]', parent).checked replies = $('input[name=replies]', parent).checked makeStub = $('input[name=makeStub]', parent).checked {post} = ReplyHiding.menu if thisPost ReplyHiding.hide post, makeStub, replies else if replies Recursive.hide post, makeStub else return ReplyHiding.saveHiddenState post, true, thisPost, makeStub, replies Menu.close() makeButton: (post, type) -> a = $.el 'a', className: "#{type}-reply-button" innerHTML: "[ #{if type is 'hide' then '-' else '+'} ]" href: 'javascript:;' $.on a, 'click', -> ReplyHiding.toggle post a saveHiddenState: (post, isHiding, thisPost, makeStub, hideRecursively) -> # Get fresh hidden posts. hiddenPosts = ReplyHiding.getHiddenPosts() if isHiding unless thread = hiddenPosts.threads[post.thread] thread = hiddenPosts.threads[post.thread] = {} thread[post] = thisPost: thisPost isnt false # undefined -> true makeStub: makeStub hideRecursively: hideRecursively else thread = hiddenPosts.threads[post.thread] delete thread[post] unless Object.keys(thread).length delete hiddenPosts.threads[post.thread] $.set "hiddenPosts.#{g.BOARD}", hiddenPosts toggle: (post) -> if post.isHidden ReplyHiding.show post else ReplyHiding.hide post ReplyHiding.saveHiddenState post, post.isHidden hide: (post, makeStub=Conf['Stubs'], hideRecursively=Conf['Recursive Hiding']) -> return if post.isHidden post.isHidden = true Recursive.hide post, makeStub, true if hideRecursively for quotelink in Get.allQuotelinksLinkingTo post $.addClass quotelink, 'filtered' unless makeStub post.nodes.root.hidden = true return a = ReplyHiding.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) -> if post.nodes.stub $.rm post.nodes.stub delete post.nodes.stub else post.nodes.root.hidden = false post.isHidden = false for quotelink in Get.allQuotelinksLinkingTo post $.rmClass quotelink, 'filtered' return Recursive = toHide: [] init: -> Post::callbacks.push name: 'Recursive' cb: @node node: -> return if @isClone # In fetched posts: # - Strike-through quotelinks # - Hide recursively for quote in @quotes if quote in Recursive.toHide ReplyHiding.hide @, !!g.posts[quote].nodes.stub, true for quotelink in @nodes.quotelinks {board, postID} = Get.postDataFromLink quotelink if g.posts["#{board}.#{postID}"]?.isHidden $.addClass quotelink, 'filtered' return hide: (post, makeStub) -> {fullID} = post Recursive.toHide.push fullID for ID, post of g.posts continue if !post.isReply for quote in post.quotes if quote is fullID ReplyHiding.hide post, makeStub, true break return QR = init: -> return if g.VIEW is 'catalog' or !Conf['Quick Reply'] if Conf['Hide Original Post Form'] $.addClass doc, 'hide-original-post-form' link = $.el 'a', className: 'qr-shortcut' textContent: 'Quick Reply' href: 'javascript:;' $.on link, 'click', -> Header.menu.close() QR.open() if g.BOARD.ID is 'f' if g.VIEW is 'index' QR.threadSelector.value = '9999' else if g.VIEW is 'thread' QR.threadSelector.value = g.THREAD else QR.threadSelector.value = 'new' $('textarea', QR.el).focus() $.event 'AddMenuEntry', type: 'header' el: link $.on d, 'dragover', QR.dragOver $.on d, 'drop', QR.dropFile $.on d, 'dragstart dragend', QR.drag $.on d, '4chanXInitFinished', -> return unless Conf['Persistent QR'] QR.open() QR.hide() if Conf['Auto Hide QR'] Post::callbacks.push name: 'Quick Reply' cb: @node node: -> $.on $('a[title="Quote this post"]', @nodes.info), 'click', QR.quote open: -> if QR.el QR.el.hidden = false QR.unhide() return try QR.dialog() catch err delete QR.el Main.handleErrors message: 'Quick Reply dialog creation crashed.' error: err close: -> QR.el.hidden = true QR.abort() d.activeElement.blur() $.rmClass QR.el, 'dump' for i in QR.replies QR.replies[0].rm() QR.cooldown.auto = false QR.status() QR.resetFileInput() if not Conf['Remember Spoiler'] and (spoiler = $.id 'spoiler').checked spoiler.click() QR.cleanNotification() hide: -> d.activeElement.blur() $.addClass QR.el, 'autohide' $.id('autohide').checked = true unhide: -> $.rmClass QR.el, 'autohide' $.id('autohide').checked = false toggleHide: -> if @checked QR.hide() else QR.unhide() error: (err) -> QR.open() if typeof err is 'string' el = $.tn err else el = err el.removeAttribute 'style' if QR.captcha.isEnabled and /captcha|verification/i.test el.textContent # Focus the captcha input on captcha error. $('[autocomplete]', QR.el).focus() alert el.textContent if d.hidden QR.lastNotification = new Notification 'warning', el cleanNotification: -> QR.lastNotification?.close() delete QR.lastNotification status: (data={}) -> return unless QR.el if g.dead # XXX value = 404 disabled = true QR.cooldown.auto = false value = data.progress or QR.cooldown.seconds or value {input} = QR.status input.value = if QR.cooldown.auto if value then "Auto #{value}" else 'Auto' else value or 'Submit' input.disabled = disabled or false cooldown: init: -> QR.cooldown.types = thread: switch g.BOARD when 'q' then 86400 when 'b', 'soc', 'r9k' then 600 else 300 sage: if g.BOARD is 'q' then 600 else 60 file: if g.BOARD is 'q' then 300 else 30 post: if g.BOARD is 'q' then 60 else 30 QR.cooldown.cooldowns = $.get "#{g.BOARD}.cooldown", {} QR.cooldown.start() $.sync "#{g.BOARD}.cooldown", QR.cooldown.sync start: -> return if QR.cooldown.isCounting QR.cooldown.isCounting = true QR.cooldown.count() sync: (cooldowns) -> # Add each cooldowns, don't overwrite everything in case we # still need to prune one in the current tab to auto-post. for id of cooldowns QR.cooldown.cooldowns[id] = cooldowns[id] QR.cooldown.start() set: (data) -> start = Date.now() if data.delay cooldown = delay: data.delay else isSage = /sage/i.test data.post.email hasFile = !!data.post.file isReply = data.isReply type = unless isReply 'thread' else if isSage 'sage' else if hasFile 'file' else 'post' cooldown = isReply: isReply isSage: isSage hasFile: hasFile timeout: start + QR.cooldown.types[type] * $.SECOND QR.cooldown.cooldowns[start] = cooldown $.set "#{g.BOARD}.cooldown", QR.cooldown.cooldowns QR.cooldown.start() unset: (id) -> delete QR.cooldown.cooldowns[id] $.set "#{g.BOARD}.cooldown", QR.cooldown.cooldowns count: -> if Object.keys(QR.cooldown.cooldowns).length setTimeout QR.cooldown.count, 1000 else $.delete "#{g.BOARD}.cooldown" delete QR.cooldown.isCounting delete QR.cooldown.seconds QR.status() return isReply = if g.BOARD.ID is 'f' and g.VIEW is 'thread' true else QR.threadSelector.value isnt 'new' if isReply post = QR.replies[0] isSage = /sage/i.test post.email hasFile = !!post.file now = Date.now() seconds = null {types, cooldowns} = QR.cooldown for start, cooldown of cooldowns if 'delay' of cooldown if cooldown.delay seconds = Math.max seconds, cooldown.delay-- else seconds = Math.max seconds, 0 QR.cooldown.unset start continue if isReply is cooldown.isReply # Only cooldowns relevant to this post can set the seconds value. # Unset outdated cooldowns that can no longer impact us. type = unless isReply 'thread' else if isSage and cooldown.isSage 'sage' else if hasFile and cooldown.hasFile 'file' else 'post' elapsed = Math.floor (now - start) / 1000 if elapsed >= 0 # clock changed since then? seconds = Math.max seconds, types[type] - elapsed unless start <= now <= cooldown.timeout QR.cooldown.unset start # Update the status when we change posting type. # Don't get stuck at some random number. # Don't interfere with progress status updates. update = seconds isnt null or !!QR.cooldown.seconds QR.cooldown.seconds = seconds QR.status() if update QR.submit() if seconds is 0 and QR.cooldown.auto quote: (e) -> e?.preventDefault() QR.open() ta = $ 'textarea', QR.el if QR.threadSelector and !ta.value and g.BOARD.ID isnt 'f' QR.threadSelector.value = $.x('ancestor::div[parent::div[@class="board"]]', @).id[1..] # Make sure we get the correct number, even with XXX censors post = Get.postFromRoot $.x 'ancestor-or-self::div[contains(@class,"postContainer")][1]', @ text = ">>#{post}\n" sel = d.getSelection() selectionRoot = $.x 'ancestor-or-self::div[contains(@class,"postContainer")][1]', sel.anchorNode if (s = sel.toString().trim()) and post.nodes.root is selectionRoot # XXX Opera doesn't retain `\n`s? s = s.replace /\n/g, '\n>' text += ">#{s}\n" caretPos = ta.selectionStart # Replace selection for text. ta.value = ta.value[...caretPos] + text + ta.value[ta.selectionEnd..] # Move the caret to the end of the new quote. range = caretPos + text.length ta.setSelectionRange range, range ta.focus() # Fire the 'input' event ta.dispatchEvent new Event 'input' characterCount: -> counter = QR.charaCounter count = @textLength counter.textContent = count counter.hidden = count < 1000 (if count > 1500 then $.addClass else $.rmClass) counter, 'warning' drag: (e) -> # Let it drag anything from the page. toggle = if e.type is 'dragstart' then $.off else $.on toggle d, 'dragover', QR.dragOver toggle d, 'drop', QR.dropFile dragOver: (e) -> e.preventDefault() e.dataTransfer.dropEffect = 'copy' # cursor feedback dropFile: (e) -> # Let it only handle files from the desktop. return unless e.dataTransfer.files.length e.preventDefault() QR.open() QR.fileInput.call e.dataTransfer $.addClass QR.el, 'dump' fileInput: -> QR.cleanNotification() # Set or change current reply's file. if @files.length is 1 file = @files[0] if file.size > @max QR.error 'File too large.' QR.resetFileInput() else if -1 is QR.mimeTypes.indexOf file.type QR.error 'Unsupported file type.' QR.resetFileInput() else QR.selected.setFile file return # Create new replies with these files. for file in @files if file.size > @max QR.error "File #{file.name} is too large." break else if -1 is QR.mimeTypes.indexOf file.type QR.error "#{file.name}: Unsupported file type." break unless QR.replies[QR.replies.length - 1].file # set last reply's file QR.replies[QR.replies.length - 1].setFile file else new QR.reply().setFile file $.addClass QR.el, 'dump' QR.resetFileInput() # reset input resetFileInput: -> $('[type=file]', QR.el).value = null replies: [] reply: class constructor: -> # set values, or null, to avoid 'undefined' values in inputs prev = QR.replies[QR.replies.length-1] persona = $.get 'QR.persona', {} @name = if prev then prev.name else persona.name or null @email = if prev and !/^sage$/.test prev.email then prev.email else persona.email or null @sub = if prev and Conf['Remember Subject'] then prev.sub else if Conf['Remember Subject'] then persona.sub else null @spoiler = if prev and Conf['Remember Spoiler'] then prev.spoiler else false @com = null @el = $.el 'a', className: 'qrpreview' draggable: true href: 'javascript:;' innerHTML: '×' $('input', @el).checked = @spoiler $.on @el, 'click', => @select() $.on $('.remove', @el), 'click', (e) => e.stopPropagation() @rm() $.on $('label', @el), 'click', (e) => e.stopPropagation() $.on $('input', @el), 'change', (e) => @spoiler = e.target.checked $.id('spoiler').checked = @spoiler if @el.id is 'selected' $.before $('#addReply', QR.el), @el $.on @el, 'dragstart', @dragStart $.on @el, 'dragenter', @dragEnter $.on @el, 'dragleave', @dragLeave $.on @el, 'dragover', @dragOver $.on @el, 'dragend', @dragEnd $.on @el, 'drop', @drop QR.replies.push @ setFile: (@file) -> @el.title = "#{file.name} (#{$.bytesToString file.size})" $('label', @el).hidden = false if QR.spoiler unless /^image/.test file.type @el.style.backgroundImage = null return # XXX Opera does not support window.URL return unless url = window.URL or window.webkitURL url.revokeObjectURL @url # Create a redimensioned thumbnail. fileUrl = url.createObjectURL file img = $.el 'img' $.on img, 'load', => # Generate thumbnails only if they're really big. # Resized pictures through canvases look like ass, # so we generate thumbnails `s` times bigger then expected # to avoid crappy resized quality. s = 90*3 if img.height < s or img.width < s @url = fileUrl @el.style.backgroundImage = "url(#{@url})" return if img.height <= img.width img.width = s / img.height * img.width img.height = s else img.height = s / img.width * img.height img.width = s c = $.el 'canvas' c.height = img.height c.width = img.width c.getContext('2d').drawImage img, 0, 0, img.width, img.height # Support for toBlob fucking when? data = atob c.toDataURL().split(',')[1] # DataUrl to Binary code from Aeosynth's 4chan X repo l = data.length ui8a = new Uint8Array l for i in [0...l] ui8a[i] = data.charCodeAt i @url = url.createObjectURL new Blob [ui8a], type: 'image/png' @el.style.backgroundImage = "url(#{@url})" url.revokeObjectURL? fileUrl img.src = fileUrl rmFile: -> QR.resetFileInput() delete @file @el.title = null @el.style.backgroundImage = null $('label', @el).hidden = true if QR.spoiler (window.URL or window.webkitURL).revokeObjectURL? @url select: -> QR.selected?.el.id = null QR.selected = @ @el.id = 'selected' # Scroll the list to center the focused reply. rectEl = @el.getBoundingClientRect() rectList = @el.parentNode.getBoundingClientRect() @el.parentNode.scrollLeft += rectEl.left + rectEl.width/2 - rectList.left - rectList.width/2 # Load this reply's values. for data in ['name', 'email', 'sub', 'com'] $("[name=#{data}]", QR.el).value = @[data] QR.characterCount.call $ 'textarea', QR.el $('#spoiler', QR.el).checked = @spoiler dragStart: -> $.addClass @, 'drag' dragEnter: -> $.addClass @, 'over' dragLeave: -> $.rmClass @, 'over' dragOver: (e) -> e.preventDefault() e.dataTransfer.dropEffect = 'move' drop: -> el = $ '.drag', @parentNode index = (el) -> Array::slice.call(el.parentNode.children).indexOf el oldIndex = index el newIndex = index @ if oldIndex < newIndex $.after @, el else $.before @, el reply = QR.replies.splice(oldIndex, 1)[0] QR.replies.splice newIndex, 0, reply dragEnd: -> $.rmClass @, 'drag' if el = $ '.over', @parentNode $.rmClass el, 'over' rm: -> QR.resetFileInput() $.rm @el index = QR.replies.indexOf @ if QR.replies.length is 1 new QR.reply().select() else if @el.id is 'selected' (QR.replies[index-1] or QR.replies[index+1]).select() QR.replies.splice index, 1 (window.URL or window.webkitURL)?.revokeObjectURL @url captcha: init: -> return if -1 isnt d.cookie.indexOf 'pass_enabled=' return unless @isEnabled = !!$.id 'captchaFormPart' if $.id 'recaptcha_challenge_field_holder' @ready() else @onready = => @ready() $.on $.id('recaptcha_widget_div'), 'DOMNodeInserted', @onready ready: -> if @challenge = $.id 'recaptcha_challenge_field_holder' $.off $.id('recaptcha_widget_div'), 'DOMNodeInserted', @onready delete @onready else return $.addClass QR.el, 'captcha' $.after $('.textarea', QR.el), $.el 'div', className: 'captchaimg' title: 'Reload' innerHTML: '' $.after $('.captchaimg', QR.el), $.el 'div', className: 'captchainput' innerHTML: '' @img = $ '.captchaimg > img', QR.el @input = $ '.captchainput > input', QR.el $.on @img.parentNode, 'click', @reload $.on @input, 'keydown', @keydown $.on @challenge, 'DOMNodeInserted', => @load() $.sync 'captchas', (arr) => @count arr.length @count $.get('captchas', []).length # start with an uncached captcha @reload() save: -> return unless response = @input.value captchas = $.get 'captchas', [] # Remove old captchas. while (captcha = captchas[0]) and captcha.time < Date.now() captchas.shift() captchas.push challenge: @challenge.firstChild.value response: response time: @timeout $.set 'captchas', captchas @count captchas.length @reload() load: -> # -1 minute to give upload some time. @timeout = Date.now() + $.unsafeWindow.RecaptchaState.timeout * $.SECOND - $.MINUTE challenge = @challenge.firstChild.value @img.alt = challenge @img.src = "//www.google.com/recaptcha/api/image?c=#{challenge}" @input.value = null count: (count) -> @input.placeholder = switch count when 0 'Verification (Shift + Enter to cache)' when 1 'Verification (1 cached captcha)' else "Verification (#{count} cached captchas)" @input.alt = count # For XTRM RICE. reload: (focus) -> # the 't' argument prevents the input from being focused $.unsafeWindow.Recaptcha.reload 't' # Focus if we meant to. QR.captcha.input.focus() if focus keydown: (e) -> c = QR.captcha if e.keyCode is 8 and not c.input.value c.reload() else if e.keyCode is 13 and e.shiftKey c.save() else return e.preventDefault() dialog: -> QR.el = UI.dialog 'qr', 'top:0;right:0;', """
Quick Reply ×
""" # Allow only this board's supported files. mimeTypes = $('ul.rules').firstElementChild.textContent.trim().match(/: (.+)/)[1].toLowerCase().replace /\w+/g, (type) -> switch type when 'jpg' 'image/jpeg' when 'pdf' 'application/pdf' when 'swf' 'application/x-shockwave-flash' else "image/#{type}" QR.mimeTypes = mimeTypes.split ', ' # Add empty mimeType to avoid errors with URLs selected in Window's file dialog. QR.mimeTypes.push '' fileInput = $ 'input[type=file]', QR.el fileInput.max = $('input[name=MAX_FILE_SIZE]').value fileInput.accept = mimeTypes if $.engine isnt 'presto' # Opera's accept attribute is fucked up QR.spoiler = !!$ 'input[name=spoiler]' spoiler = $ '#spoilerLabel', QR.el spoiler.hidden = !QR.spoiler QR.charaCounter = $ '#charCount', QR.el ta = $ 'textarea', QR.el span = $('.move > span', QR.el) # Make a list of visible threads. if g.BOARD.ID is 'f' if g.VIEW is 'index' QR.threadSelector = $('select[name=filetag]').cloneNode true else QR.threadSelector = $.el 'select', title: 'Create a new thread / Reply to a thread' threads = '' for key, thread of g.BOARD.threads threads += "" QR.threadSelector.innerHTML = threads if g.VIEW is 'thread' QR.threadSelector.value = g.THREAD if QR.threadSelector $.prepend span, QR.threadSelector $.on span, 'mousedown', (e) -> e.stopPropagation() $.on $('#autohide', QR.el), 'change', QR.toggleHide $.on $('.close', QR.el), 'click', QR.close $.on $('#dump', QR.el), 'click', -> QR.el.classList.toggle 'dump' $.on $('#addReply', QR.el), 'click', -> new QR.reply().select() $.on $('form', QR.el), 'submit', QR.submit $.on ta, 'input', -> QR.selected.el.lastChild.textContent = @value $.on ta, 'input', QR.characterCount $.on fileInput, 'change', QR.fileInput $.on fileInput, 'click', (e) -> if e.shiftKey then QR.selected.rmFile() or e.preventDefault() $.on spoiler.firstChild, 'change', -> $('input', QR.selected.el).click() new QR.reply().select() # save selected reply's data for name in ['name', 'email', 'sub', 'com'] # The input event replaces keyup, change and paste events. $.on $("[name=#{name}]", QR.el), 'input', -> QR.selected[@name] = @value # Disable auto-posting if you're typing in the first reply # during the last 5 seconds of the cooldown. if QR.cooldown.auto and QR.selected is QR.replies[0] and 0 < QR.cooldown.seconds <= 5 QR.cooldown.auto = false QR.status.input = $ 'input[type=submit]', QR.el QR.status() QR.cooldown.init() QR.captcha.init() $.add d.body, QR.el # Create a custom event when the QR dialog is first initialized. # Use it to extend the QR's functionalities, or for XTRM RICE. $.event new CustomEvent 'QRDialogCreation', null, QR.el submit: (e) -> e?.preventDefault() if QR.cooldown.seconds QR.cooldown.auto = !QR.cooldown.auto QR.status() return QR.abort() reply = QR.replies[0] if g.BOARD.ID is 'f' and g.VIEW is 'index' filetag = QR.threadSelector.value threadID = 'new' else threadID = QR.threadSelector.value # prevent errors if threadID is 'new' threadID = null if g.BOARD.ID in ['vg', 'q'] and !reply.sub err = 'New threads require a subject.' else unless reply.file or textOnly = !!$ 'input[name=textonly]', $.id 'postForm' err = 'No file selected.' else if g.BOARD.ID is 'f' and filetag is '9999' err = 'Invalid tag specified.' else unless reply.com or reply.file err = 'No file selected.' if QR.captcha.isEnabled and !err # get oldest valid captcha captchas = $.get 'captchas', [] # remove old captchas while (captcha = captchas[0]) and captcha.time < Date.now() captchas.shift() if captcha = captchas.shift() challenge = captcha.challenge response = captcha.response else challenge = QR.captcha.img.alt if response = QR.captcha.input.value then QR.captcha.reload() $.set 'captchas', captchas QR.captcha.count captchas.length unless response err = 'No valid captcha.' else response = response.trim() # one-word-captcha: # If there's only one word, duplicate it. response = "#{response} #{response}" unless /\s/.test response if err # stop auto-posting QR.cooldown.auto = false QR.status() QR.error err return QR.cleanNotification() # Enable auto-posting if we have stuff to post, disable it otherwise. QR.cooldown.auto = QR.replies.length > 1 if Conf['Auto Hide QR'] and not QR.cooldown.auto QR.hide() if not QR.cooldown.auto and $.x 'ancestor::div[@id="qr"]', d.activeElement # Unfocus the focused element if it is one within the QR and we're not auto-posting. d.activeElement.blur() # Starting to upload might take some time. # Provide some feedback that we're starting to submit. QR.status progress: '...' post = resto: threadID name: reply.name email: reply.email sub: reply.sub com: reply.com upfile: reply.file filetag: filetag spoiler: reply.spoiler textonly: textOnly mode: 'regist' pwd: if m = d.cookie.match(/4chan_pass=([^;]+)/) then decodeURIComponent m[1] else $('input[name=pwd]').value recaptcha_challenge_field: challenge recaptcha_response_field: response callbacks = onload: -> QR.response @response onerror: -> # Connection error, or # CORS disabled error on www.4chan.org/banned QR.cooldown.auto = false QR.status() QR.error $.el 'a', href: '//www.4chan.org/banned', target: '_blank', textContent: 'Connection error, or you are banned.' opts = form: $.formData post upCallbacks: onload: -> # Upload done, waiting for response. QR.status progress: '...' onprogress: (e) -> # Uploading... QR.status progress: "#{Math.round e.loaded / e.total * 100}%" QR.ajax = $.ajax $.id('postForm').parentNode.action, callbacks, opts response: (html) -> tmpDoc = d.implementation.createHTMLDocument '' tmpDoc.documentElement.innerHTML = html if ban = $ '.banType', tmpDoc # banned/warning board = $('.board', tmpDoc).innerHTML err = $.el 'span', innerHTML: if ban.textContent.toLowerCase() is 'banned' "You are banned on #{board}! ;_;
" + "Click here to see the reason." else "You were issued a warning on #{board} as #{$('.nameBlock', tmpDoc).innerHTML}.
" + "Reason: #{$('.reason', tmpDoc).innerHTML}" else if err = tmpDoc.getElementById 'errmsg' # error! $('a', err)?.target = '_blank' # duplicate image link else if tmpDoc.title isnt 'Post successful!' err = 'Connection error with sys.4chan.org.' if err if /captcha|verification/i.test(err.textContent) or err is 'Connection error with sys.4chan.org.' # Remove the obnoxious 4chan Pass ad. if /mistyped/i.test err.textContent err = 'Error: You seem to have mistyped the CAPTCHA.' # Enable auto-post if we have some cached captchas. QR.cooldown.auto = if QR.captcha.isEnabled !!$.get('captchas', []).length else if err is 'Connection error with sys.4chan.org.' true else # Something must've gone terribly wrong if you get captcha errors without captchas. # Don't auto-post indefinitely in that case. false # Too many frequent mistyped captchas will auto-ban you! # On connection error, the post most likely didn't go through. QR.cooldown.set delay: 2 else # stop auto-posting QR.cooldown.auto = false QR.status() QR.error err return h1 = $ 'h1', tmpDoc QR.lastNotification = new Notification 'success', h1.textContent, 5 reply = QR.replies[0] persona = $.get 'QR.persona', {} persona = name: reply.name email: if /^sage$/.test reply.email then persona.email else reply.email sub: if Conf['Remember Subject'] then reply.sub else null $.set 'QR.persona', persona [_, threadID, postID] = h1.nextSibling.textContent.match /thread:(\d+),no:(\d+)/ # Post/upload confirmed as successful. $.event new CustomEvent 'QRPostSuccessful', { threadID postID }, QR.el QR.cooldown.set post: reply isReply: threadID isnt '0' # Enable auto-posting if we have stuff to post, disable it otherwise. QR.cooldown.auto = QR.replies.length > 1 if threadID is '0' # new thread $.open "/#{g.BOARD}/res/#{postID}" else if g.VIEW is 'reply' and !QR.cooldown.auto # posting from the index $.open "//boards.4chan.org/#{g.BOARD}/res/#{threadID}#p#{postID}" if Conf['Persistent QR'] or QR.cooldown.auto reply.rm() else QR.close() QR.status() QR.resetFileInput() abort: -> QR.ajax?.abort() delete QR.ajax QR.status() 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: (post) -> a = $.el 'a', className: 'menu-button' innerHTML: '[]' href: 'javascript:;' a.setAttribute 'data-postid', post.fullID a.setAttribute 'data-clone', true if post.isClone $.on a, 'click', Menu.toggle a toggle: (e) -> post = if @dataset.clone Get.postFromRoot $.x 'ancestor::div[contains(@class,"postContainer")][1]', @ 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 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}) -> fileEl.textContent = 'File' $.on fileEl, 'click', DeleteLink.delete !!file $.event 'AddMenuEntry', type: 'post' el: div open: (post) -> return false if post.isDead DeleteLink.post = post node = div.firstChild if seconds = DeleteLink.cooldown[post.fullID] node.textContent = "Delete (#{seconds})" DeleteLink.cooldown.el = node else node.textContent = 'Delete' delete DeleteLink.cooldown.el true subEntries: [postEntry, fileEntry] $.on d, 'QRPostSuccessful', @cooldown.start delete: -> {post} = DeleteLink return if DeleteLink.cooldown[post.fullID] $.off @, 'click', DeleteLink.delete @textContent = "Deleting #{@textContent}..." pwd = if m = d.cookie.match /4chan_pass=([^;]+)/ decodeURIComponent m[1] else $.id('delPassword').value form = mode: 'usrdel' onlyimgdel: $.hasClass @, 'delete-file' pwd: pwd form[post.ID] = 'delete' link = @ $.ajax $.id('delform').action.replace("/#{g.BOARD}/", "/#{post.board}/"), { onload: -> DeleteLink.load link, @response onerror: -> DeleteLink.error link }, { form: $.formData form } load: (link, 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 s = 'Deleted' link.textContent = s error: (link) -> link.textContent = 'Connection error, please retry.' $.on link, 'click', DeleteLink.delete cooldown: start: (e) -> seconds = if g.BOARD.ID is 'q' 600 else 30 fullID = "#{g.BOARD}.#{e.detail.postID}" DeleteLink.cooldown.count fullID, seconds, seconds count: (fullID, seconds, length) -> return unless 0 <= seconds <= length setTimeout DeleteLink.cooldown.count, 1000, fullID, seconds-1, length {el} = DeleteLink.cooldown if seconds is 0 el?.textContent = 'Delete' delete DeleteLink.cooldown[fullID] delete DeleteLink.cooldown.el return el?.textContent = "Delete (#{seconds})" DeleteLink.cooldown[fullID] = seconds DownloadLink = init: -> return if g.VIEW is 'catalog' or !Conf['Menu'] or !Conf['Download Link'] # Test for download feature support. return if $.el('a').download is undefined a = $.el 'a', className: 'download-link' textContent: 'Download file' $.event 'AddMenuEntry', type: 'post' el: a 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 open: ({ID: postID, thread: threadID, board}) -> redirect = Redirect.to {postID, threadID, board} 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' if type is 'post' open = ({ID: postID, thread: threadID, board}) -> el.href = Redirect.to {postID, threadID, board} true else open = (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 board: post.board type: type value: value isSearch: true true return { el: el open: open } Redirect = image: (board, filename) -> # XXX need to differentiate between thumbnail only and full_image for img src= # Do not use g.BOARD, the image url can originate from a cross-quote. switch "#{board}" when 'a', 'co', 'jp', 'm', 'q', 'sp', 'tg', 'tv', 'v', 'vg', 'wsg' "//archive.foolz.us/#{board}/full_image/#{filename}" when 'u' "//nsfw.foolz.us/#{board}/full_image/#{filename}" when 'po' "http://archive.thedarkcave.org/#{board}/full_image/#{filename}" when 'ck', 'lit' "//fuuka.warosu.org/#{board}/full_image/#{filename}" when 'diy', 'sci' "//archive.installgentoo.net/#{board}/full_image/#{filename}" when 'cgl', 'g', 'mu', 'w' "//rbt.asia/#{board}/full_image/#{filename}" when 'an', 'fit', 'k', 'mlp', 'r9k', 'toy', 'x' "http://archive.heinessen.com/#{board}/full_image/#{filename}" when 'c' "//archive.nyafuu.org/#{board}/full_image/#{filename}" post: (board, postID) -> switch "#{board}" when 'a', 'co', 'jp', 'm', 'q', 'sp', 'tg', 'tv', 'v', 'vg', 'wsg', 'dev', 'foolz' "//archive.foolz.us/_/api/chan/post/?board=#{board}&num=#{postID}" when 'u', 'kuku' "//nsfw.foolz.us/_/api/chan/post/?board=#{board}&num=#{postID}" when 'po' "http://archive.thedarkcave.org/_/api/chan/post/?board=#{board}&num=#{postID}" # for fuuka-based archives: # https://github.com/eksopl/fuuka/issues/27 to: (data) -> {board} = data switch "#{board}" when 'a', 'co', 'jp', 'm', 'q', 'sp', 'tg', 'tv', 'v', 'vg', 'wsg', 'dev', 'foolz' url = Redirect.path '//archive.foolz.us', 'foolfuuka', data when 'u', 'kuku' url = Redirect.path '//nsfw.foolz.us', 'foolfuuka', data when 'po' url = Redirect.path 'http://archive.thedarkcave.org', 'foolfuuka', data when 'ck', 'lit' url = Redirect.path '//fuuka.warosu.org', 'fuuka', data when 'diy', 'sci' url = Redirect.path '//archive.installgentoo.net', 'fuuka', data when 'cgl', 'g', 'mu', 'w' url = Redirect.path '//rbt.asia', 'fuuka', data when 'an', 'fit', 'k', 'mlp', 'r9k', 'toy', 'x' url = Redirect.path 'http://archive.heinessen.com', 'fuuka', data when 'c' url = Redirect.path '//archive.nyafuu.org', 'fuuka', data else if data.threadID url = "//boards.4chan.org/#{board}/" url or '' path: (base, archiver, data) -> if data.isSearch {board, 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}/#{board}/search/#{type}/#{value}" else if type is 'image' "#{base}/#{board}/?task=search2&search_media_hash=#{value}" else "#{base}/#{board}/?task=search2&search_#{type}=#{value}" {board, threadID, postID} = data # keep the number only if the location.hash was sent f.e. postID = postID.match(/\d+/)[0] if postID and typeof postID is 'string' path = if threadID "#{board}/thread/#{threadID}" else "#{board}/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, board) -> o = # id postID: data.no threadID: data.resto or data.no board: board # 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/#{board}/src/#{data.tim}#{data.ext}" height: data.h width: data.w MD5: data.md5 size: data.fsize turl: "//thumbs.4chan.org/#{board}/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, board 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 = if subject "#{subject}" else '' 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[board] # Randomize the spoiler image. fileThumb += "-#{board}" + Math.floor 1 + spoilerRange * Math.random() fileThumb += '.png' file.twidth = file.theight = 100 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 "
>>
") + "
" + "
" + "" + "#{name or ''}" + tripcode + capcodeStart + capcode + userID + flag + sticky + closed + "
#{subject}" + "
#{date}" + '
' + "No." + "#{postID}" + '
' + '
' + (if isOP then fileHTML else '') + "
" + " " + "#{subject} " + "" + emailStart + "#{name or ''}" + tripcode + capcodeStart + emailEnd + capcode + userID + flag + sticky + closed + ' ' + "#{date} " + "" + "No." + "#{postID}" + '' + '
' + (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 = "/#{board}/res/#{href}" # Fix pathnames container Get = postFromRoot: (root) -> link = $ 'a[title="Highlight this post"]', root board = link.pathname.split('/')[1] postID = link.hash[2..] index = root.dataset.clone post = g.posts["#{board}.#{postID}"] if index then post.clones[index] else post postDataFromLink: (link) -> if link.hostname is 'boards.4chan.org' path = link.pathname.split '/' board = path[1] threadID = path[3] postID = link.hash[2..] else # resurrected quote board = link.dataset.board threadID = link.dataset.threadid or 0 postID = link.dataset.postid return { board: board 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 -1 isnt quoterPost.quotes.indexOf post.fullID 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, Array::slice.call quotedPost.nodes.backlinks # Third: # Filter out irrelevant quotelinks. quotelinks.filter (quotelink) -> {board, postID} = Get.postDataFromLink quotelink board is post.board.ID and postID is post.ID contextFromLink: (quotelink) -> Get.postFromRoot $.x 'ancestor::div[parent::div[@class="thread"]][1]', quotelink postClone: (board, threadID, postID, root, context) -> if post = g.posts["#{board}.#{postID}"] Get.insert post, root, context return root.textContent = "Loading post No.#{postID}..." if threadID $.cache "//api.4chan.org/#{board}/res/#{threadID}.json", -> Get.fetchedPost @, board, threadID, postID, root, context else if url = Redirect.post board, postID $.cache url, -> Get.archivedPost @, board, 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 nodes.root.innerHTML = null $.add nodes.root, nodes.post root.innerHTML = null $.add root, nodes.root fetchedPost: (req, board, 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["#{board}.#{postID}"] Get.insert post, root, context return {status} = req if status isnt 200 # The thread can die by the time we check a quote. if url = Redirect.post board, postID $.cache url, -> Get.archivedPost @, board, postID, root, context else $.addClass root, 'warning' root.textContent = if status is 404 "Thread No.#{threadID} 404'd." else "Error #{req.status}: #{req.statusText}." return posts = JSON.parse(req.response).posts Build.spoilerRange[board] = 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 board, postID $.cache url, -> Get.archivedPost @, board, postID, root, context else $.addClass root, 'warning' root.textContent = "Post No.#{postID} was not found." return board = g.boards[board] or new Board board thread = g.threads["#{board}.#{threadID}"] or new Thread threadID, board post = new Post Build.postFromObject(post, board), thread, board Main.callbackNodes Post, [post] Get.insert post, root, context archivedPost: (req, board, 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["#{board}.#{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}" board: board # 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/#{board}/thumb/#{data.media.preview_orig}" theight: data.media.preview_h twidth: data.media.preview_w isSpoiler: data.media.spoiler is '1' board = g.boards[board] or new Board board thread = g.threads["#{board}.#{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: -> return if @isClone for deadlink in $$ '.deadlink', @nodes.comment if deadlink.parentNode.className is 'prettyprint' # Don't quotify deadlinks inside code tags, # un-`span` them. $.replace deadlink, Array::slice.call deadlink.childNodes continue quote = deadlink.textContent continue unless ID = quote.match(/\d+$/)?[0] board = if m = quote.match /^>>>\/([a-z\d]+)/ m[1] else @board.ID quoteID = "#{board}.#{ID}" # \u00A0 is nbsp 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: "/#{board}/#{post.thread}/res/#p#{ID}" className: 'quotelink' textContent: quote else if redirect = Redirect.to {board: board, threadID: post.thread.ID, postID: ID} # Replace the .deadlink span if we can redirect. a = $.el 'a', href: redirect className: 'quotelink deadlink' target: '_blank' textContent: "#{quote}\u00A0(Dead)" a.setAttribute 'data-board', board a.setAttribute 'data-threadid', post.thread.ID a.setAttribute 'data-postid', ID else if redirect = Redirect.to {board: board, threadID: 0, postID: ID} # Replace the .deadlink span if we can redirect. a = $.el 'a', href: redirect className: 'deadlink' target: '_blank' textContent: "#{quote}\u00A0(Dead)" if Redirect.post board, ID # Make it function as a normal quote if we can fetch the post. $.addClass a, 'quotelink' a.setAttribute 'data-board', board a.setAttribute 'data-postid', ID unless quoteID in @quotes @quotes.push quoteID unless a deadlink.textContent += "\u00A0(Dead)" continue $.replace deadlink, a if $.hasClass a, 'quotelink' @nodes.quotelinks.push a return QuoteInline = init: -> return if g.VIEW is 'catalog' or !Conf['Quote Inline'] Post::callbacks.push name: 'Quote Inline' cb: @node node: -> for link in @nodes.quotelinks $.on link, 'click', QuoteInline.toggle for link in @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() {board, threadID, postID} = Get.postDataFromLink @ context = Get.contextFromLink @ if $.hasClass @, 'inlined' QuoteInline.rm @, board, threadID, postID, context else return if $.x "ancestor::div[@id='p#{postID}']", @ QuoteInline.add @, board, 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, board, threadID, postID, context) -> isBacklink = $.hasClass quotelink, 'backlink' inline = $.el 'div', id: "i#{postID}" className: 'inline' $.after QuoteInline.findRoot(quotelink, isBacklink), inline Get.postClone board, threadID, postID, inline, context return unless (post = g.posts["#{board}.#{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 reply. # XXX # if (i = Unread.replies.indexOf el) isnt -1 # Unread.replies.splice i, 1 # Unread.update true rm: (quotelink, board, 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["#{board}.#{postID}"] post.rmClone el.dataset.clone # Decrease forward count and unhide. if Conf['Forward Hiding'] and isBacklink and context.thread is g.threads["#{board}.#{threadID}"] and not --post.forwarded delete post.forwarded $.rmClass post.nodes.root, 'forwarded' # Repeat. while inlined = $ '.inlined', el {board, threadID, postID} = Get.postDataFromLink inlined QuoteInline.rm inlined, board, threadID, postID, context $.rmClass inlined, 'inlined' return QuotePreview = init: -> return if g.VIEW is 'catalog' or !Conf['Quote Preview'] Post::callbacks.push name: 'Quote Preview' cb: @node node: -> for link in @nodes.quotelinks $.on link, 'mouseover', QuotePreview.mouseover for link in @nodes.backlinks $.on link, 'mouseover', QuotePreview.mouseover return mouseover: (e) -> return if $.hasClass @, 'inlined' {board, threadID, postID} = Get.postDataFromLink @ qp = $.el 'div', id: 'qp' className: 'dialog' $.add d.body, qp Get.postClone board, threadID, postID, qp, Get.contextFromLink @ UI.hover @, qp, 'mouseout click', QuotePreview.mouseout return unless origin = g.posts["#{board}.#{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 if quote.hash[2..] is quoterID $.addClass quote, 'forwardlink' for quote in 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] for clone in post.clones containers.push clone.nodes.backlinkContainer for container in containers link = a.cloneNode true if Conf['Quote Preview'] $.on link, 'mouseover', QuotePreview.mouseover if Conf['Quote Inline'] $.on link, 'click', QuoteInline.toggle $.add container, [$.tn(' '), link] return secondNode: -> if @isClone and @origin.nodes.backlinkContainer @nodes.backlinkContainer = $ '.container', @nodes.info return # Don't backlink the OP. return unless Conf['OP Backlinks'] or @isReply container = QuoteBacklink.getContainer @fullID @nodes.backlinkContainer = container $.add @nodes.info, container getContainer: (id) -> @containers[id] or= $.el 'span', className: 'container' 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.quotelinks # rm (OP) from cross-thread quotes. if @isClone and -1 < quotes.indexOf @fullID for quote in quotelinks quote.textContent = quote.textContent.replace QuoteOP.text, '' op = (if @isClone then @context else @).thread.fullID # add (OP) to quotes quoting this context's OP. return unless -1 < quotes.indexOf op for quote in quotelinks {board, postID} = Get.postDataFromLink quote if "#{board}.#{postID}" is op $.add quote, $.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.quotelinks {board, thread} = if @isClone then @context else @ for quote in quotelinks data = Get.postDataFromLink quote continue unless data.threadID # deadlink if @isClone quote.textContent = quote.textContent.replace QuoteCT.text, '' if data.board is @board.ID and data.threadID isnt thread.ID $.add quote, $.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 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 '#' # XXX .trim() is there to fix Opera reading two different line breaks. 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' "' + post.file.thumbURL + '" when '%url' "' + post.file.URL + '" when '%md5' "' + encodeURIComponent(post.file.MD5) + '" when '%board' "' + 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 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: 'Auto-GIF' cb: @node node: -> return unless @file?.isImage $.on @file.thumb, 'mouseover', ImageHover.mouseover mouseover: -> el = $.el 'img' id: 'ihover' src: @parentNode.href $.add d.body, el $.on el, 'load', => ImageHover.load @, el $.on el, 'error', ImageHover.error UI.hover @, el, 'mouseout' load: (root, el) -> return unless el.parentNode # 'Fake' mousemove event by giving required values. {style} = el e = new Event 'mousemove' e.clientX = - 45 + parseInt style.left e.clientY = 120 + parseInt style.top root.dispatchEvent e error: -> return unless @parentNode src = @src.split '/' unless src[2] is 'images.4chan.org' and url = Redirect.image src[3], src[5] return if g.DEAD url = "//images.4chan.org/#{src[3]}/src/#{src[5]}" return if $.engine isnt 'webkit' and url.split('/')[2] is 'images.4chan.org' timeoutID = setTimeout (=> @src = url), 3000 # Only Chrome let userscripts do cross domain requests. # Don't check for 404'd status in the archivers. return if $.engine isnt 'webkit' or url.split('/')[2] isnt 'images.4chan.org' $.ajax url, onreadystatechange: (-> clearTimeout timeoutID if @status is 404), type: 'head' ThreadUpdater = init: -> return if g.VIEW isnt 'thread' or !Conf['Thread Updater'] Thread::callbacks.push name: 'Thread Updater' cb: @node node: -> new ThreadUpdater.Updater @ ### http://freesound.org/people/pierrecartoons1979/sounds/90112/ cc-by-nc-3.0 ### beep: 'data:audio/wav;base64,UklGRjQDAABXQVZFZm10IBAAAAABAAEAgD4AAIA+AAABAAgAc21wbDwAAABBAAADAAAAAAAAAAA8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABkYXRhzAIAAGMms8em0tleMV4zIpLVo8nhfSlcPR102Ki+5JspVEkdVtKzs+K1NEhUIT7DwKrcy0g6WygsrM2k1NpiLl0zIY/WpMrjgCdbPhxw2Kq+5Z4qUkkdU9K1s+K5NkVTITzBwqnczko3WikrqM+l1NxlLF0zIIvXpsnjgydZPhxs2ay95aIrUEkdUdC3suK8N0NUIjq+xKrcz002WioppdGm091pK1w0IIjYp8jkhydXPxxq2K295aUrTkoeTs65suK+OUFUIzi7xqrb0VA0WSoootKm0t5tKlo1H4TYqMfkiydWQBxm16+85actTEseS8y7seHAPD9TIza5yKra01QyWSson9On0d5wKVk2H4DYqcfkjidUQB1j1rG75KsvSkseScu8seDCPz1TJDW2yara1FYxWSwnm9Sn0N9zKVg2H33ZqsXkkihSQR1g1bK65K0wSEsfR8i+seDEQTxUJTOzy6rY1VowWC0mmNWoz993KVc3H3rYq8TklSlRQh1d1LS647AyR0wgRMbAsN/GRDpTJTKwzKrX1l4vVy4lldWpzt97KVY4IXbUr8LZljVPRCxhw7W3z6ZISkw1VK+4sMWvXEhSPk6buay9sm5JVkZNiLWqtrJ+TldNTnquqbCwilZXU1BwpKirrpNgWFhTaZmnpquZbFlbVmWOpaOonHZcXlljhaGhpZ1+YWBdYn2cn6GdhmdhYGN3lp2enIttY2Jjco+bnJuOdGZlZXCImJqakHpoZ2Zug5WYmZJ/bGlobX6RlpeSg3BqaW16jZSVkoZ0bGtteImSk5KIeG5tbnaFkJKRinxxbm91gY2QkIt/c3BwdH6Kj4+LgnZxcXR8iI2OjIR5c3J0e4WLjYuFe3VzdHmCioyLhn52dHR5gIiKioeAeHV1eH+GiYqHgXp2dnh9hIiJh4J8eHd4fIKHiIeDfXl4eHyBhoeHhH96eHmA' Updater: class constructor: (@thread) -> html = '
' for name, val of Config.updater.checkbox title = val[1] checked = if Conf[name] then 'checked' else '' html += "
" checked = if Conf['Auto Update'] then 'checked' else '' html += """
""" dialog = UI.dialog 'updater', 'bottom: 0; right: 0;', html @timer = $ '#timer', dialog @status = $ '#status', dialog @unsuccessfulFetchCount = 0 @lastModified = '0' @threadRoot = thread.posts[thread].nodes.root.parentNode @lastPost = +@threadRoot.lastElementChild.id[2..] for input in $$ 'input', dialog if input.type is 'checkbox' $.on input, 'click', @cb.checkbox.bind @ input.dispatchEvent new Event 'click' switch input.name when 'Scroll BG' $.on input, 'click', @cb.scrollBG.bind @ @cb.scrollBG.call @ when 'Auto Update This' $.on input, 'click', @cb.autoUpdate.bind @ when 'Interval' $.on input, 'change', @cb.interval.bind @ input.dispatchEvent new Event 'change' when 'Update Now' $.on input, 'click', @update.bind @ $.on window, 'online offline', @cb.online.bind @ $.on d, 'QRPostSuccessful', @cb.post.bind @ $.on d, 'visibilitychange ovisibilitychange mozvisibilitychange webkitvisibilitychange', @cb.visibility.bind @ @cb.online.call @ $.add d.body, dialog cb: online: -> if @online = navigator.onLine @unsuccessfulFetchCount = 0 @set 'timer', @getInterval() @update() if Conf['Auto Update This'] @set 'status', null @status.className = null else @status.className = 'warning' @set 'status', 'Offline' @set 'timer', null @cb.autoUpdate.call @ post: (e) -> return unless @['Auto Update This'] and +e.detail.threadID is @thread.ID @unsuccessfulFetchCount = 0 setTimeout @update.bind(@), 1000 if @seconds > 2 visibility: -> return if $.hidden() # Reset the counter when we focus this tab. @unsuccessfulFetchCount = 0 if @seconds > @interval @set 'timer', @getInterval() checkbox: (e) -> input = e.target {checked, name} = input @[name] = checked $.cb.checked.call input scrollBG: -> @scrollBG = if @['Scroll BG'] -> true else -> not $.hidden() autoUpdate: -> if @['Auto Update This'] and @online @timeoutID = setTimeout @timeout.bind(@), 1000 else clearTimeout @timeoutID interval: (e) -> input = e.target val = Math.max 5, parseInt input.value, 10 @interval = input.value = val $.cb.value.call input load: -> switch @req.status when 404 @set 'timer', null @set 'status', '404' @status.className = 'warning' clearTimeout @timeoutID @thread.isDead = true # if Conf['Unread Count'] # Unread.title = Unread.title.match(/^.+-/)[0] + ' 404' # else # d.title = d.title.match(/^.+-/)[0] + ' 404' # Unread.update true # QR.abort() # XXX 304 -> 0 in Opera when 0, 304 ### 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. ### @unsuccessfulFetchCount++ @set 'timer', @getInterval() @set 'status', null @status.className = null when 200 @parse JSON.parse(@req.response).posts @lastModified = @req.getResponseHeader 'Last-Modified' @set 'timer', @getInterval() else @unsuccessfulFetchCount++ @set 'timer', @getInterval() @set 'status', "#{@req.statusText} (#{@req.status})" @status.className = 'warning' delete @req getInterval: -> i = @interval j = Math.min @unsuccessfulFetchCount, 10 unless $.hidden() # Lower the max refresh rate limit on visible tabs. j = Math.min j, 7 @seconds = Math.max i, [0, 5, 10, 15, 20, 30, 60, 90, 120, 240, 300][j] set: (name, text) -> el = @[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 timeout: -> @timeoutID = setTimeout @timeout.bind(@), 1000 unless n = --@seconds @update() else if n <= -60 @set 'status', 'Retrying' @status.className = null @update() else if n > 0 @set 'timer', n update: -> return unless @online @seconds = 0 @set 'timer', '...' if @req # abort() triggers onloadend, we don't want that. @req.onloadend = null @req.abort() url = "//api.4chan.org/#{@thread.board}/res/#{@thread}.json" @req = $.ajax url, onloadend: @cb.load.bind @, headers: 'If-Modified-Since': @lastModified parse: (postObjects) -> Build.spoilerRange[@thread.board] = postObjects[0].custom_spoiler nodes = [] # post container elements posts = [] # post objects index = [] # existing posts image = [] # existing images count = 0 # new posts count # Build the index, create posts. for postObject in postObjects num = postObject.no index.push num image.push num if postObject.ext continue if num <= @lastPost # Insert new posts, not older ones. count++ node = Build.postFromObject postObject, @thread.board.ID nodes.push node posts.push new Post node, @thread, @thread.board # Check for deleted posts and deleted images. for i, post of @thread.posts continue if post.isDead {ID} = post if -1 is index.indexOf ID post.kill() else if post.file and !post.file.isDead and -1 is image.indexOf ID post.kill true if count if Conf['Beep'] and $.hidden() and (Unread.replies.length is 0) unless @audio @audio = $.el 'audio', src: ThreadUpdater.beep audio.play() @set 'status', "+#{count}" @status.className = 'new' @unsuccessfulFetchCount = 0 else @set 'status', null @status.className = null @unsuccessfulFetchCount++ return @lastPost = posts[count - 1].ID Main.callbackNodes Post, posts scroll = @['Auto Scroll'] and @scrollBG() and @threadRoot.getBoundingClientRect().bottom - doc.clientHeight < 25 $.add @threadRoot, nodes if scroll nodes[0].scrollIntoView()