Config = main: Enhancing: '404 Redirect': [true, 'Redirect dead threads and images.'] 'Keybinds': [true, 'Bind actions to keyboard shortcuts.'] 'Time Formatting': [true, 'Localize and format timestamps arbitrarily.'] 'File Info Formatting': [true, 'Reformat the file information.'] 'Comment Expansion': [true, 'Can expand too long comments.'] 'Thread Expansion': [true, 'Can expand threads to view all replies.'] 'Index Navigation': [false, 'Navigate to previous / next thread.'] 'Reply Navigation': [false, 'Navigate to top / bottom of thread.'] 'Check for Updates': [true, 'Check for updated versions of 4chan X.'] Filtering: 'Anonymize': [false, 'Turn everyone Anonymous.'] 'Filter': [true, 'Self-moderation placebo.'] 'Recursive Filtering': [true, 'Filter replies of filtered posts, recursively.'] 'Reply Hiding': [true, 'Hide single replies.'] 'Thread Hiding': [true, 'Hide entire threads.'] 'Stubs': [true, 'Make stubs of hidden threads / replies.'] Imaging: 'Image Auto-Gif': [false, 'Animate GIF thumbnails.'] 'Image Expansion': [true, 'Expand images.'] 'Expand From Position': [true, 'Expand all images only from current position to thread end.'] 'Image Hover': [false, 'Show full image on mouseover.'] 'Sauce': [true, 'Add sauce links to images.'] 'Reveal Spoilers': [false, 'Reveal spoiler thumbnails.'] Menu: 'Menu': [true, 'Add a drop-down menu in posts.'] 'Report Link': [true, 'Add a report link to the menu.'] 'Delete Link': [true, 'Add post and image deletion links to the menu.'] 'Download Link': [true, 'Add a download with original filename link to the menu. Chrome-only currently.'] 'Archive Link': [true, 'Add an archive link to the menu.'] Monitoring: 'Thread Updater': [true, 'Fetch and insert new replies. Has more options in its own dialog.'] 'Unread Count': [true, 'Show the unread posts count in the tab title.'] 'Unread Favicon': [true, 'Show a different favicon when there are unread posts.'] 'Post in Title': [true, 'Show the thread\'s subject in the tab title.'] 'Thread Stats': [true, 'Display reply and image count.'] 'Thread Watcher': [true, 'Bookmark threads.'] 'Auto Watch': [true, 'Automatically watch threads that you start.'] 'Auto Watch Reply': [false, 'Automatically watch threads that you reply to.'] Posting: 'Quick Reply': [true, 'WMD.'] 'Persistent QR': [false, 'The Quick reply won\'t disappear after posting.'] 'Auto Hide QR': [false, 'Automatically hide the quick reply when posting.'] 'Open Reply in New Tab': [false, 'Open replies posted from the board pages in a new tab.'] 'Remember Subject': [false, 'Remember the subject field, instead of resetting after posting.'] 'Remember Spoiler': [false, 'Remember the spoiler state, instead of resetting after posting.'] 'Hide Original Post Form': [true, 'Replace the normal post form with a shortcut to open the QR.'] Quoting: 'Quote Backlinks': [true, 'Add quote backlinks.'] 'OP Backlinks': [false, 'Add backlinks to the OP.'] 'Quote Inline': [true, 'Inline quoted post on click.'] 'Forward Hiding': [true, 'Hide original posts of inlined backlinks.'] 'Quote Preview': [true, 'Show quoted post on hover.'] 'Quote Highlighting': [true, 'Highlight the previewed post.'] 'Resurrect Quotes': [true, 'Linkify dead quotes to archives.'] 'Indicate OP quote': [true, 'Add \'(OP)\' to OP quotes.'] 'Indicate Cross-thread Quotes': [true, 'Add \'(Cross-thread)\' to cross-threads quotes.'] filter: name: [ '# Filter any namefags:' '#/^(?!Anonymous$)/' ].join '\n' uniqueid: [ '# Filter a specific ID:' '#/Txhvk1Tl/' ].join '\n' tripcode: [ '# Filter any tripfags' '#/^!/' ].join '\n' capcode: [ '# Set a custom class for mods:' '#/Mod$/;highlight:mod;op:yes' '# Set a custom class for moot:' '#/Admin$/;highlight:moot;op:yes' ].join '\n' email: [ '# Filter any e-mails that are not `sage` on /a/ and /jp/:' '#/^(?!sage$)/;boards:a,jp' ].join '\n' subject: [ '# Filter Generals on /v/:' '#/general/i;boards:v;op:only' ].join '\n' comment: [ '# Filter Stallman copypasta on /g/:' '#/what you\'re refer+ing to as linux/i;boards:g' ].join '\n' flag: [ '' ].join '\n' filename: [ '' ].join '\n' dimensions: [ '# Highlight potential wallpapers:' '#/1920x1080/;op:yes;highlight;top:no;boards:w,wg' ].join '\n' filesize: [ '' ].join '\n' md5: [ '' ].join '\n' sauces: [ 'http://iqdb.org/?url=$1' 'http://www.google.com/searchbyimage?image_url=$1' '#http://tineye.com/search?url=$1' '#http://saucenao.com/search.php?db=999&url=$1' '#http://3d.iqdb.org/?url=$1' '#http://regex.info/exif.cgi?imgurl=$2' '# uploaders:' '#http://imgur.com/upload?url=$2;text:Upload to imgur' '#http://omploader.org/upload?url1=$2;text:Upload to omploader' '# "View Same" in archives:' '#http://archive.foolz.us/search/image/$3/;text:View same on foolz' '#http://archive.foolz.us/$4/search/image/$3/;text:View same on foolz /$4/' '#https://archive.installgentoo.net/$4/image/$3;text:View same on installgentoo /$4/' ].join '\n' time: '%m/%d/%y(%a)%H:%M' backlink: '>>%id' fileInfo: '%l (%p%s, %r)' favicon: 'ferongr' hotkeys: # QR & Options 'open QR': ['q', 'Open QR with post number inserted.'] 'open empty QR': ['Q', 'Open QR without post number inserted.'] 'open options': ['alt+o', 'Open Options.'] 'close': ['Esc', 'Close Options or QR.'] 'spoiler tags': ['ctrl+s', 'Insert spoiler tags.'] 'code tags': ['alt+c', 'Insert code tags.'] 'submit QR': ['alt+s', 'Submit post.'] # Thread related 'watch': ['w', 'Watch thread.'] 'update': ['u', 'Update the thread now.'] 'read thread': ['r', 'Mark thread as read.'] # Images 'expand image': ['E', 'Expand selected image.'] 'expand images': ['e', 'Expand all images.'] # Board Navigation 'front page': ['0', 'Jump to page 0.'] 'next page': ['Right', 'Jump to the next page.'] 'previous page': ['Left', 'Jump to the previous page.'] # Thread Navigation 'next thread': ['Down', 'See next thread.'] 'previous thread': ['Up', 'See previous thread.'] 'expand thread': ['ctrl+e', 'Expand thread.'] 'open thread': ['o', 'Open thread in current tab.'] 'open thread tab': ['O', 'Open thread in new tab.'] # Reply Navigation 'next reply': ['j', 'Select next reply.'] 'previous reply': ['k', 'Select previous reply.'] 'hide': ['x', 'Hide thread.'] updater: checkbox: 'Auto Scroll': [false, 'Scroll updated posts into view. Only enabled at bottom of page.'] 'Scroll BG': [false, 'Auto-scroll background tabs.'] 'Auto Update': [true, 'Automatically fetch new posts.'] 'Interval': 30 imageFit: 'fit width' # Opera doesn't support the @match metadata key, # return 4chan X here if we're not on 4chan. return unless /^(boards|images|sys)\.4chan\.org$/.test location.hostname Conf = {} d = document g = VERSION: '3.0.0' NAMESPACE: '4chan_X.' boards: {} threads: {} posts: {} UI = dialog: (id, position, html) -> el = d.createElement 'div' el.className = 'reply dialog' el.innerHTML = html el.id = id el.style.cssText = localStorage.getItem("#{g.NAMESPACE}#{id}.position") or position el.querySelector('.move').addEventListener 'mousedown', UI.dragstart, false el dragstart: (e) -> # prevent text selection e.preventDefault() UI.el = el = @parentNode d.addEventListener 'mousemove', UI.drag, false d.addEventListener 'mouseup', UI.dragend, false # distance from pointer to el edge is constant; calculate it here. rect = el.getBoundingClientRect() UI.dx = e.clientX - rect.left UI.dy = e.clientY - rect.top UI.width = d.documentElement.clientWidth - rect.width UI.height = d.documentElement.clientHeight - rect.height drag: (e) -> left = e.clientX - UI.dx top = e.clientY - UI.dy left = if left < 10 then '0px' else if UI.width - left < 10 then null else left + 'px' top = if top < 10 then '0px' else if UI.height - top < 10 then null else top + 'px' {style} = UI.el style.left = left style.top = top style.right = if left then null else '0px' style.bottom = if top then null else '0px' dragend: -> localStorage.setItem "#{g.NAMESPACE}#{UI.el.id}.position", UI.el.style.cssText d.removeEventListener 'mousemove', UI.drag, false d.removeEventListener 'mouseup', UI.dragend, false delete UI.el hover: (e) -> {clientX, clientY} = e {style} = UI.el {clientHeight, clientWidth} = d.documentElement height = UI.el.offsetHeight top = clientY - 120 style.top = if clientHeight <= height or top <= 0 '0px' else if top + height >= clientHeight clientHeight - height + 'px' else top + 'px' if clientX <= clientWidth - 400 style.left = clientX + 45 + 'px' style.right = null else style.left = null style.right = clientWidth - clientX + 45 + 'px' hoverend: -> $.rm UI.el delete UI.el ### loosely follows the jquery api: http://api.jquery.com/ not chainable ### $ = (selector, root=d.body) -> root.querySelector selector $$ = (selector, root=d.body) -> Array::slice.call root.querySelectorAll selector $.extend = (object, properties) -> for key, val of properties object[key] = val return $.extend $, SECOND: 1000 MINUTE: 1000 * 60 HOUR : 1000 * 60 * 60 DAY : 1000 * 60 * 60 * 24 log: console.log.bind console engine: /WebKit|Presto|Gecko/.exec(navigator.userAgent)[0].toLowerCase() id: (id) -> d.getElementById id ready: (fc) -> if /interactive|complete/.test d.readyState $.queueTask fc return cb = -> $.off d, 'DOMContentLoaded', cb fc() $.on d, 'DOMContentLoaded', cb sync: (key, cb) -> $.on window, 'storage', (e) -> if e.key is "#{g.NAMESPACE}#{key}" cb JSON.parse e.newValue formData: (form) -> if form instanceof HTMLFormElement return new FormData form fd = new FormData() for key, val of form fd.append key, val if val fd ajax: (url, callbacks, opts={}) -> {type, headers, upCallbacks, form} = opts r = new XMLHttpRequest() type or= form and 'post' or 'get' r.open type, url, true for key, val of headers r.setRequestHeader key, val $.extend r, callbacks $.extend r.upload, upCallbacks r.send form r cache: (url, cb) -> reqs = $.cache.requests or= {} if req = reqs[url] if req.readyState is 4 cb.call req else req.callbacks.push cb return req = $.ajax url, onload: -> cb.call @ for cb in @callbacks delete @callbacks onabort: -> delete reqs[url] onerror: -> delete reqs[url] req.callbacks = [cb] reqs[url] = req cb: checked: -> $.set @name, @checked Conf[@name] = @checked value: -> $.set @name, @value.trim() Conf[@name] = @value addStyle: (css) -> style = $.el 'style', textContent: css $.add d.head, style style x: (path, root=d.body) -> # XPathResult.ANY_UNORDERED_NODE_TYPE === 8 d.evaluate(path, root, null, 8, null).singleNodeValue addClass: (el, className) -> el.classList.add className rmClass: (el, className) -> el.classList.remove className hasClass: (el, className) -> el.classList.contains className rm: (el) -> el.parentNode.removeChild el tn: (s) -> d.createTextNode s nodes: (nodes) -> # In (at least) Chrome, elements created inside different # scripts/window contexts inherit from unequal prototypes. # window_context1.Node !== window_context2.Node unless nodes instanceof Array return nodes frag = d.createDocumentFragment() for node in nodes frag.appendChild node frag add: (parent, el) -> parent.appendChild $.nodes el prepend: (parent, el) -> parent.insertBefore $.nodes(el), parent.firstChild after: (root, el) -> root.parentNode.insertBefore $.nodes(el), root.nextSibling before: (root, el) -> root.parentNode.insertBefore $.nodes(el), root replace: (root, el) -> root.parentNode.replaceChild $.nodes(el), root el: (tag, properties) -> el = d.createElement tag $.extend el, properties if properties el on: (el, events, handler) -> for event in events.split ' ' el.addEventListener event, handler, false return off: (el, events, handler) -> for event in events.split ' ' el.removeEventListener event, handler, false return open: (url) -> (GM_openInTab or window.open) url, '_blank' queueTask: # inspired by https://www.w3.org/Bugs/Public/show_bug.cgi?id=15007 (-> taskQueue = [] execTask = -> task = taskQueue.shift() func = task[0] args = Array::slice.call task, 1 func.apply func, args if window.MessageChannel taskChannel = new MessageChannel() taskChannel.port1.onmessage = execTask -> taskQueue.push arguments taskChannel.port2.postMessage null else # XXX Firefox -> taskQueue.push arguments setTimeout execTask, 0 )() globalEval: (code) -> script = $.el 'script', textContent: code $.add d.head, script $.rm script # http://mths.be/unsafewindow unsafeWindow: if window.opera # Opera window else if unsafeWindow isnt window # Firefox unsafeWindow else # Chrome (-> p = d.createElement 'p' p.setAttribute 'onclick', 'return window' p.onclick() )() bytesToString: (size) -> unit = 0 # Bytes while size >= 1024 size /= 1024 unit++ # Remove trailing 0s. size = if unit > 1 # Keep the size as a float if the size is greater than 2^20 B. # Round to hundredth. Math.round(size * 100) / 100 else # Round to an integer otherwise. Math.round size "#{size} #{['B', 'KB', 'MB', 'GB'][unit]}" $.extend $, if GM_deleteValue? delete: (name) -> GM_deleteValue g.NAMESPACE + name get: (name, defaultValue) -> if value = GM_getValue g.NAMESPACE + name JSON.parse value else defaultValue set: (name, value) -> name = g.NAMESPACE + name value = JSON.stringify value # for `storage` events localStorage.setItem name, value GM_setValue name, value else if window.opera delete: (name)-> delete opera.scriptStorage[g.NAMESPACE + name] get: (name, defaultValue) -> if value = opera.scriptStorage[g.NAMESPACE + name] JSON.parse value else defaultValue set: (name, value) -> name = g.NAMESPACE + name value = JSON.stringify value # for `storage` events localStorage.setItem name, value opera.scriptStorage[name] = value else delete: (name) -> localStorage.removeItem g.NAMESPACE + name get: (name, defaultValue) -> if value = localStorage.getItem g.NAMESPACE + name JSON.parse value else defaultValue set: (name, value) -> localStorage.setItem g.NAMESPACE + name, JSON.stringify value class Board toString: -> @ID constructor: (@ID) -> @threads = {} @posts = {} g.boards[@] = @ class Thread callbacks: [] toString: -> @ID constructor: (ID, @board) -> @ID = +ID @posts = {} # XXX Can't check when parsing single posts # move to Post constructor? unless @isReply # postInfo = $ '.postInfo', root.firstElementChild # @isClosed = !!$ 'img[title=Closed]', postInfo # @isSticky = !!$ 'img[title=Sticky]', postInfo g.threads["#{board}.#{@}"] = board.threads[@] = @ class Post callbacks: [] toString: -> @ID constructor: (root, @thread, @board, that={}) -> @ID = +root.id[2..] post = $ '.post', root info = $ '.postInfo', post @nodes = root: root post: post info: info comment: $ '.postMessage', post quotelinks: [] backlinks: info.getElementsByClassName 'backlink' @info = {} if subject = $ '.subject', info @nodes.subject = subject @info.subject = subject.textContent if name = $ '.name', info @nodes.name = name @info.name = name.textContent if email = $ '.useremail', info @nodes.email = email @info.email = decodeURIComponent email.href[7..] if tripcode = $ '.postertrip', info @nodes.tripcode = tripcode @info.tripcode = tripcode.textContent if uniqueID = $ '.posteruid', info @nodes.uniqueID = uniqueID @info.uniqueID = uniqueID.textContent if capcode = $ '.capcode', info @nodes.capcode = capcode @info.capcode = capcode.textContent if flag = $ '.countryFlag', info @nodes.flag = flag @info.flag = flag.title if date = $ '.dateTime', info @nodes.date = date @info.date = new Date date.dataset.utc * 1000 # Get the comment's text. #
-> \n # Remove: # 'Comment too long'... # Admin/Mod/Dev replies. (/q/) # EXIF data. (/p/) # Rolls. (/tg/) # Preceding and following new lines. # Trailing spaces. bq = @nodes.comment.cloneNode true for node in $$ '.abbr, .capcodeReplies, .exif, b', bq $.rm node text = [] # XPathResult.ORDERED_NODE_SNAPSHOT_TYPE === 7 nodes = d.evaluate './/br|.//text()', bq, null, 7, null for i in [0...nodes.snapshotLength] text.push if data = nodes.snapshotItem(i).data then data else '\n' @info.comment = text.join('').replace /^\n+|\n+$| +(?=\n|$)/g, '' quotes = {} for quotelink in $$ '.quotelink', @nodes.comment # Don't add board links. (>>>/b/) # Don't add text-board quotelinks. (>>>/img/1234) # Don't count capcode replies as quotes. (Admin/Mod/Dev Replies: ...) # Only add quotes that link to posts on an imageboard. if quotelink.hash @nodes.quotelinks.push quotelink continue if quotelink.parentNode.parentNode.className is 'capcodeReplies' quotes["#{quotelink.pathname.split('/')[1]}.#{quotelink.hash[2..]}"] = true @quotes = Object.keys quotes if (file = $ '.file', post) and thumb = $ 'img[data-md5]', file # Supports JPG/PNG/GIF/PDF. # Flash files are not supported. alt = thumb.alt anchor = thumb.parentNode @file = info: $ '.fileInfo', file text: $ '.fileText', file thumb: thumb URL: anchor.href MD5: thumb.dataset.md5 isSpoiler: $.hasClass anchor, 'imgspoiler' size = +alt.match(/\d+(\.\d+)?/)[0] unit = ['B', 'KB', 'MB', 'GB'].indexOf alt.match(/\w+$/)[0] while unit-- size *= 1024 @file.size = size @file.thumbURL = if that.isArchived thumb.src else "#{location.protocol}//thumbs.4chan.org/#{board}/thumb/#{@file.URL.match(/(\d+)\./)[1]}s.jpg" @file.name = $('span[title]', @file.info).title if @file.isImage = /(jpg|png|gif)$/i.test @file.name @file.dimensions = @file.text.textContent.match(/\d+x\d+/)[0] @isReply = $.hasClass post, 'reply' @isDead = true if that.isArchived @clones = [] g.posts["#{board}.#{@}"] = thread.posts[@] = board.posts[@] = @ addClone: -> new Clone @ rmClone: (index) -> @clones.splice index, 1 for i in [index...@clones.length] @clones[i].nodes.root.setAttribute 'data-clone', i return class Clone extends Post constructor: (@origin) -> for key in ['ID', 'board', 'thread', 'info', 'quotes', 'isReply'] # Copy or point to the origin's key value. @[key] = origin[key] {nodes} = origin root = nodes.root.cloneNode true post = $ '.post', root info = $ '.postInfo', post @nodes = root: root post: post info: info comment: $ '.postMessage', post quotelinks: [] backlinks: info.getElementsByClassName 'backlink' # Remove inlined posts inside of this post. for inline in $$ '.inline', post $.rm inline for inlined in $$ '.inlined', post $.rmClass inlined, 'inlined' # root.hidden = false # post hiding $.rmClass root, 'forwarded' # quote inlining # $.rmClass post, 'highlight' # keybind navigation if nodes.subject @nodes.subject = $ '.subject', info if nodes.name @nodes.name = $ '.name', info if nodes.email @nodes.email = $ '.useremail', info if nodes.tripcode @nodes.tripcode = $ '.postertrip', info if nodes.uniqueID @nodes.uniqueID = $ '.posteruid', info if nodes.capcode @nodes.capcode = $ '.capcode', info if nodes.flag @nodes.flag = $ '.countryFlag', info if nodes.date @nodes.date = $ '.dateTime', info for quotelink in $$ '.quotelink', @nodes.comment # See comments in Post's constructor. if quotelink.hash or $.hasClass quotelink, 'deadlink' @nodes.quotelinks.push quotelink if origin.file # Copy values, point to relevant elements. # See comments in Post's constructor. @file = {} for key, val of origin.file @file[key] = val file = $ '.file', post @file.info = $ '.fileInfo', file @file.text = $ '.fileText', file @file.thumb = $ 'img[data-md5]', file @isDead = true if origin.isDead @isClone = true index = origin.clones.push(@) - 1 root.setAttribute 'data-clone', index Main = init: -> # flatten Config into Conf # and get saved or default values flatten = (parent, obj) -> if obj instanceof Array Conf[parent] = obj[0] else if typeof obj is 'object' for key, val of obj flatten key, val else # string or number Conf[parent] = obj return flatten null, Config for key, val of Conf Conf[key] = $.get key, val pathname = location.pathname.split '/' g.BOARD = new Board pathname[1] if g.REPLY = pathname[2] is 'res' g.THREAD = +pathname[3] switch location.hostname when 'boards.4chan.org' Main.addStyle() Main.initHeader() Main.initFeatures() when 'sys.4chan.org' return when 'images.4chan.org' $.ready -> if Conf['404 Redirect'] and d.title is '4chan - 404 Not Found' path = location.pathname.split '/' url = Redirect.image path[1], path[3] location.href = url if url return initHeader: -> Main.header = $.el 'div', className: 'reply' innerHTML: '
' # disable 4chan's features localStorage.setItem '4chan_settings', false $.ready Main.initHeaderReady initHeaderReady: -> return unless $.id 'navtopr' header = Main.header $.prepend d.body, header nav = $.id 'boardNavDesktop' header.id = nav.id $.prepend header, nav nav.id = nav.className = null nav.lastElementChild.hidden = true settings = $.el 'span', id: 'settings' innerHTML: '[Settings]' $.on settings.firstElementChild, 'click', Main.settings $.add nav, settings $("a[href$='/#{g.BOARD}/']", nav)?.className = 'current' $.addClass d.body, $.engine $.addClass d.body, 'fourchan_x' # disable the mobile layout $('link[href*=mobile]', d.head)?.disabled = true $.id('boardNavDesktopFoot')?.hidden = true initFeatures: -> if Conf['Resurrect Quotes'] try Quotify.init() catch err # XXX handle error $.log err, 'Resurrect Quotes' if Conf['Quote Inline'] try QuoteInline.init() catch err # XXX handle error $.log err, 'Quote Inline' if Conf['Quote Preview'] try QuotePreview.init() catch err # XXX handle error $.log err, 'Quote Preview' if Conf['Quote Backlinks'] try QuoteBacklink.init() catch err # XXX handle error $.log err, 'Quote Backlinks' if Conf['Time Formatting'] try Time.init() catch err # XXX handle error $.log err, 'Time Formatting' $.ready Main.initFeaturesReady initFeaturesReady: -> if d.title is '4chan - 404 Not Found' if Conf['404 Redirect'] and g.REPLY location.href = Redirect.thread g.BOARD, g.THREAD, location.hash return return unless $.id 'navtopr' threads = [] posts = [] for boardChild in $('.board').children continue unless $.hasClass boardChild, 'thread' thread = new Thread boardChild.id[1..], g.BOARD threads.push thread for threadChild in boardChild.children continue unless $.hasClass threadChild, 'postContainer' try posts.push new Post threadChild, thread, g.BOARD catch err # Skip posts that we failed to parse. # XXX handle error # Post parser crashed for post No.#{threadChild.id[2..]} $.log threadChild, err Main.callbackNodes Thread, threads, true Main.callbackNodes Post, posts, true callbackNodes: (klass, nodes, notify) -> # get the nodes' length only once len = nodes.length for callback in klass::callbacks try for i in [0...len] callback.cb.call nodes[i] catch err # XXX handle error if notify $.log callback.name, 'crashed. error:', err.message, nodes[i], err return settings: -> alert 'Here be settings' addStyle: -> $.off d, 'DOMNodeInserted', Main.addStyle if d.head $.addStyle Main.css else $.on d, 'DOMNodeInserted', Main.addStyle css: """ /* general */ .dialog.reply { display: block; border: 1px solid rgba(0, 0, 0, .25); padding: 0; } .move { cursor: move; } label { cursor: pointer; } a[href="javascript:;"] { text-decoration: none; } .warning { color: red; } /* 4chan style fixes */ .opContainer, .op { display: block !important; } .post { overflow: visible !important; } /* header */ body.fourchan_x { margin-top: 2.5em; } #boardNavDesktop.reply { border-width: 0 0 1px; padding: 4px; position: fixed; top: 0; right: 0; left: 0; transition: opacity .1s ease-in-out; -o-transition: opacity .1s ease-in-out; -moz-transition: opacity .1s ease-in-out; -webkit-transition: opacity .1s ease-in-out; z-index: 1; } #boardNavDesktop.reply:not(:hover) { opacity: .4; transition: opacity 1.5s .5s ease-in-out; -o-transition: opacity 1.5s .5s ease-in-out; -moz-transition: opacity 1.5s .5s ease-in-out; -webkit-transition: opacity 1.5s .5s ease-in-out; } #boardNavDesktop.reply a { margin: -1px; } #settings { float: right; } /* quote related */ .inlined { opacity: .5; } #qp input, .forwarded { display: none; } .quotelink.forwardlink, .backlink.forwardlink { text-decoration: none; border-bottom: 1px dashed; } .inline { border: 1px solid rgba(128, 128, 128, .5); display: table; margin: 2px 0; } .inline .post { border: 0 !important; display: table !important; margin: 0 !important; padding: 1px 2px !important; } #qp { position: fixed; padding: 2px 2px 5px; } #qp .post { border: none; margin: 0; padding: 0; } #qp img { max-height: 300px; max-width: 500px; } .qphl { outline: 2px solid rgba(216, 94, 49, .7); } """ Redirect = image: (board, filename) -> # Do not use g.BOARD, the image url can originate from a cross-quote. switch board when 'a', 'jp', 'm', 'q', 'sp', 'tg', 'vg', 'wsg' "//archive.foolz.us/#{board}/full_image/#{filename}" when 'u' "//nsfw.foolz.us/#{board}/full_image/#{filename}" when 'ck', 'lit' "//fuuka.warosu.org/#{board}/full_image/#{filename}" when 'cgl', 'g', 'w' "//archive.rebeccablacktech.com/#{board}/full_image/#{filename}" when 'an', 'k', 'toy', 'x' "http://archive.heinessen.com/#{board}/full_image/#{filename}" # when 'e' # "https://www.cliché.net/4chan/cgi-board.pl/#{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}/format/json" when 'u', 'kuku' "//nsfw.foolz.us/api/chan/post/board/#{board}/num/#{postID}/format/json" # for fuuka-based archives: # https://github.com/eksopl/fuuka/issues/27 thread: (board, threadID, postID) -> # keep the number only, if the location.hash was sent f.e. postID = postID.match(/\d+/)[0] if postID path = if threadID "#{board}/thread/#{threadID}" else "#{board}/post/#{postID}" switch "#{board}" when 'a', 'co', 'jp', 'm', 'q', 'sp', 'tg', 'tv', 'v', 'vg', 'wsg', 'dev', 'foolz' url = "//archive.foolz.us/#{path}/" if threadID and postID url += "##{postID}" when 'u', 'kuku' url = "//nsfw.foolz.us/#{path}/" if threadID and postID url += "##{postID}" when 'ck', 'lit' url = "//fuuka.warosu.org/#{path}" if threadID and postID url += "#p#{postID}" when 'diy', 'g', 'sci' url = "//archive.installgentoo.net/#{path}" if threadID and postID url += "#p#{postID}" when 'cgl', 'mu', 'soc', 'w' url = "//archive.rebeccablacktech.com/#{path}" if threadID and postID url += "#p#{postID}" when 'an', 'fit', 'k', 'mlp', 'r9k', 'toy', 'x' url = "http://archive.heinessen.com/#{path}" if threadID and postID url += "#p#{postID}" when 'e' url = "https://www.cliché.net/4chan/cgi-board.pl/#{path}" if threadID and postID url += "#p#{postID}" else if threadID url = "//boards.4chan.org/#{board}/" url or '' Build = 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 post: (o) -> { postID, threadID, board name, capcode, tripcode, uniqueID, email, subject, flag, flagTitle, date, dateUTC, comment file } = o isOP = postID is threadID # post info html = [] # input html.push " " # subject html.push "#{subject} " # name block html.push "" # mail start html.push "" if email # name html.push "#{name}" # tripcode html.push " #{tripcode}" if tripcode # user id html.push " (ID: #{uniqueID})" if uniqueID # capcode switch capcode when 'M' html.push ' ## Mod' html.push ' This user is a 4chan Moderator.' when 'A' html.push ' ## Admin' html.push ' This user is the 4chan Administrator.' when 'D' html.push ' ## Mod' html.push ' This user is a 4chan Developer.' # mail end html.push '' if email # flag # XXX what about troll flags in /pol/? html.push " #{flag}" if flag # end name block html.push ' ' # date html.push "#{date} " # post num html.push '' html.push "No." html.push "#{postID}" # XXX closed/sticky? html.push '' pi = $.el 'div', id: "pi#{postID}" className: 'postInfo desktop' innerHTML: html.join '' bq = $.el 'blockquote', id: "m#{postID}" className: 'postMessage' innerHTML: comment # file if file.name # XXX need to fix support of Flash html = [] html.push '
' html.push "File: " html.push "#{file.origin}" html.push '-(' html.push 'Spoiler Image, ' if file.isSpoiler html.push "#{$.bytesToString file.size}, " html.push if /\.pdf$/i.test file.name "PDF" else "#{file.width}x#{file.height}" html.push ", #{Build.shortFilename file.name}" unless file.isSpoiler html.push ")
" html.push "" html.push "#{if file.isSpoiler then " html.push '' fl = $.el 'div', id: "f#{postID}" className: 'file' innerHTML: html.join '' post = $.el 'div', id: "p#{postID}" className: "post #{if isOP then 'op' else 'reply'}" $.add post, fl if fl and isOP $.add post, pi $.add post, fl if fl and !isOP $.add post, bq container = $.el 'div', id: "pc#{postID}" className: "postContainer #{if isOP then 'op' else 'reply'}Container" unless isOP $.add container, $.el 'div', id: "sa#{postID}" className: 'sideArrows' textContent: '>>' $.add container, post 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.host 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 = '' postID = link.dataset.postid return { board: board threadID: threadID postID: postID } postClone: (board, threadID, postID, root) -> if origin = g.posts["#{board}.#{postID}"] clone = origin.addClone() Main.callbackNodes Post, [clone] $.add root, Get.cleanRoot clone return root.textContent = "Loading post No.#{postID}..." if threadID $.cache "/#{board}/res/#{threadID}", -> Get.fetchedPost @, board, threadID, postID, root else if url = Redirect.post board, postID $.cache url, -> Get.archivedPost @, board, postID, root cleanRoot: (clone) -> {root, post} = clone.nodes for child in Array::slice.call root.childNodes $.rm child unless child is post root fetchedPost: (req, board, threadID, postID, root) -> {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 else $.addClass root, 'warning' root.textContent = if status is 404 "Thread No.#{threadID} has not been found." else "Error #{req.status}: #{req.statusText}." return doc = d.implementation.createHTMLDocument '' doc.documentElement.innerHTML = req.response unless pc = doc.getElementById "pc#{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 else $.addClass root, 'warning' root.textContent = "Post No.#{postID} has not been found." return pc = d.importNode pc, true for quote in $$ '.quotelink', pc href = quote.getAttribute 'href' continue if href[0] is '/' # Cross-board quote, or board link quote.href = "/#{board}/res/#{href}" # Fix pathnames link = $ 'a[title="Highlight this post"]', pc link.href = "/#{board}/res/#{threadID}#p#{postID}" link.nextSibling.href = "/#{board}/res/#{threadID}#q#{postID}" inBoard = g.boards[board] or new Board board inThread = g.threads["#{board}.#{threadID}"] or new Thread threadID, inBoard # In case of multiple callbacks for the same request, # don't parse the same original post more than once. unless post = g.posts["#{board}.#{postID}"] post = new Post postContainer, thread, board, isArchived: true Main.callbackNodes Post, [post] # Stop here if the container has been removed while loading. return unless root.parentNode clone = post.addClone() Main.callbackNodes Post, [clone] $.replace root.firstChild, Get.cleanRoot clone archivedPost: (req, board, postID, root) -> 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]' '' # greentext comment = bq.innerHTML.replace /(^|>)(>[^<$]+)(<|$)/g, '$1$2$3' threadID = data.thread_num postContainer = Build.post # id postID: postID threadID: threadID board: board # info name: data.name capcode: data.capcode tripcode: data.trip uniqueID: data.poster_hash email: data.email subject: data.title flag: data.poster_country # XXX flagTitle: data.??? date: data.fourchan_date dateUTC: data.timestamp comment: comment # file file: name: data.media_filename origin: data.media_orig url: data.media_link or data.remote_media_link height: data.media_h width: data.media_w isSpoiler: data.spoiler is '1' MD5: data.media_hash size: data.media_size turl: data.thumb_link or "//thumbs.4chan.org/#{board}/thumb/#{data.preview_orig}" theight: data.preview_h twidth: data.preview_w board = g.boards[board] or new Board board thread = g.threads["#{board}.#{threadID}"] or new Thread threadID, board # In case of multiple callbacks for the same request, # don't parse the same original post more than once. unless post = g.posts["#{board}.#{postID}"] post = new Post postContainer, thread, board, isArchived: true Main.callbackNodes Post, [post] # Stop here if the container has been removed while loading. return unless root.parentNode clone = post.addClone() Main.callbackNodes Post, [clone] $.replace root.firstChild, Get.cleanRoot clone Quotify = init: -> Post::callbacks.push name: 'Resurrect Quotes' cb: @node node: -> return if @isClone # XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE === 6 # Get all the text nodes that are not inside an anchor. snapshot = d.evaluate './/text()[not(parent::a)]', @nodes.comment, null, 6, null for i in [0...snapshot.snapshotLength] node = snapshot.snapshotItem i data = node.data # Only accept nodes with potentially valid links continue unless quotes = data.match />>(>\/[a-z\d]+\/)?\d+/g nodes = [] for quote in quotes index = data.indexOf quote if text = data[...index] # Potential text before this valid quote. nodes.push $.tn text 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] if post.isDead a = $.el 'a', href: Redirect.thread board, 0, ID className: 'quotelink deadlink' textContent: "#{quote}\u00A0(Dead)" target: '_blank' else # 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 a = $.el 'a', href: Redirect.thread board, 0, ID className: 'deadlink' target: '_blank' # Don't (Dead) when quotifying in an archived post, # and we don't know anything about the post. textContent: if @isDead then quote else "#{quote}\u00A0(Dead)" if Redirect.post board, ID $.addClass a, 'quotelink' a.setAttribute 'data-board', board a.setAttribute 'data-postid', ID if @quotes.indexOf(quoteID) is -1 @quotes.push quoteID @nodes.quotelinks.push a nodes.push a data = data[index + quote.length..] if data # Potential text after the last valid quote. nodes.push $.tn data $.replace node, nodes return QuoteInline = init: -> 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 @ if $.hasClass @, 'inlined' QuoteInline.rm @, board, threadID, postID else return if $.x "ancestor::div[@id='p#{postID}']", @ QuoteInline.add @, board, threadID, postID @classList.toggle 'inlined' add: (quotelink, board, threadID, postID) -> inline = $.el 'div', id: "i#{postID}" className: 'inline' root = if isBacklink = $.hasClass quotelink, 'backlink' quotelink.parentNode.parentNode else $.x 'ancestor-or-self::*[parent::blockquote][1]', quotelink $.after root, inline Get.postClone board, threadID, postID, inline return unless board is g.BOARD.ID and $.x "ancestor::div[@id='t#{threadID}']", quotelink post = g.posts["#{board}.#{postID}"] # 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) -> # Select the corresponding inlined quote, and remove it. root = if $.hasClass quotelink, 'backlink' quotelink.parentNode.parentNode else $.x 'ancestor-or-self::*[parent::blockquote][1]', quotelink 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 inThreadID = $.x('ancestor::div[@class="thread"]', quotelink).id[1..] # Decrease forward count and unhide. if Conf['Forward Hiding'] and board is g.BOARD.ID and threadID is inThreadID and $.hasClass quotelink, 'backlink' unless --post.forwarded delete post.forwarded $.rmClass post.nodes.root, 'forwarded' # Repeat. inlines = $$ '.inlined', el for inline in inlines {board, threadID, postID} = Get.postDataFromLink inline root = if $.hasClass inline, 'backlink' inline.parentNode.parentNode else $.x 'ancestor-or-self::*[parent::blockquote][1]', inline root = $.x "following-sibling::div[@id='i#{postID}'][1]", root continue unless el = root.firstElementChild post = g.posts["#{board}.#{postID}"] post.rmClone el.dataset.clone if Conf['Forward Hiding'] and board is g.BOARD.ID and threadID is inThreadID and $.hasClass inline, 'backlink' unless --post.forwarded delete post.forwarded $.rmClass post.nodes.root, 'forwarded' return QuotePreview = init: -> 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' # Don't stop other elements from dragging return if UI.el {board, threadID, postID} = Get.postDataFromLink @ qp = UI.el = $.el 'div', id: 'qp' className: 'reply dialog' UI.hover e Get.postClone board, threadID, postID, qp $.add d.body, qp $.on @, 'mousemove', UI.hover $.on @, '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: (e) -> root = UI.el.firstElementChild UI.hoverend() $.off @, 'mousemove', UI.hover $.off @, 'mouseout click', QuotePreview.mouseout # Stop if it only contains text. return unless root 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: -> 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#{@}" # XXX className: if post.el.hidden then 'filtered backlink' else 'backlink' className: '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 "#{@board}.#{@}" @nodes.backlinkContainer = container $.add @nodes.info, container getContainer: (id) -> @containers[id] or= $.el 'span', className: 'container' Time = init: -> @funk = @createFunc() Post::callbacks.push name: 'Time Formatting' cb: @node node: -> return if @isClone @nodes.date.textContent = Time.funk Time, @info.date createFunc: -> code = Conf['time'].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 Main.init()