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] catalogToggler = $.el 'label', innerHTML: " Use catalog links" $.on catalogToggler.firstElementChild, 'change', @toggleCatalogLinks $.event 'AddMenuEntry', type: 'header' el: catalogToggler order: 105 $.asap (-> d.body), -> if $ 'link[href*="favicon-status.ico"]', d.head # 404 error page or similar. return $.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 toggleCatalogLinks: -> useCatalog = @checked root = $ '.board-list', Header.headerEl as = $$ 'a[href*="boards.4chan.org"]', root as.push $ '.board-name', Header.headerEl for a in as a.pathname = "/#{a.pathname.split('/')[1]}/#{if useCatalog then 'catalog' else ''}" return 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: '<%= meta.name %> Settings' href: 'javascript:;' $.on link, 'click', Settings.open $.event 'AddMenuEntry', type: 'header' el: link order: 110 # 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: 111 open: -> Conf['Enable 4chan\'s extension'] 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: -> $.event 'CloseMenu' # Here be settings Fourchan = init: -> return if g.VIEW is 'catalog' board = g.BOARD.ID if board is 'g' Post::callbacks.push name: 'Parse /g/ code' cb: @code if board is 'sci' Post::callbacks.push name: 'Parse /sci/ math' cb: @math code: -> return if @isClone for pre in $$ '.prettyprint', @nodes.comment pre.innerHTML = $.unsafeWindow.prettyPrintOne pre.innerHTML return math: -> return if @isClone or !$ '.math', @nodes.comment # https://github.com/MayhemYDG/4chan-x/issues/645#issuecomment-13704562 {jsMath} = $.unsafeWindow if jsMath if jsMath.loaded # process one post jsMath.ProcessBeforeShowing @nodes.post else # load jsMath and process whole document # Yes this requires to be globalEval'd, don't ask me why. $.globalEval """ jsMath.Autoload.Script.Push('ProcessBeforeShowing', [null]); jsMath.Autoload.LoadJsMath(); """ parseThread: (threadID, offset, limit) -> # Fix /sci/ # Fix /g/ $.event '4chanParsingDone', threadId: threadID offset: offset limit: limit 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 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}$/" 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 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:;' $.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 order: 20 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 $.event 'CloseMenu' 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 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.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}) -> fileEl.textContent = 'File' $.on fileEl, 'click', DeleteLink.delete !!file $.event 'AddMenuEntry', type: 'post' el: div order: 40 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 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: 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 } Keybinds = init: -> return if g.VIEW is 'catalog' or !Conf['Keybinds'] $.on d, 'keydown', Keybinds.keydown $.on d, '4chanXInitFinished', -> for node in $$ '[accesskey]' node.removeAttribute 'accesskey' keydown: (e) -> return unless key = Keybinds.keyCode e {target} = e if target.nodeName in ['INPUT', 'TEXTAREA'] return unless (key is 'Esc') or (/(Alt|Ctrl|Meta)\+/.test key) threadRoot = Nav.getThread() thread = Get.postFromNode($('.op', threadRoot)).thread switch key # QR & Options when Conf['Open empty QR'] Keybinds.qr threadRoot when Conf['Open QR'] Keybinds.qr threadRoot, true when Conf['Open options'] Settings.open() when Conf['Close'] if $.id 'settings' Options.close() else if (notifications = $$ '.notification').length for notification in notifications $('.close', notification).click() else if QR.el 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['Math tags'] return if target.nodeName isnt 'TEXTAREA' Keybinds.tags 'math', target when Conf['Submit QR'] QR.submit() if QR.el 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 else return e.preventDefault() 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'] QR.open() if quote QR.quote.call $ 'input', $('.post.highlight', thread) or thread $('textarea', QR.el).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 input = ImageExpand.expandAllInput input.checked = !input.checked ImageExpand.cb.all.call input else post = Get.postFromNode $('.post.highlight', thread) or $ '.op', thread ImageExpand.toggle post open: (thread, tab) -> return if g.VIEW isnt 'index' url = "//boards.4chan.org/#{thread.board}/res/#{thread}" if tab $.open url else location.href = url hl: (delta, thread) -> headRect = $.id('header-bar').getBoundingClientRect() topMargin = headRect.top + headRect.height if postEl = $ '.reply.highlight', thread $.rmClass postEl, 'highlight' rect = postEl.getBoundingClientRect() if rect.bottom >= topMargin and rect.top <= d.documentElement.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 > d.documentElement.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 <= d.documentElement.clientHeight @focus reply return focus: (post) -> $.addClass post, 'highlight' $('a[title="Highlight this post"]', post).focus() Nav = init: -> return if g.VIEW isnt 'index' or !Conf['Index Navigation'] 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] $.on d, '4chanXInitFinished', -> $.add d.body, span prev: -> Nav.scroll -1 next: -> Nav.scroll +1 getThread: (full) -> headRect = $.id('header-bar').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: (board, filename) -> # 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' "//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' "//archive.foolz.us/_/api/chan/post/?board=#{board}&num=#{postID}" when 'u' "//nsfw.foolz.us/_/api/chan/post/?board=#{board}&num=#{postID}" when 'c', 'int', 'po' "//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 'int', 'po' url = Redirect.path '//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 = "#{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[board] # Randomize the spoiler image. fileThumb += "-#{board}" + Math.floor 1 + spoilerRange * Math.random() fileThumb += '.png' file.twidth = file.theight = 100 if board 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 "
>>
") + "
" + "
" + "" + "#{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 = threadExcerpt: (thread) -> op = thread.posts[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() "/#{thread.board}/ - #{excerpt}" 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 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 '/' 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 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.statusText} (#{req.status})." 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 posts. if Unread.posts and (i = Unread.posts.indexOf post) isnt -1 Unread.posts.splice i, 1 Unread.update() 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 root: @ el: qp latestEvent: e endEvents: 'mouseout click' cb: QuotePreview.mouseout asapTest: -> qp.firstElementChild 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]) 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 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.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' 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 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 diff % $.SECOND else if diff < $.HOUR diff % $.MINUTE else if diff < $.DAY diff % $.HOUR else diff % $.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.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' "' + 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 ImageExpand = init: -> return if g.VIEW is 'catalog' or !Conf['Image Expansion'] Post::callbacks.push name: 'Image Expansion' cb: @node node: -> return unless @file and @file.isImage $.on @file.thumb.parentNode, 'click', ImageExpand.cb.toggle 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 @ all: -> $.event 'CloseMenu' ImageExpand.on = @checked posts = [] 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 posts.push post func = if ImageExpand.on then ImageExpand.expand else ImageExpand.contract for post in posts 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, 'style' ImageExpand.resize() else $.off window, 'resize', ImageExpand.resize toggle: (post) -> {thumb} = post.file unless thumb.hidden ImageExpand.expand post return rect = thumb.parentNode.getBoundingClientRect() if rect.bottom > 0 # Should be at least partially visible. # Scroll back to the thumbnail when contracting the image # to avoid being left miles away from the relevant post. postRect = post.nodes.root.getBoundingClientRect() headRect = $.id('header-bar').getBoundingClientRect() top = postRect.top - headRect.top - headRect.height - 2 if $.engine is 'webkit' d.body.scrollTop += top if rect.top < 0 d.body.scrollLeft = 0 if rect.left < 0 else d.documentElement.scrollTop += top if rect.top < 0 d.documentElement.scrollLeft = 0 if rect.left < 0 ImageExpand.contract post contract: (post) -> {thumb} = post.file thumb.hidden = false if img = $ '.full-image', thumb.parentNode img.hidden = true $.rmClass post.nodes.root, 'expanded-image' expand: (post) -> # Do not expand images of hidden/filtered replies, or already expanded pictures. {thumb} = post.file return if post.isHidden or thumb.hidden thumb.hidden = true $.addClass post.nodes.root, 'expanded-image' if img = $ '.full-image', thumb.parentNode # Expand already loaded picture. img.hidden = false return img = $.el 'img', className: 'full-image' src: post.file.URL $.on img, 'error', ImageExpand.error $.after thumb, img error: -> post = Get.postFromNode @ $.rm @ ImageExpand.contract post if @hidden # Don't try to re-expend if it was already contracted. return src = @src.split '/' unless src[2] is 'images.4chan.org' and URL = Redirect.image src[3], src[5] return if g.DEAD {URL} = post.file return if $.engine isnt 'webkit' and URL.split('/')[2] is 'images.4chan.org' timeoutID = setTimeout ImageExpand.expand, 10000, post # 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' menu: init: -> return if g.VIEW is 'catalog' or !Conf['Image Expansion'] el = $.el 'span', textContent: 'Image Expansion' {createSubEntry} = ImageExpand.menu subEntries = [] subEntries.push createSubEntry 'Expand all' for key, conf of Config.imageExpansion subEntries.push createSubEntry key, conf $.event 'AddMenuEntry', type: 'header' el: el order: 20 subEntries: subEntries createSubEntry: (type, config) -> label = $.el 'label', innerHTML: " #{type}" input = label.firstElementChild switch type when 'Expand all' $.on input, 'change', ImageExpand.cb.all ImageExpand.expandAllInput = input when '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: 'Auto-GIF' cb: @node node: -> return unless @file?.isImage $.on @file.thumb, 'mouseover', ImageHover.mouseover mouseover: (e) -> el = $.el 'img' id: 'ihover' src: @parentNode.href $.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 @ src = @src.split '/' unless src[2] is 'images.4chan.org' and URL = Redirect.image src[3], src[5] return if g.DEAD {URL} = post.file 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' 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 $.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 = 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) -> if req.status isnt 200 a.textContent = "Error #{req.statusText} (#{req.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 Preview'] QuotePreview.node.call post if Conf['Quote Inline'] 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: -> op = @posts[@] 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.posts[thread].nodes.root.parentNode url = "//api.4chan.org/#{thread.board}/res/#{thread}.json" a = $ '.summary', threadRoot text = a.textContent switch text[0] when '+' a.textContent = text.replace '+', '× Loading...' $.cache url, -> ExpandThread.parse @, thread, a ExpandComment.expand thread.posts[thread] when '×' a.textContent = text.replace '× Loading...', '+' when '-' a.textContent = text.replace '-', '+' ExpandComment.contract thread.posts[thread] #goddamit moot num = switch g.BOARD # 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 Inline'] # rm clones inlined.click() while inlined = $ '.inlined', reply $.rm reply return parse: (req, thread, a) -> return if a.textContent[0] is '+' if req.status isnt 200 a.textContent = "Error #{req.statusText} (#{req.status})" $.off a, 'click', ExpandThread.cb.toggle return 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'] $.unsafeWindow.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'] $.on d, 'ThreadUpdate', @onUpdate $.on d, 'QRPostSuccessful', @post $.on d, 'scroll visibilitychange', @read Thread::callbacks.push name: 'Unread' cb: @node node: -> Unread.yourPosts = [] Unread.posts = [] Unread.title = d.title posts = [] for ID, post of @posts posts.push post if post.isReply Unread.addPosts posts Unread.update() addPosts: (newPosts) -> unless d.hidden height = doc.clientHeight for post in newPosts if (index = Unread.yourPosts.indexOf post.ID) isnt -1 Unread.yourPosts.splice index, 1 else if !post.isHidden and (d.hidden or post.nodes.root.getBoundingClientRect().bottom > height) Unread.posts.push post return onUpdate: (e) -> unless e.detail[404] Unread.addPosts e.detail.newPosts Unread.update() post: (e) -> Unread.yourPosts.push +e.detail.postID read: -> 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.posts = Unread.posts[i..] Unread.update() update: -> count = Unread.posts.length if Conf['Unread Count'] d.title = "(#{Unread.posts.length}) #{Unread.title}" return unless Conf['Unread Tab Icon'] Favicon.el.href = if g.DEAD if count Favicon.unreadDead else Favicon.dead else if count Favicon.unread else Favicon.default # `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.unreadSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/ferongr/unreadSFW.gif", {encoding: "base64"}) %>' Favicon.unreadNSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/ferongr/unreadNSFW.gif", {encoding: "base64"}) %>' when 'xat-' Favicon.unreadDead = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadDead.png", {encoding: "base64"}) %>' Favicon.unreadSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadSFW.png", {encoding: "base64"}) %>' Favicon.unreadNSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/xat-/unreadNSFW.png", {encoding: "base64"}) %>' when 'Mayhem' Favicon.unreadDead = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadDead.png", {encoding: "base64"}) %>' Favicon.unreadSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadSFW.png", {encoding: "base64"}) %>' Favicon.unreadNSFW = 'data:image/png;base64,<%= grunt.file.read("img/favicons/Mayhem/unreadNSFW.png", {encoding: "base64"}) %>' when 'Original' Favicon.unreadDead = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/Original/unreadDead.gif", {encoding: "base64"}) %>' Favicon.unreadSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/Original/unreadSFW.gif", {encoding: "base64"}) %>' Favicon.unreadNSFW = 'data:image/gif;base64,<%= grunt.file.read("img/favicons/Original/unreadNSFW.gif", {encoding: "base64"}) %>' Favicon.unread = if Favicon.SFW then Favicon.unreadSFW else Favicon.unreadNSFW 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
""" @postCount = @fileCount = 0 @postCountEl = $ '#post-count', @dialog @fileCountEl = $ '#file-count', @dialog @fileLimit = # XXX boards config, need up to date data on this, check browser switch g.BOARD.ID when 'a', 'b', 'v', 'co', 'mlp' 251 when 'vg' 376 else 151 Thread::callbacks.push name: 'Thread Stats' cb: @node node: -> for ID, post of @posts ThreadStats.postCount++ ThreadStats.fileCount++ if post.file ThreadStats.update() $.on d, 'ThreadUpdate', ThreadStats.onUpdate $.add d.body, ThreadStats.dialog onUpdate: (e) -> for post in e.detail.newPosts ThreadStats.postCount++ ThreadStats.fileCount++ if post.file ThreadStats.postCount -= e.detail.deletedPosts.length ThreadStats.fileCount -= e.detail.deletedFiles.length ThreadStats.update() update: -> @postCountEl.textContent = ThreadStats.postCount @fileCountEl.textContent = ThreadStats.fileCount (if ThreadStats.fileCount > ThreadStats.fileLimit then $.addClass else $.rmClass) ThreadStats.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 = @posts[@].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 Now' $.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 # if Conf['Unread Count'] # Unread.title = Unread.title.match(/^.+-/)[0] + ' 404' # else # d.title = d.title.match(/^.+-/)[0] + ' 404' # Unread.update true 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 parse: (postObjects) -> Build.spoilerRange[ThreadUpdater.thread.board] = postObjects[0].custom_spoiler 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.ID 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 continue if post.isDead ID = +ID if -1 is index.indexOf ID post.kill() deletedPosts.push post else if post.file and !post.file.isDead and -1 is files.indexOf ID 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 nodes[0].scrollIntoView() $.queueTask -> # Enable 4chan features. threadID = ThreadUpdater.thread.ID {length} = ThreadUpdater.root.children if Conf['Enable 4chan\'s extension'] $.unsafeWindow.Parser.parseThread threadID, -count else Fourchan.parseThread threadID, length - count, length $.event 'ThreadUpdate', 404: false thread: ThreadUpdater.thread newPosts: posts deletedPosts: deletedPosts deletedFiles: deletedFiles 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: -> op = @posts[@] favicon = $.el 'img', className: 'favicon' $.on favicon, 'click', ThreadWatcher.cb.toggle $.before $('input', op.nodes.post), favicon if g.VIEW is 'thread' and @ID is $.get 'AutoWatch', 0 ThreadWatcher.watch @ $.delete 'AutoWatch' ready: -> ThreadWatcher.refresh() $.add d.body, ThreadWatcher.dialog refresh: (watched) -> watched or= $.get 'WatchedThreads', {} 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 ThreadWatcher.dialog.innerHTML = '' $.add ThreadWatcher.dialog, nodes watched = watched[g.BOARD] or {} for ID, thread of g.BOARD.threads op = thread.posts[thread] favicon = $ '.favicon', 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) -> {postID, threadID} = e.detail if threadID is '0' if Conf['Auto Watch'] $.set 'AutoWatch', +postID else if Conf['Auto Watch Reply'] ThreadWatcher.watch g.BOARD.threads[threadID] toggle: (thread) -> op = thread.posts[thread] if $('.favicon', op.nodes.post).src is Favicon.empty ThreadWatcher.watch thread else ThreadWatcher.unwatch thread.board, thread.ID unwatch: (board, threadID) -> watched = $.get 'WatchedThreads', {} delete watched[board][threadID] ThreadWatcher.refresh watched $.set 'WatchedThreads', watched watch: (thread) -> watched = $.get 'WatchedThreads', {} watched[thread.board] or= {} watched[thread.board][thread] = href: "/#{thread.board}/res/#{thread}" textContent: Get.threadExcerpt thread ThreadWatcher.refresh watched $.set 'WatchedThreads', watched