config = main: Enhancing: '404 Redirect': [true, 'Redirect dead threads and images'] 'Keybinds': [true, 'Binds actions to keys'] 'Time Formatting': [true, 'Arbitrarily formatted timestamps, using your local time'] 'File Info Formatting': [true, 'Reformats the file information'] 'Report Button': [true, 'Add report buttons'] 'Comment Expansion': [true, 'Expand too long comments'] 'Thread Expansion': [true, 'View all replies'] 'Index Navigation': [true, '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, 'Make everybody 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'] 'Show Stubs': [true, 'Of hidden threads / replies'] Imaging: 'Image Auto-Gif': [false, 'Animate gif thumbnails'] 'Image Expansion': [true, 'Expand images'] 'Image Hover': [false, 'Show full image on mouseover'] 'Sauce': [true, 'Add sauce to images'] 'Reveal Spoilers': [false, 'Replace spoiler thumbnails by the original thumbnail'] 'Expand From Current': [false, 'Expand images from current position to thread end.'] Monitoring: 'Thread Updater': [true, 'Update threads. Has more options in its own dialog.'] 'Unread Count': [true, 'Show unread post count in tab title'] 'Unread Favicon': [true, 'Show a different favicon when there are unread posts'] 'Post in Title': [true, 'Show the op\'s post 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, 'Reply without leaving the page.'] 'Cooldown': [true, 'Prevent "flood detected" errors.'] 'Persistent QR': [false, 'The Quick reply won\'t disappear after posting.'] 'Auto Hide QR': [true, 'Automatically hide the quick reply when posting.'] 'Open Reply in New Tab': [false, 'Open replies in a new tab that are made from the main board.'] 'Remember QR size': [false, 'Remember the size of the Quick reply (Firefox only).'] '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 Highlighting': [true, 'Highlight the previewed post'] 'Quote Inline': [true, 'Show quoted post inline on quote click'] 'Quote Preview': [true, 'Show quote content on hover'] 'Resurrect Quotes': [true, 'Bring dead links back to life'] 'Indicate OP quote': [true, 'Add \'(OP)\' to OP quotes'] 'Indicate Cross-thread Quotes': [true, 'Add \'(Cross-thread)\' to cross-threads quotes'] 'Forward Hiding': [true, 'Hide original posts of inlined backlinks'] filter: name: [ '# Filter any namefags:' '#/^(?!Anonymous$)/' ].join '\n' uniqueid: [ '# Filter a specific ID:' '#/Txhvk1Tl/' ].join '\n' tripcode: [ '# Filter any tripfags' '#/^!/' ].join '\n' mod: [ '# 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' 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' '#http://omploader.org/upload?url1=$2' '# "View Same" in archives:' '#http://archive.foolz.us/$4/image/$3/' '#http://archive.installgentoo.net/$4/image/$3' ].join '\n' time: '%m/%d/%y(%a)%H:%M' backlink: '>>%id' fileInfoR: '%l (%s, %r)' fileInfoT: '%l (%s, %r)' favicon: 'ferongr' hotkeys: # QR & Options openQR: ['i', 'Open QR with post number inserted'] openEmptyQR: ['I', 'Open QR without post number inserted'] openOptions: ['ctrl+o', 'Open Options'] close: ['Esc', 'Close Options or QR'] spoiler: ['ctrl+s', 'Quick spoiler'] submit: ['alt+s', 'Submit post'] # Thread related watch: ['w', 'Watch thread'] update: ['u', 'Update now'] unreadCountTo0: ['z', 'Reset unread status'] # Images expandImage: ['m', 'Expand selected image'] expandAllImages: ['M', 'Expand all images'] # Board Navigation zero: ['0', 'Jump to page 0'] nextPage: ['L', 'Jump to the next page'] previousPage: ['H', 'Jump to the previous page'] # Thread Navigation nextThread: ['n', 'See next thread'] previousThread: ['p', 'See previous thread'] expandThread: ['e', 'Expand thread'] openThreadTab: ['o', 'Open thread in current tab'] openThread: ['O', 'Open thread in new tab'] # Reply Navigation nextReply: ['J', 'Select next reply'] previousReply: ['K', 'Select previous reply'] hide: ['x', 'Hide thread'] updater: checkbox: 'Scrolling': [false, 'Scroll updated posts into view. Only enabled at bottom of page.'] 'Scroll BG': [false, 'Scroll background tabs'] 'Verbose': [true, 'Show countdown timer, new post count'] 'Auto Update': [true, 'Automatically fetch new posts'] 'Interval': 30 # XXX Chrome can't into {log} = console # XXX GreaseMonkey can't into console.log.bind log = console.log.bind? console # flatten the config conf = {} (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 ) null, config NAMESPACE = '4chan_x.' VERSION = '2.28.1' SECOND = 1000 MINUTE = 60*SECOND HOUR = 60*MINUTE DAY = 24*HOUR engine = /WebKit|Presto|Gecko/.exec(navigator.userAgent)[0].toLowerCase() d = document g = callbacks: [] ui = dialog: (id, position, html) -> el = d.createElement 'div' el.className = 'reply dialog' el.innerHTML = html el.id = id el.style.cssText = if saved = localStorage["#{NAMESPACE}#{id}.position"] then saved else 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. # XXX opera reports el.offsetLeft / el.offsetTop as 0 rect = el.getBoundingClientRect() ui.dx = e.clientX - rect.left ui.dy = e.clientY - rect.top #factor out el from document dimensions ui.width = d.body.clientWidth - el.offsetWidth ui.height = d.body.clientHeight - el.offsetHeight drag: (e) -> left = e.clientX - ui.dx top = e.clientY - ui.dy left = if left < 10 then 0 else if ui.width - left < 10 then null else left top = if top < 10 then 0 else if ui.height - top < 10 then null else top right = if left is null then 0 else null bottom = if top is null then 0 else null #using null instead of '' is 4% faster #these 4 statements are 40% faster than 1 style.cssText {style} = ui.el style.top = top style.right = right style.bottom = bottom style.left = left dragend: -> #$ coffee -bpe '{a} = {b} = c' #var a, b; #a = (b = c.b, c).a; {el} = ui localStorage["#{NAMESPACE}#{el.id}.position"] = el.style.cssText d.removeEventListener 'mousemove', ui.drag, false d.removeEventListener 'mouseup', ui.dragend, false hover: (e) -> {clientX, clientY} = e {el} = ui {style} = el {clientHeight, clientWidth} = d.body height = el.offsetHeight top = clientY - 120 style.top = if clientHeight <= height or top <= 0 0 else if top + height >= clientHeight clientHeight - height else top if clientX <= clientWidth - 400 style.left = clientX + 45 style.right = null else style.left = null style.right = clientWidth - clientX + 45 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 $.extend = (object, properties) -> for key, val of properties object[key] = val return $.extend $, ready: (fc) -> if /interactive|complete/.test d.readyState # Execute the functions in parallel. # If one fails, do not stop the others. return setTimeout fc cb = -> $.off d, 'DOMContentLoaded', cb fc() $.on d, 'DOMContentLoaded', cb sync: (key, cb) -> $.on window, 'storage', (e) -> cb JSON.parse e.newValue if e.key is "#{NAMESPACE}#{key}" id: (id) -> d.getElementById id ajax: (url, callbacks, opts={}) -> {type, headers, upCallbacks, form} = opts r = new XMLHttpRequest() r.open type or 'get', url, true for key, val of headers r.setRequestHeader key, val $.extend r, callbacks $.extend r.upload, upCallbacks if typeof form is 'string' then r.sendAsBinary form else r.send form r cache: (url, cb) -> if req = $.cache.requests[url] if req.readyState is 4 cb.call req else req.callbacks.push cb else req = $.ajax url, onload: -> cb.call @ for cb in @callbacks onabort: -> delete $.cache.requests[url] req.callbacks = [cb] $.cache.requests[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 is 8 d.evaluate(path, root, null, 8, null). singleNodeValue addClass: (el, className) -> el.classList.add className removeClass: (el, className) -> el.classList.remove className rm: (el) -> el.parentNode.removeChild el tn: (s) -> d.createTextNode s nodes: (nodes) -> if nodes instanceof Node return nodes frag = d.createDocumentFragment() for node in nodes frag.appendChild node frag add: (parent, children) -> parent.appendChild $.nodes children prepend: (parent, children) -> parent.insertBefore $.nodes(children), 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, eventType, handler) -> el.addEventListener eventType, handler, false off: (el, eventType, handler) -> el.removeEventListener eventType, handler, false open: (url) -> (GM_openInTab or window.open) url, '_blank' isDST: -> ### http://en.wikipedia.org/wiki/Eastern_Time_Zone Its UTC time offset is −5 hrs (UTC−05) during standard time and −4 hrs (UTC−04) during daylight saving time. Since 2007, the local time changes at 02:00 EST to 03:00 EDT on the second Sunday in March and returns at 02:00 EDT to 01:00 EST on the first Sunday in November, in the U.S. as well as in Canada. 0200 EST (UTC-05) = 0700 UTC 0200 EDT (UTC-04) = 0600 UTC ### D = new Date() date = D.getUTCDate() day = D.getUTCDay() hours = D.getUTCHours() month = D.getUTCMonth() #this is the easy part if month < 2 or 10 < month return false if 2 < month < 10 return true # (sunday's date) = (today's date) - (number of days past sunday) # date is not zero-indexed sunday = date - day if month is 2 #before second sunday if sunday < 8 return false #during second sunday if sunday < 15 and day is 0 if hours < 7 return false return true #after second sunday return true #month is 10 # before first sunday if sunday < 1 return true # during first sunday if sunday < 8 and day is 0 if hours < 6 return true return false #after first sunday return false $.cache.requests = {} $.extend $, if GM_deleteValue? delete: (name) -> name = NAMESPACE + name GM_deleteValue name get: (name, defaultValue) -> name = NAMESPACE + name if value = GM_getValue name JSON.parse value else defaultValue set: (name, value) -> name = NAMESPACE + name # for `storage` events localStorage[name] = JSON.stringify value GM_setValue name, JSON.stringify value else delete: (name) -> name = NAMESPACE + name delete localStorage[name] get: (name, defaultValue) -> name = NAMESPACE + name if value = localStorage[name] JSON.parse value else defaultValue set: (name, value) -> name = NAMESPACE + name localStorage[name] = JSON.stringify value $$ = (selector, root=d.body) -> Array::slice.call root.querySelectorAll selector Filter = filters: {} init: -> 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 boards.split(',').indexOf(g.BOARD) is -1 continue try if key is 'md5' # MD5 filter will use strings instead of regular expressions. regexp = regexp[1] else # Please, don't write silly regular expressions. regexp = RegExp regexp[1], regexp[2] catch e # I warned you, bro. alert e.message continue # Filter OPs along with their threads, replies only, or both. # Defaults to replies only. op = filter.match(/[^t]op:(yes|no|only)/)?[1].toLowerCase() or 'no' # 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].toLowerCase() 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].toLowerCase() or 'yes' top = top is 'yes' # Turn it into a boolean @filters[key].push @createFilter regexp, op, hl, top # Only execute filter types that contain valid filters. unless @filters[key].length delete @filters[key] if Object.keys(@filters).length g.callbacks.push @node createFilter: (regexp, op, hl, top) -> test = if typeof regexp is 'string' # MD5 checking (value) -> regexp is value else (value) -> regexp.test value (value, isOP) -> if isOP and op is 'no' or !isOP and op is 'only' return false unless test value return false if hl return [hl, top] true node: (post) -> return if post.isInlined {isOP, el} = post for key of Filter.filters value = Filter[key] post if value is false # Continue if there's nothing to filter (no tripcode for example). continue for filter in Filter.filters[key] unless result = filter value, isOP continue # Hide if result is true if isOP unless g.REPLY ThreadHiding.hide post.el.parentNode else continue else ReplyHiding.hide post.root return # Highlight if isOP $.addClass el, result[0] else $.addClass el.parentNode, result[0] if isOP and result[1] and not g.REPLY # Put the highlighted OPs' threads on top of the board pages... thisThread = el.parentNode # ...before the first non highlighted thread. if firstThread = $ 'div[class=op]' $.before firstThread.parentNode, [thisThread, thisThread.nextElementSibling] name: (post) -> name = if post.isOP then $ '.postername', post.el else $ '.commentpostername', post.el name.textContent uniqueid: (post) -> if uid = $ '.posteruid', post.el return uid.textContent false tripcode: (post) -> if trip = $ '.postertrip', post.el return trip.textContent false mod: (post) -> if mod = (if post.isOP then $ '.commentpostername', post.el else $ '.commentpostername ~ .commentpostername', post.el) return mod.textContent false email: (post) -> if mail = $ '.linkmail', post.el return mail.href false subject: (post) -> sub = if post.isOP then $ '.filetitle', post.el else $ '.replytitle', post.el sub.textContent comment: (post) -> text = [] # XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE is 7 nodes = d.evaluate './/br|.//text()', post.el.lastChild, null, 7, null for i in [0...nodes.snapshotLength] text.push if data = nodes.snapshotItem(i).data then data else '\n' text.join '' filename: (post) -> {filesize} = post if filesize and file = $ 'span', filesize return file.title false dimensions: (post) -> {filesize} = post if filesize and match = filesize.textContent.match /\d+x\d+/ return match[0] false filesize: (post) -> {img} = post if img return img.alt false md5: (post) -> {img} = post if img return img.getAttribute 'md5' false StrikethroughQuotes = init: -> g.callbacks.push @node node: (post) -> return if post.isInlined for quote in post.quotes if (el = $.id quote.hash[1..]) and el.parentNode.parentNode.parentNode.hidden $.addClass quote, 'filtered' ReplyHiding.hide post.root if conf['Recursive Filtering'] return ExpandComment = init: -> for a in $$ '.abbr > a' $.on a, 'click', ExpandComment.expand return expand: (e) -> e.preventDefault() [_, threadID, replyID] = @href.match /(\d+)#(\d+)/ @textContent = "Loading #{replyID}..." threadID = @pathname.split('/').pop() or $.x('ancestor::div[@class="thread"]/div', @).id a = @ $.cache @pathname, (-> ExpandComment.parse @, a, threadID, replyID) parse: (req, a, threadID, replyID) -> if req.status isnt 200 a.textContent = "#{req.status} #{req.statusText}" return doc = d.implementation.createHTMLDocument null doc.documentElement.innerHTML = req.responseText bq = if threadID is replyID # OP $ 'blockquote', doc else $ 'blockquote', doc.getElementById replyID $.replace a.parentNode.parentNode, bq quotes = $$ '.quotelink', bq for quote in quotes if quote.getAttribute('href') is quote.hash quote.pathname = "/#{g.BOARD}/res/#{threadID}" post = threadId: threadID quotes: quotes backlinks: [] if conf['Quote Preview'] QuotePreview.node post if conf['Quote Inline'] QuoteInline.node post if conf['Indicate OP quote'] QuoteOP.node post if conf['Indicate Cross-thread Quotes'] QuoteCT.node post if conf['Resurrect Quotes'] DeadQuotes.node post ExpandThread = init: -> for span in $$ '.omittedposts' a = $.el 'a', textContent: "+ #{span.textContent}" className: 'omittedposts' href: 'javascript:;' $.on a, 'click', ExpandThread.cb.toggle $.replace span, a cb: toggle: -> thread = @parentNode ExpandThread.toggle thread toggle: (thread) -> threadID = thread.firstChild.id pathname = "/#{g.BOARD}/res/#{threadID}" a = $ '.omittedposts', thread # \u00d7 is × switch a.textContent[0] when '+' $('.op .container', thread)?.textContent = '' a.textContent = a.textContent.replace '+', '\u00d7 Loading...' $.cache pathname, (-> ExpandThread.parse @, pathname, thread, a) when '\u00d7' a.textContent = a.textContent.replace '\u00d7 Loading...', '+' $.cache.requests[pathname].abort() when '-' a.textContent = a.textContent.replace '-', '+' #goddamit moot num = switch g.BOARD when 'b', 'vg' then 3 when 't' then 1 else 5 table = $.x "following::br[@clear]/preceding::table[#{num}]", a while (prev = table.previousSibling) and (prev.nodeName isnt 'A') $.rm prev for backlink in $$ '.backlink', $ '.op', thread $.rm backlink if !$.id backlink.hash[1..] parse: (req, pathname, thread, a) -> if req.status isnt 200 a.textContent = "#{req.status} #{req.statusText}" $.off a, 'click', ExpandThread.cb.toggle return a.textContent = a.textContent.replace '\u00d7 Loading...', '-' body = $.el 'body', innerHTML: req.responseText nodes = [] for reply in $$ '.reply', body for quote in $$ '.quotelink', reply if (href = quote.getAttribute('href')) is quote.hash #add pathname to normal quotes quote.pathname = pathname else if href isnt quote.href #fix x-thread links, not x-board ones quote.href = "res/#{href}" link = $ '.quotejs', reply link.href = "res/#{thread.firstChild.id}##{reply.id}" link.nextSibling.href = "res/#{thread.firstChild.id}#q#{reply.id}" nodes.push reply.parentNode.parentNode.parentNode # eat everything, then replace with fresh full posts while (next = a.nextSibling) and not next.clear #br[clear] $.rm next $.before next, nodes ReplyHiding = init: -> @td = $.el 'td', noWrap: true className: 'replyhider' innerHTML: '[ - ]' g.callbacks.push @node node: (post) -> return if post.class td = ReplyHiding.td.cloneNode true $.on td.firstChild, 'click', ReplyHiding.toggle $.replace post.el.previousSibling, td if post.id of g.hiddenReplies ReplyHiding.hide post.root toggle: -> parent = @parentNode if parent.className is 'replyhider' ReplyHiding.hide parent.parentNode.parentNode.parentNode id = parent.nextSibling.id for quote in $$ ".quotelink[href='##{id}'], .backlink[href='##{id}']" $.addClass quote, 'filtered' g.hiddenReplies[id] = Date.now() else table = parent.nextSibling table.hidden = false $.rm parent id = table.firstChild.firstChild.lastChild.id for quote in $$ ".quotelink[href='##{id}'], .backlink[href='##{id}']" $.removeClass quote, 'filtered' delete g.hiddenReplies[id] $.set "hiddenReplies/#{g.BOARD}/", g.hiddenReplies hide: (table) -> return if table.hidden # already hidden by filter table.hidden = true return unless conf['Show Stubs'] name = $('td[id] > .commentpostername', table).textContent uid = $('td[id] > .posteruid', table)?.textContent or '' trip = $('td[id] > .postertrip', table)?.textContent or '' div = $.el 'div', className: 'stub' innerHTML: "[ + ] #{name} #{uid} #{trip}" $.on div.firstChild, 'click', ReplyHiding.toggle $.before table, div Keybinds = init: -> for node in $$ '[accesskey]' node.removeAttribute 'accesskey' $.on d, 'keydown', Keybinds.keydown keydown: (e) -> if not (key = Keybinds.keyCode(e)) or /TEXTAREA|INPUT/.test(e.target.nodeName) and not (e.altKey or e.ctrlKey or e.keyCode is 27) return thread = Nav.getThread() switch key # QR & Options when conf.openQR Keybinds.qr thread, true when conf.openEmptyQR Keybinds.qr thread when conf.openOptions Options.dialog() unless $.id 'overlay' when conf.close if o = $.id 'overlay' Options.close.call o else if qr.el qr.close() when conf.submit qr.submit() if qr.el and !qr.status() when conf.spoiler ta = e.target return if ta.nodeName isnt 'TEXTAREA' value = ta.value selStart = ta.selectionStart selEnd = ta.selectionEnd ta.value = value[...selStart] + '[spoiler]' + value[selStart...selEnd] + '[/spoiler]' + value[selEnd..] range = 9 + selEnd # Move the caret to the end of the selection. ta.setSelectionRange range, range # Thread related when conf.watch Watcher.toggle thread when conf.update Updater.update() when conf.unreadCountTo0 Unread.replies = [] Unread.update() # Images when conf.expandImage Keybinds.img thread when conf.expandAllImages Keybinds.img thread, true # Board Navigation when conf.zero window.location = "/#{g.BOARD}/0#0" when conf.nextPage $('input[value=Next]')?.click() when conf.previousPage $('input[value=Previous]')?.click() # Thread Navigation when conf.nextThread return if g.REPLY Nav.scroll +1 when conf.previousThread return if g.REPLY Nav.scroll -1 when conf.expandThread ExpandThread.toggle thread when conf.openThread Keybinds.open thread when conf.openThreadTab Keybinds.open thread, true # Reply Navigation when conf.nextReply Keybinds.hl +1, thread when conf.previousReply Keybinds.hl -1, thread when conf.hide ThreadHiding.toggle thread if /\bthread\b/.test thread.className else return e.preventDefault() keyCode: (e) -> key = switch kc = e.keyCode when 8 '' when 27 'Esc' when 37 'Left' when 38 'Up' when 39 'Right' when 40 'Down' when 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90 #0-9, A-Z c = String.fromCharCode kc if e.shiftKey then c else c.toLowerCase() else null if key if e.altKey then key = 'alt+' + key if e.ctrlKey then key = 'ctrl+' + key key img: (thread, all) -> if all $.id('imageExpand').click() else thumb = $ 'img[md5]', $('.replyhl', thread) or thread ImageExpand.toggle thumb.parentNode qr: (thread, quote) -> if quote qr.quote.call $ '.quotejs + .quotejs', $('.replyhl', thread) or thread else qr.open() $('textarea', qr.el).focus() open: (thread, tab) -> id = thread.firstChild.id url = "http://boards.4chan.org/#{g.BOARD}/res/#{id}" if tab $.open url else location.href = url hl: (delta, thread) -> if td = $ '.replyhl', thread td.className = 'reply' td.removeAttribute 'tabindex' rect = td.getBoundingClientRect() if rect.bottom >= 0 and rect.top <= d.body.clientHeight # We're at least partially visible next = if delta is +1 $.x 'following::td[@class="reply"]', td else $.x 'preceding::td[@class="reply"]', td unless next td.className = 'replyhl' td.tabIndex = 0 td.focus() return return unless g.REPLY or $.x('ancestor::div[@class="thread"]', next) is thread rect = next.getBoundingClientRect() if rect.top < 0 or rect.bottom > d.body.clientHeight next.scrollIntoView delta is -1 next.className = 'replyhl' next.tabIndex = 0 next.focus() return replies = $$ '.reply', thread replies.reverse() if delta is -1 for reply in replies rect = reply.getBoundingClientRect() if delta is +1 and rect.top >= 0 or delta is -1 and rect.bottom <= d.body.clientHeight reply.className = 'replyhl' reply.tabIndex = 0 reply.focus() return Nav = # ◀ ▶ init: -> 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] $.add d.body, span prev: -> if g.REPLY window.scrollTo 0, 0 else Nav.scroll -1 next: -> if g.REPLY window.scrollTo 0, d.body.scrollHeight else Nav.scroll +1 getThread: (full) -> Nav.threads = $$ '.thread:not([hidden])' for thread, i in Nav.threads rect = thread.getBoundingClientRect() {bottom} = rect if bottom > 0 #we have not scrolled past if full return [thread, i, rect] return thread return $ 'form[name=delform]' scroll: (delta) -> [thread, i, rect] = Nav.getThread true {top} = rect #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} = Nav.threads[i]?.getBoundingClientRect() window.scrollBy 0, top qr = init: -> return unless $.id 'recaptcha_challenge_field_holder' g.callbacks.push @node setTimeout @asyncInit asyncInit: -> if conf['Hide Original Post Form'] link = $.el 'h1', innerHTML: "#{if g.REPLY then 'Quick Reply' else 'New Thread'}" $.on $('a', link), 'click', -> qr.open() $('select', qr.el).value = 'new' unless g.REPLY $('textarea', qr.el).focus() form = d.forms[0] $.before form, link # CORS is ignored for content script on Chrome, but not Safari/Oprah/Firefox. if /chrome/i.test navigator.userAgent qr.status ready: true else iframe = $.el 'iframe', id: 'iframe' src: 'http://sys.4chan.org/robots.txt' $.on iframe, 'error', -> @src = @src # Greasemonkey ghetto fix loadChecking = (iframe) -> unless qr.status.ready iframe.src = 'about:blank' setTimeout (-> iframe.src = 'http://sys.4chan.org/robots.txt'), 100 $.on iframe, 'load', -> if @src isnt 'about:blank' then setTimeout loadChecking, 500, @ $.add d.head, iframe # Prevent original captcha input from being focused on reload. script = $.el 'script', textContent: 'Recaptcha.focus_response_field=function(){}' $.add d.head, script $.rm script if conf['Persistent QR'] qr.dialog() qr.hide() if conf['Auto Hide QR'] $.on d, 'dragover', qr.dragOver $.on d, 'drop', qr.dropFile $.on d, 'dragstart', qr.drag $.on d, 'dragend', qr.drag node: (post) -> $.on $('.quotejs + .quotejs', post.el), 'click', qr.quote open: -> if qr.el qr.el.hidden = false qr.unhide() else qr.dialog() close: -> qr.el.hidden = true qr.message.send req: 'abort' d.activeElement.blur() $.removeClass qr.el, 'dump' for i in qr.replies qr.replies[0].rm() qr.cooldown.auto = false qr.status() qr.resetFileInput() if not conf['Remember Spoiler'] and (spoiler = $.id 'spoiler').checked spoiler.click() qr.cleanError() hide: -> d.activeElement.blur() $.addClass qr.el, 'autohide' $.id('autohide').checked = true unhide: -> $.removeClass qr.el, 'autohide' $.id('autohide').checked = false toggleHide: -> @checked and qr.hide() or qr.unhide() error: (err, node) -> el = $ '.warning', qr.el el.textContent = err $.replace el.firstChild, node if node qr.open() if /captcha|verification/i.test err # Focus the captcha input on captcha error. $('[autocomplete]', qr.el).focus() alert err if d.hidden or d.oHidden or d.mozHidden or d.webkitHidden cleanError: -> $('.warning', qr.el).textContent = null status: (data={}) -> if data.ready qr.status.ready = true qr.status.banned = data.banned else unless qr.status.ready value = 'Loading' disabled = true if g.dead value = 404 disabled = true qr.cooldown.auto = false else if qr.status.banned value = 'Banned' disabled = true else # do not cancel `value = 'Loading'` once the cooldown is over value = qr.cooldown.seconds or data.progress or value return unless qr.el {input} = qr.status input.value = if qr.cooldown.auto and conf['Cooldown'] if value then "Auto #{value}" else 'Auto' else value or 'Submit' input.disabled = disabled or false cooldown: init: -> return unless conf['Cooldown'] qr.cooldown.start $.get "/#{g.BOARD}/cooldown", 0 $.sync "/#{g.BOARD}/cooldown", qr.cooldown.start start: (timeout) -> seconds = Math.floor (timeout - Date.now()) / 1000 qr.cooldown.count seconds set: (seconds) -> return unless conf['Cooldown'] qr.cooldown.count seconds $.set "/#{g.BOARD}/cooldown", Date.now() + seconds*SECOND count: (seconds) -> return unless 0 <= seconds <= 60 setTimeout qr.cooldown.count, 1000, seconds-1 qr.cooldown.seconds = seconds if seconds is 0 $.delete "/#{g.BOARD}/cooldown" qr.submit() if qr.cooldown.auto qr.status() quote: (e) -> e?.preventDefault() qr.open() unless g.REPLY $('select', qr.el).value = $.x('ancestor::div[@class="thread"]', @).firstChild.id # Make sure we get the correct number, even with XXX censors id = @previousElementSibling.hash[1..] text = ">>#{id}\n" sel = window.getSelection() if (s = sel.toString()) and id is $.x('ancestor-or-self::blockquote/preceding-sibling::input', sel.anchorNode)?.name s = s.replace /\n/g, '\n>' text += ">#{s}\n" ta = $ 'textarea', qr.el caretPos = ta.selectionStart # Replace selection for text. # onchange event isn't triggered, save value. qr.selected.el.lastChild.textContent = qr.selected.com = ta.value = ta.value[...caretPos] + text + ta.value[ta.selectionEnd..] ta.focus() # Move the caret to the end of the new quote. ta.selectionEnd = ta.selectionStart = caretPos + text.length range = caretPos + text.length ta.setSelectionRange range, range drag: (e) -> # Let it drag anything from the page. i = if e.type is 'dragstart' then 'off' else 'on' $[i] d, 'dragover', qr.dragOver $[i] d, 'drop', qr.dropFile dragOver: (e) -> e.preventDefault() e.dataTransfer.dropEffect = 'copy' # cursor feedback dropFile: (e) -> # Let it only handle files from the desktop. return unless e.dataTransfer.files.length e.preventDefault() qr.open() qr.fileInput.call e.dataTransfer $.addClass qr.el, 'dump' fileInput: -> qr.cleanError() # Set or change current reply's file. if @files.length is 1 file = @files[0] if file.size > @max qr.error 'File too large.' qr.resetFileInput() else if -1 is qr.mimeTypes.indexOf file.type qr.error 'Unsupported file type.' qr.resetFileInput() else qr.selected.setFile file return # Create new replies with these files. for file in @files if file.size > @max qr.error "File #{file.name} is too large." break else if -1 is qr.mimeTypes.indexOf file.type qr.error "#{file.name}: Unsupported file type." break unless qr.replies[qr.replies.length - 1].file # set last reply's file qr.replies[qr.replies.length - 1].setFile file else new qr.reply().setFile file $.addClass qr.el, 'dump' qr.resetFileInput() # reset input resetFileInput: -> $('[type=file]', qr.el).value = null replies: [] reply: class constructor: -> # set values, or null, to avoid 'undefined' values in inputs prev = qr.replies[qr.replies.length-1] persona = $.get 'qr.persona', {} @name = if prev then prev.name else persona.name or null @email = if prev and !/^sage$/.test prev.email then prev.email else persona.email or null @sub = if prev and conf['Remember Subject'] then prev.sub else if conf['Remember Subject'] then persona.sub else null @spoiler = if prev and conf['Remember Spoiler'] then prev.spoiler else false @com = null @el = $.el 'a', className: 'preview' draggable: true href: 'javascript:;' innerHTML: '×' $('input', @el).checked = @spoiler $.on @el, 'click', => @select() $.on $('.remove', @el), 'click', (e) => e.stopPropagation() @rm() $.on $('label', @el), 'click', (e) => e.stopPropagation() $.on $('input', @el), 'change', (e) => @spoiler = e.target.checked $.id('spoiler').checked = @spoiler if @el.id is 'selected' $.before $('#addReply', qr.el), @el $.on @el, 'dragstart', @dragStart $.on @el, 'dragenter', @dragEnter $.on @el, 'dragleave', @dragLeave $.on @el, 'dragover', @dragOver $.on @el, 'dragend', @dragEnd $.on @el, 'drop', @drop qr.replies.push @ setFile: (@file) -> @el.title = file.name $('label', @el).hidden = false if qr.spoiler if file.type is 'application/pdf' @el.style.backgroundImage = null return url = window.URL or window.webkitURL url.revokeObjectURL @url # Create a redimensioned thumbnail. fileUrl = url.createObjectURL file img = $.el 'img' $.on img, 'load', => # Generate thumbnails only if they're really big. # Resized pictures through canvases look like ass, # so we generate thumbnails `s` times bigger then expected # to avoid crappy resized quality. s = 90*3 if img.height < s or img.width < s @url = fileUrl @el.style.backgroundImage = "url(#{@url})" return if img.height <= img.width img.width = s / img.height * img.width img.height = s else img.height = s / img.width * img.height img.width = s c = $.el 'canvas' c.height = img.height c.width = img.width c.getContext('2d').drawImage img, 0, 0, img.width, img.height # Support for toBlob fucking when? data = atob c.toDataURL().split(',')[1] # DataUrl to Binary code from Aeosynth's 4chan X repo l = data.length ui8a = new Uint8Array l for i in [0...l] ui8a[i] = data.charCodeAt i bb = new (window.MozBlobBuilder or window.WebKitBlobBuilder)() bb.append ui8a.buffer @url = url.createObjectURL bb.getBlob 'image/png' @el.style.backgroundImage = "url(#{@url})" url.revokeObjectURL fileUrl img.src = fileUrl rmFile: -> qr.resetFileInput() delete @file @el.title = null @el.style.backgroundImage = null $('label', @el).hidden = true if qr.spoiler (window.URL or window.webkitURL).revokeObjectURL @url select: -> qr.selected?.el.id = null qr.selected = @ @el.id = 'selected' # Scroll the list to center the focused reply. rectEl = @el.getBoundingClientRect() rectList = @el.parentNode.getBoundingClientRect() @el.parentNode.scrollLeft += rectEl.left + rectEl.width/2 - rectList.left - rectList.width/2 # Load this reply's values. for data in ['name', 'email', 'sub', 'com'] $("[name=#{data}]", qr.el).value = @[data] $('#spoiler', qr.el).checked = @spoiler dragStart: -> $.addClass @, 'drag' dragEnter: -> $.addClass @, 'over' dragLeave: -> $.removeClass @, 'over' dragOver: (e) -> e.preventDefault() e.dataTransfer.dropEffect = 'move' drop: -> el = $ '.drag', @parentNode index = (el) -> Array::slice.call(el.parentNode.children).indexOf el oldIndex = index el newIndex = index @ if oldIndex < newIndex $.after @, el else $.before @, el reply = qr.replies.splice(oldIndex, 1)[0] qr.replies.splice newIndex, 0, reply dragEnd: -> $.removeClass @, 'drag' if el = $ '.over', @parentNode $.removeClass el, 'over' rm: -> qr.resetFileInput() $.rm @el index = qr.replies.indexOf @ if qr.replies.length is 1 new qr.reply().select() else if @el.id is 'selected' (qr.replies[index-1] or qr.replies[index+1]).select() qr.replies.splice index, 1 (window.URL or window.webkitURL).revokeObjectURL @url delete @ captcha: init: -> @img = $ '.captcha > img', qr.el @input = $ '[autocomplete]', qr.el @challenge = $.id 'recaptcha_challenge_field_holder' $.on @img.parentNode, 'click', @reload $.on @input, 'keydown', @keydown $.on @challenge, 'DOMNodeInserted', => @load() $.sync 'captchas', (arr) => @count arr.length @count $.get('captchas', []).length # start with an uncached captcha @reload() save: -> return unless response = @input.value captchas = $.get 'captchas', [] # Remove old captchas. while (captcha = captchas[0]) and captcha.time < Date.now() captchas.shift() captchas.push challenge: @challenge.firstChild.value response: response time: @timeout $.set 'captchas', captchas @count captchas.length @reload() load: -> # Timeout is available at RecaptchaState.timeout in seconds. @timeout = Date.now() + 26*MINUTE challenge = @challenge.firstChild.value @img.alt = challenge @img.src = "http://www.google.com/recaptcha/api/image?c=#{challenge}" @input.value = null count: (count) -> @input.placeholder = switch count when 0 'Verification (Shift + Enter to cache)' when 1 'Verification (1 cached captcha)' else "Verification (#{count} cached captchas)" @input.alt = count # For XTRM RICE. reload: (focus) -> window.location = 'javascript:Recaptcha.reload()' # Focus if we meant to. qr.captcha.input.focus() if focus keydown: (e) -> c = qr.captcha if e.keyCode is 8 and not c.input.value c.reload() else if e.keyCode is 13 and e.shiftKey c.save() else return e.preventDefault() dialog: -> qr.el = ui.dialog 'qr', 'top:0;right:0;', '
Quick Reply ×
+
' if conf['Remember QR size'] and engine is 'gecko' $.on ta = $('textarea', qr.el), 'mouseup', -> $.set 'qr.size', @style.cssText ta.style.cssText = $.get 'qr.size', '' # Allow only this board's supported files. mimeTypes = $('.rules').firstChild.textContent.match(/: (.+) /)[1].toLowerCase().replace /\w+/g, (type) -> switch type when 'jpg' 'image/jpeg' when 'pdf' 'application/pdf' else "image/#{type}" qr.mimeTypes = mimeTypes.split ', ' # Add empty mimeType to avoid errors with URLs selected in Window's file dialog. qr.mimeTypes.push '' fileInput = $ '[type=file]', qr.el fileInput.max = $('[name=MAX_FILE_SIZE]').value fileInput.accept = mimeTypes qr.spoiler = !!$ '#com_submit + label' spoiler = $ '#spoilerLabel', qr.el spoiler.hidden = !qr.spoiler unless g.REPLY # Make a list with visible threads and an option to create a new one. threads = '' for thread in $$ '.op' threads += "" $.prepend $('.move > span', qr.el), $.el 'select' innerHTML: threads title: 'Create a new thread / Reply to a thread' $.on $('select', qr.el), 'mousedown', (e) -> e.stopPropagation() $.on $('#autohide', qr.el), 'change', qr.toggleHide $.on $('.close', qr.el), 'click', qr.close $.on $('#dump', qr.el), 'click', -> qr.el.classList.toggle 'dump' $.on $('#addReply', qr.el), 'click', -> new qr.reply().select() $.on $('form', qr.el), 'submit', qr.submit $.on $('textarea', qr.el), 'keyup', -> qr.selected.el.lastChild.textContent = @value $.on fileInput, 'change', qr.fileInput $.on fileInput, 'click', (e) -> if e.shiftKey then qr.selected.rmFile() or e.preventDefault() $.on spoiler.firstChild, 'change', -> $('input', qr.selected.el).click() $.on $('.warning', qr.el), 'click', qr.cleanError new qr.reply().select() # save selected reply's data for name in ['name', 'email', 'sub', 'com'] input = $ "[name=#{name}]", qr.el for event in ['textInput', 'keyup', 'change', 'paste'] $.on input, event, -> qr.selected[@name] = @value # Disable auto-posting if you're typing in the first reply # during the last 5 seconds of the cooldown. if qr.cooldown.auto and qr.selected is qr.replies[0] and parseInt(qr.status.input.value.match /\d+/) < 6 qr.cooldown.auto = false # sync between tabs $.sync 'qr.persona', (persona) -> return unless qr.el.hidden for key, val of persona qr.selected[key] = val $("[name=#{key}]", qr.el).value = val qr.status.input = $ '[type=submit]', qr.el qr.status() qr.cooldown.init() qr.captcha.init() $.add d.body, qr.el # Create a custom event when the QR dialog is first initialized. # Use it to extend the QR's functionalities, or for XTRM RICE. e = d.createEvent 'CustomEvent' e.initEvent 'QRDialogCreation', true, false qr.el.dispatchEvent e submit: (e) -> e?.preventDefault() if qr.cooldown.seconds qr.cooldown.auto = !qr.cooldown.auto qr.status() return qr.message.send req: 'abort' reply = qr.replies[0] # prevent errors unless reply.com or reply.file err = 'No file selected.' else # get oldest valid captcha captchas = $.get 'captchas', [] # remove old captchas while (captcha = captchas[0]) and captcha.time < Date.now() captchas.shift() if captcha = captchas.shift() challenge = captcha.challenge response = captcha.response else challenge = qr.captcha.img.alt if response = qr.captcha.input.value then qr.captcha.reload() $.set 'captchas', captchas qr.captcha.count captchas.length unless response err = 'No valid captcha.' if err # stop auto-posting qr.cooldown.auto = false qr.status() qr.error err return qr.cleanError() threadID = g.THREAD_ID or $('select', qr.el).value # Enable auto-posting if we have stuff to post, disable it otherwise. qr.cooldown.auto = qr.replies.length > 1 if conf['Auto Hide QR'] and not qr.cooldown.auto qr.hide() if conf['Thread Watcher'] and conf['Auto Watch Reply'] and threadID isnt 'new' Watcher.watch threadID post = board: g.BOARD resto: threadID name: reply.name email: reply.email sub: reply.sub com: reply.com upfile: reply.file spoiler: reply.spoiler mode: 'regist' pwd: if m = d.cookie.match(/4chan_pass=([^;]+)/) then decodeURIComponent m[1] else $('[name=pwd]').value recaptcha_challenge_field: challenge recaptcha_response_field: response + ' ' # Starting to upload might take some time. # Provide some feedback that we're starting to submit. qr.status progress: '...' if engine is 'gecko' and reply.file # https://bugzilla.mozilla.org/show_bug.cgi?id=673742 # We plan to allow postMessaging Files and FileLists across origins, # that just needs a more in depth security review. file = {} reader = new FileReader() reader.onload = -> file.buffer = @result file.name = reply.file.name file.type = reply.file.type post.upfile = file qr.message.send post reader.readAsBinaryString reply.file return # CORS is ignored for content script on Chrome, but not Safari/Oprah/Firefox. if /chrome/i.test navigator.userAgent qr.message.post post return qr.message.send post response: (html) -> doc = $.el 'a', innerHTML: html # Check for ban. if $('title', doc).textContent is '4chan - Banned' qr.status ready: true, banned: true return unless b = $ 'td b', doc err = 'Connection error with sys.4chan.org.' else if b.childElementCount # error! if b.firstChild.tagName # duplicate image link node = b.firstChild node.target = '_blank' err = b.firstChild.textContent if err if /captcha|verification/i.test(err) or err is 'Connection error with sys.4chan.org.' # Enable auto-post if we have some cached captchas. qr.cooldown.auto = !!$.get('captchas', []).length # Too many frequent mistyped captchas will auto-ban you! # On connection error, the post most likely didn't go through. qr.cooldown.set 2 else # stop auto-posting qr.cooldown.auto = false qr.status() qr.error err, node return reply = qr.replies[0] persona = $.get 'qr.persona', {} persona = name: reply.name email: if /^sage$/.test reply.email then persona.email else reply.email sub: if conf['Remember Subject'] then reply.sub else null $.set 'qr.persona', persona [_, thread, postNumber] = b.lastChild.textContent.match /thread:(\d+),no:(\d+)/ if thread is '0' # new thread if conf['Thread Watcher'] and conf['Auto Watch'] $.set 'autoWatch', postNumber # auto-noko location.pathname = "/#{g.BOARD}/res/#{postNumber}" else # Enable auto-posting if we have stuff to post, disable it otherwise. qr.cooldown.auto = qr.replies.length > 1 qr.cooldown.set if /sage/i.test reply.email then 60 else 30 if conf['Open Reply in New Tab'] && !g.REPLY && !qr.cooldown.auto $.open "http://boards.4chan.org/#{g.BOARD}/res/#{thread}##{postNumber}" if conf['Persistent QR'] or qr.cooldown.auto reply.rm() else qr.close() if g.REPLY and (conf['Unread Count'] or conf['Unread Favicon']) Unread.foresee.push postNumber if g.REPLY and conf['Thread Updater'] and conf['Auto Update This'] Updater.update() qr.status() qr.resetFileInput() message: send: (data) -> # CORS is ignored for content script on Chrome, but not Safari/Oprah/Firefox. if /chrome/i.test navigator.userAgent qr.message.receive data return data.qr = true host = location.hostname window = if host is 'boards.4chan.org' $.id('iframe').contentWindow else parent window.postMessage data, '*' receive: (data) -> req = data.req delete data.req delete data.qr switch req when 'abort' qr.ajax?.abort() qr.message.send req: 'status' when 'response' # xhr response qr.response data.html when 'status' qr.status data else qr.message.post data # Reply object: we're posting post: (data) -> url = "http://sys.4chan.org/#{data.board}/post" # Do not append these values to the form. delete data.board # File with filename upload fix from desuwa if engine is 'gecko' and data.upfile # All of this is fucking retarded. unless data.binary toBin = (data, name, val) -> bb = new MozBlobBuilder() bb.append val r = new FileReader() r.onload = -> data[name] = r.result unless --i qr.message.post data r.readAsBinaryString bb.getBlob 'text/plain' i = Object.keys(data).length for name, val of data if typeof val is 'object' # File. toBin the filename. toBin data.upfile, 'name', data.upfile.name else if typeof val is 'boolean' if val toBin data, name, String val else i-- else toBin data, name, val data.board = url.split('/')[3] data.binary = true return delete data.binary boundary = '-------------SMCD' + Date.now(); parts = [] parts.push 'Content-Disposition: form-data; name="upfile"; filename="' + data.upfile.name + '"\r\n' + 'Content-Type: ' + data.upfile.type + '\r\n\r\n' + data.upfile.buffer + '\r\n' delete data.upfile for name, val of data parts.push 'Content-Disposition: form-data; name="' + name + '"\r\n\r\n' + val + '\r\n' if val form = '--' + boundary + '\r\n' + parts.join('--' + boundary + '\r\n') + '--' + boundary + '--\r\n' else form = new FormData() for name, val of data form.append name, val if val callbacks = onload: -> qr.message.send req: 'response' html: @response opts = form: form type: 'post' upCallbacks: onload: -> qr.message.send req: 'status' progress: '...' onprogress: (e) -> qr.message.send req: 'status' progress: "#{Math.round e.loaded / e.total * 100}%" if boundary opts.headers = 'Content-Type': 'multipart/form-data;boundary=' + boundary try qr.ajax = $.ajax url, callbacks, opts catch e # CORS disabled error: redirecting to banned page ;_; if e.name is 'NETWORK_ERR' qr.message.send req: 'status', ready: true, banned: true Options = init: -> for home in [$.id('navtopr'), $.id('navbotr')] a = $.el 'a', textContent: '4chan X' href: 'javascript:;' $.on a, 'click', Options.dialog $.replace home.firstElementChild, a unless $.get 'firstrun' $.set 'firstrun', true Options.dialog() dialog: -> dialog = $.el 'div' id: 'options' className: 'reply dialog' innerHTML: '
4chan X | ' + VERSION + ' | Issues
| | | |

Sauce is disabled.
Lines starting with a # will be ignored.
Filter is disabled.
Use regular expressions, one per line.
Lines starting with a # will be ignored.
For example, /weeaboo/i will filter posts containing `weeaboo` case-insensitive.

Name:

Unique ID:

Tripcode:

Admin/Mod:

E-mail:

Subject:

Comment:

Filename:

Image dimensions:

Filesize:

Image MD5 (uses exact string matching, not regular expressions):

Quote Backlinks are disabled.
Time Formatting is disabled.
File Info Formatting is disabled.
Unread Favicon is disabled.
Unread favicons
Keybinds are disabled.
Allowed keys: Ctrl, Alt, a-z, A-Z, 0-9, Up, Down, Right, Left.
ActionsKeybinds
' #main for key, obj of config.main ul = $.el 'ul', textContent: key for key, arr of obj checked = if conf[key] then 'checked' else '' description = arr[1] li = $.el 'li', innerHTML: ": #{description}" $.on $('input', li), 'click', $.cb.checked $.add ul, li $.add $('#main_tab + div', dialog), ul hiddenThreads = $.get "hiddenThreads/#{g.BOARD}/", {} hiddenNum = Object.keys(g.hiddenReplies).length + Object.keys(hiddenThreads).length li = $.el 'li', innerHTML: " : Forget all hidden posts. Useful if you accidentally hide a post and have \"Show Stubs\" disabled." $.on $('button', li), 'click', Options.clearHidden $.add $('ul:nth-child(2)', dialog), li #filter & sauce for ta in $$ 'textarea', dialog ta.textContent = conf[ta.name] $.on ta, 'change', $.cb.value #rice (back = $ '[name=backlink]', dialog).value = conf['backlink'] (time = $ '[name=time]', dialog).value = conf['time'] (fileInfoR = $ '[name=fileInfoR]', dialog).value = conf['fileInfoR'] (fileInfoT = $ '[name=fileInfoT]', dialog).value = conf['fileInfoT'] $.on back, 'keyup', $.cb.value $.on back, 'keyup', Options.backlink $.on time, 'keyup', $.cb.value $.on time, 'keyup', Options.time $.on fileInfoR, 'keyup', $.cb.value $.on fileInfoR, 'keyup', Options.fileInfo $.on fileInfoT, 'keyup', $.cb.value $.on fileInfoT, 'keyup', Options.fileInfo favicon = $ 'select', dialog favicon.value = conf['favicon'] $.on favicon, 'change', $.cb.value $.on favicon, 'change', Options.favicon #keybinds for key, arr of config.hotkeys tr = $.el 'tr', innerHTML: "#{arr[1]}" input = $ 'input', tr input.value = conf[key] $.on input, 'keydown', Options.keybind $.add $('#keybinds_tab + div tbody', dialog), tr #indicate if the settings require a feature to be enabled indicators = {} for indicator in $$ '.warning', dialog key = indicator.firstChild.textContent indicator.hidden = conf[key] indicators[key] = indicator $.on $("[name='#{key}']", dialog), 'click', -> indicators[@name].hidden = @checked overlay = $.el 'div', id: 'overlay' $.on overlay, 'click', Options.close $.on dialog, 'click', (e) -> e.stopPropagation() $.add overlay, dialog $.add d.body, overlay d.body.style.setProperty 'overflow', 'hidden', null Options.backlink.call back Options.time.call time Options.fileInfo.call fileInfoR Options.fileInfo.call fileInfoT Options.favicon.call favicon close: -> $.rm this d.body.style.removeProperty 'overflow' clearHidden: -> #'hidden' might be misleading; it's the number of IDs we're *looking* for, # not the number of posts actually hidden on the page. $.delete "hiddenReplies/#{g.BOARD}/" $.delete "hiddenThreads/#{g.BOARD}/" @textContent = "hidden: 0" g.hiddenReplies = {} keybind: (e) -> return if e.keyCode is 9 e.preventDefault() e.stopPropagation() return unless (key = Keybinds.keyCode e)? @value = key $.cb.value.call @ time: -> Time.foo() Time.date = new Date() $.id('timePreview').textContent = Time.funk Time backlink: -> $.id('backlinkPreview').textContent = conf['backlink'].replace /%id/, '123456789' fileInfo: -> type = if @name is 'fileInfoR' then 0 else 1 FileInfo.data = link: '1329791824.png' size: 996 unit: 'KB' resolution: '1366x768' fullname: '[a.f.k.] Sayonara Zetsubou Sensei - 09.avi_snapshot_03.34_[2011.02.20_06.58.00].jpg' shortname: '[a.f.k.] Sayonara Zetsubou Sen(...).jpg' type: type FileInfo.setFormats() $.id("#{@name}Preview").innerHTML = FileInfo.funks[type] FileInfo favicon: -> Favicon.switch() Unread.update true @nextElementSibling.innerHTML = " " Threading = op: (node) -> nodes = [] until node.nodeName is 'BLOCKQUOTE' nodes.push node node = node.nextSibling nodes.push node # Add the blockquote. node = node.nextSibling op = $.el 'div', className: 'op' $.add op, nodes op.id = $('input', op).name $.before node, op thread: (node) -> node = Threading.op node return if g.REPLY nodes = [] until node.nodeName is 'HR' nodes.push node node = node.nextElementSibling # Skip text nodes. div = $.el 'div', className: 'thread' $.add div, nodes $.before node, div node = node.nextElementSibling # {N,}SFW unless node.align or node.nodeName is 'CENTER' Threading.thread node ThreadHiding = init: -> hiddenThreads = $.get "hiddenThreads/#{g.BOARD}/", {} for thread in $$ '.thread' op = thread.firstChild a = $.el 'a', textContent: '[ - ]' href: 'javascript:;' $.on a, 'click', ThreadHiding.cb $.prepend op, a if op.id of hiddenThreads ThreadHiding.hide thread return cb: -> ThreadHiding.toggle @parentNode.parentNode toggle: (thread) -> hiddenThreads = $.get "hiddenThreads/#{g.BOARD}/", {} id = $('.op', thread).id if thread.hidden or thread.firstChild.className is 'block' ThreadHiding.show thread delete hiddenThreads[id] else ThreadHiding.hide thread hiddenThreads[id] = Date.now() $.set "hiddenThreads/#{g.BOARD}/", hiddenThreads hide: (thread) -> unless conf['Show Stubs'] thread.hidden = true thread.nextSibling.hidden = true return return if thread.firstChild.className is 'block' # already hidden by filter num = 0 if span = $ '.omittedposts', thread num = Number span.textContent.match(/\d+/)[0] num += $$('.op ~ table', thread).length text = if num is 1 then '1 reply' else "#{num} replies" op = $ '.op', thread name = $('.postername', op).textContent uid = $('.posteruid', op)?.textContent or '' trip = $('.postertrip', op)?.textContent or '' a = $.el 'a', innerHTML: "[ + ] #{name} #{uid} #{trip} (#{text})" href: 'javascript:;' $.on a, 'click', ThreadHiding.cb div = $.el 'div', className: 'block' $.add div, a $.prepend thread, div show: (thread, id) -> $.rm $ '.block', thread thread.hidden = false thread.nextSibling.hidden = false Updater = init: -> html = "
-#{conf['Interval']}
" {checkbox} = config.updater for name of checkbox title = checkbox[name][1] checked = if conf[name] then 'checked' else '' html += "
" checked = if conf['Auto Update'] then 'checked' else '' html += "
" dialog = ui.dialog 'updater', 'bottom: 0; right: 0;', html @count = $ '#count', dialog @timer = $ '#timer', dialog @br = $ 'br[clear]' for input in $$ 'input', dialog if input.type is 'checkbox' $.on input, 'click', $.cb.checked if input.name is 'Scroll BG' $.on input, 'click', @cb.scrollBG @cb.scrollBG.call input if input.name is 'Verbose' $.on input, 'click', @cb.verbose @cb.verbose.call input else if input.name is 'Auto Update This' $.on input, 'click', @cb.autoUpdate @cb.autoUpdate.call input # Required for the QR's update after posting. conf[input.name] = input.checked else if input.name is 'Interval' $.on input, 'change', -> conf['Interval'] = @value = parseInt(@value, 10) or conf['Interval'] $.on input, 'change', $.cb.value else if input.type is 'button' $.on input, 'click', @update $.add d.body, dialog @retryCoef = 10 @lastModified = 0 cb: verbose: -> if conf['Verbose'] Updater.count.textContent = '+0' Updater.timer.hidden = false else $.extend Updater.count, className: '' textContent: 'Thread Updater' Updater.timer.hidden = true autoUpdate: -> if @checked Updater.timeoutID = setTimeout Updater.timeout, 1000 else clearTimeout Updater.timeoutID scrollBG: -> Updater.scrollBG = if @checked -> true else -> !(d.hidden or d.oHidden or d.mozHidden or d.webkitHidden) update: -> if @status is 404 Updater.timer.textContent = '' Updater.count.textContent = 404 Updater.count.className = 'warning' clearTimeout Updater.timeoutID g.dead = true if conf['Unread Count'] Unread.title = Unread.title.match(/^.+-/)[0] + ' 404' else d.title = d.title.match(/^.+-/)[0] + ' 404' Unread.update true qr.message.send req: 'abort' qr.status() Favicon.update() return Updater.retryCoef = 10 Updater.timer.textContent = '-' + conf['Interval'] ### 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, avoid unnecessary computation, and won't load images and scripts when parsing the response. ### if @status is 304 if conf['Verbose'] Updater.count.textContent = '+0' Updater.count.className = null return Updater.lastModified = @getResponseHeader 'Last-Modified' body = $.el 'body', innerHTML: @responseText id = $('input', Updater.br.previousElementSibling).name nodes = [] for reply in $$('.reply', body).reverse() break if reply.id <= id #make sure to not insert older posts nodes.push reply.parentNode.parentNode.parentNode #table newPosts = nodes.length scroll = conf['Scrolling'] && Updater.scrollBG() && newPosts && Updater.br.previousElementSibling.getBoundingClientRect().bottom - d.body.clientHeight < 25 if conf['Verbose'] Updater.count.textContent = "+#{newPosts}" Updater.count.className = if newPosts then 'new' else null $.before Updater.br, nodes.reverse() if scroll Updater.br.previousSibling.scrollIntoView() timeout: -> Updater.timeoutID = setTimeout Updater.timeout, 1000 n = 1 + Number Updater.timer.textContent if n is 0 Updater.update() else if n is Updater.retryCoef Updater.retryCoef += 10 * (Updater.retryCoef < 120) Updater.retry() else Updater.timer.textContent = n retry: -> @count.textContent = 'Retry' @count.className = '' @update() update: -> Updater.timer.textContent = 0 Updater.request?.abort() #fool the cache url = location.pathname + '?' + Date.now() Updater.request = $.ajax url, onload: Updater.cb.update, headers: 'If-Modified-Since': Updater.lastModified Watcher = init: -> html = '
Thread Watcher
' @dialog = ui.dialog 'watcher', 'top: 50px; left: 0px;', html $.add d.body, @dialog #add watch buttons inputs = $$ '.op > input' for input in inputs favicon = $.el 'img', className: 'favicon' $.on favicon, 'click', @cb.toggle $.before input, favicon if g.THREAD_ID is $.get 'autoWatch', 0 @watch g.THREAD_ID $.delete 'autoWatch' else #populate watcher, display watch buttons @refresh() $.sync 'watched', @refresh refresh: (watched) -> watched or= $.get 'watched', {} nodes = [] for board of watched for id, props of watched[board] x = $.el 'a', # \u00d7 is × textContent: '\u00d7' href: 'javascript:;' $.on x, 'click', Watcher.cb.x link = $.el 'a', props link.title = link.textContent div = $.el 'div' $.add div, [x, $.tn(' '), link] nodes.push div for div in $$ 'div:not(.move)', Watcher.dialog $.rm div $.add Watcher.dialog, nodes watchedBoard = watched[g.BOARD] or {} for favicon in $$ '.favicon' id = favicon.nextSibling.name if id of watchedBoard favicon.src = Favicon.default else favicon.src = Favicon.empty return cb: toggle: -> Watcher.toggle @parentNode x: -> thread = @nextElementSibling.pathname.split '/' Watcher.unwatch thread[3], thread[1] toggle: (thread) -> id = $('.favicon + input', thread).name Watcher.watch(id) or Watcher.unwatch id, g.BOARD unwatch: (id, board) -> watched = $.get 'watched', {} delete watched[board][id] $.set 'watched', watched Watcher.refresh() watch: (id) -> thread = $.id id return false if $('.favicon', thread).src is Favicon.default watched = $.get 'watched', {} watched[g.BOARD] or= {} watched[g.BOARD][id] = href: "/#{g.BOARD}/res/#{id}" textContent: GetTitle thread $.set 'watched', watched Watcher.refresh() true Anonymize = init: -> g.callbacks.push @node node: (post) -> return if post.class is 'inline' name = $ '.commentpostername, .postername', post.el name.textContent = 'Anonymous' node = name.nextElementSibling if node.className is 'postertrip' or node.nodeName is 'A' $.rm node Sauce = init: -> return if g.BOARD is 'f' @links = [] for link in conf['sauces'].split '\n' continue if link[0] is '#' @links.push @createSauceLink link return unless @links.length g.callbacks.push @node createSauceLink: (link) -> domain = link.match(/(\w+)\.\w+\//)[1] href = link.replace /(\$\d)/g, (parameter) -> switch parameter when '$1' "http://thumbs.4chan.org' + img.pathname.replace(/src(\\/\\d+).+$/, 'thumb$1s.jpg') + '" when '$2' "' + img.href + '" when '$3' "' + img.firstChild.getAttribute('md5').replace(/\=*$/, '') + '" when '$4' g.BOARD href = Function 'img', "return '#{href}'" el = $.el 'a', target: '_blank' textContent: domain (img) -> a = el.cloneNode true a.href = href img a node: (post) -> {img} = post return if post.class is 'inline' or not img img = img.parentNode nodes = [] for link in Sauce.links nodes.push $.tn(' '), link img $.add post.filesize, nodes RevealSpoilers = init: -> g.callbacks.push @node node: (post) -> {img} = post if not (img and /^Spoil/.test img.alt) or post.class is 'inline' return img.removeAttribute 'height' img.removeAttribute 'width' img.src = "http://thumbs.4chan.org#{img.parentNode.pathname.replace(/src(\/\d+).+$/, 'thumb$1s.jpg')}" Time = init: -> Time.foo() # GMT -8 is given as +480; would GMT +8 be -480 ? chanOffset = 5 - new Date().getTimezoneOffset() / 60 # 4chan = EST = GMT -5 chanOffset-- if $.isDST() @parse = if Date.parse('10/11/11(Tue)18:53') is 1318351980000 (node) -> new Date Date.parse(node.textContent) + chanOffset*HOUR else # Firefox and Opera do not parse 4chan's time format correctly (node) -> [_, month, day, year, hour, min] = node.textContent.match /(\d+)\/(\d+)\/(\d+)\(\w+\)(\d+):(\d+)/ year = "20#{year}" month -= 1 #months start at 0 hour = chanOffset + Number hour new Date year, month, day, hour, min g.callbacks.push @node node: (post) -> return if post.class is 'inline' # .posttime exists on every board except /f/ node = $('.posttime', post.el) or $('span[id]', post.el).previousSibling Time.date = Time.parse node time = $.el 'time', textContent: ' ' + Time.funk(Time) + ' ' $.replace node, time foo: -> code = conf['time'].replace /%([A-Za-z])/g, (s, c) -> if c of Time.formatters "' + Time.formatters.#{c}() + '" else s Time.funk = Function 'Time', "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[Time.date.getDay()][...3] A: -> Time.day[Time.date.getDay()] b: -> Time.month[Time.date.getMonth()][...3] B: -> Time.month[Time.date.getMonth()] d: -> Time.zeroPad Time.date.getDate() e: -> Time.date.getDate() H: -> Time.zeroPad Time.date.getHours() I: -> Time.zeroPad Time.date.getHours() % 12 or 12 k: -> Time.date.getHours() l: -> Time.date.getHours() % 12 or 12 m: -> Time.zeroPad Time.date.getMonth() + 1 M: -> Time.zeroPad Time.date.getMinutes() p: -> if Time.date.getHours() < 12 then 'AM' else 'PM' P: -> if Time.date.getHours() < 12 then 'am' else 'pm' y: -> Time.date.getFullYear() - 2000 FileInfo = init: -> return if g.BOARD is 'f' @setFormats() g.callbacks.push @node node: (post) -> return if post.class is 'inline' or not node = post.filesize type = if node.childElementCount is 2 then 0 else 1 regexp = if type /^File: (<.+>)-\((?:Spoiler Image, )?([\d\.]+) (\w+), (\d+x\d+|PDF)/ else /^File: (<.+>)-\((?:Spoiler Image, )?([\d\.]+) (\w+), (\d+x\d+|PDF), ([^<]+)/ [_, link, size, unit, resolution, fullname, shortname] = node.innerHTML.match regexp FileInfo.data = link: link size: size unit: unit resolution: resolution fullname: fullname shortname: shortname type: type node.innerHTML = FileInfo.funks[type] FileInfo setFormats: -> funks = [] for i in [0..1] format = if i then conf['fileInfoT'] else conf['fileInfoR'] param = if i then /%([BKlMrs])/g else /%([BKlLMnNrs])/g code = format.replace param, (s, c) -> if c of FileInfo.formatters "' + f.formatters.#{c}() + '" else s funks.push Function 'f', "return '#{code}'" @funks = funks convertUnit: (unitT) -> size = @data.size unitF = @data.unit if unitF isnt unitT units = ['B', 'KB', 'MB'] i = units.indexOf(unitF) - units.indexOf unitT unitT = 'Bytes' if unitT is 'B' if i > 0 size *= 1024 while i-- > 0 else if i < 0 size /= 1024 while i++ < 0 if size < 1 and size.toString().length > size.toFixed(2).length size = size.toFixed 2 "#{size} #{unitT}" formatters: l: -> if FileInfo.data.type is 0 FileInfo.data.link.replace />\d+\.\w+#{@n()}<" else FileInfo.data.link L: -> FileInfo.data.link.replace />\d+\.\w+#{FileInfo.data.fullname}<" n: -> if FileInfo.data.fullname is FileInfo.data.shortname FileInfo.data.fullname else "#{FileInfo.data.fullname}#{FileInfo.data.shortname}" N: -> FileInfo.data.fullname s: -> "#{FileInfo.data.size} #{FileInfo.data.unit}" B: -> FileInfo.convertUnit 'B' K: -> FileInfo.convertUnit 'KB' M: -> FileInfo.convertUnit 'MB' r: -> FileInfo.data.resolution GetTitle = (thread) -> el = $ '.filetitle', thread if not el.textContent el = $ 'blockquote', thread if not el.textContent el = $ '.postername', thread span = $.el 'span', innerHTML: el.innerHTML.replace /
/g, ' ' "/#{g.BOARD}/ - #{span.textContent}" TitlePost = init: -> d.title = GetTitle() QuoteBacklink = init: -> format = conf['backlink'].replace /%id/g, "' + id + '" @funk = Function 'id', "return '#{format}'" g.callbacks.push @node node: (post) -> return if post.isInlined quotes = {} for quote in post.quotes # Don't process >>>/b/. if qid = quote.hash[1..] # Duplicate quotes get overwritten. quotes[qid] = true a = $.el 'a', href: "##{post.id}" className: if post.root.hidden then 'filtered backlink' else 'backlink' textContent: QuoteBacklink.funk post.id for qid of quotes # Don't backlink the OP. continue if !(el = $.id qid) or el.className is 'op' and !conf['OP Backlinks'] link = a.cloneNode true if conf['Quote Preview'] $.on link, 'mouseover', QuotePreview.mouseover if conf['Quote Inline'] $.on link, 'click', QuoteInline.toggle unless (container = $ '.container', el) and container.parentNode is el container = $.el 'span', className: 'container' $.add container, [$.tn(' '), link] root = $('.reportbutton', el) or $('span[id]', el) $.after root, container else $.add container, [$.tn(' '), link] return QuoteInline = init: -> g.callbacks.push @node node: (post) -> for quote in post.quotes continue unless quote.hash quote.removeAttribute 'onclick' $.on quote, 'click', QuoteInline.toggle for quote in post.backlinks $.on quote, '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() id = @hash[1..] if /\binlined\b/.test @className QuoteInline.rm @, id else return if $.x "ancestor::*[@id='#{id}']", @ QuoteInline.add @, id @classList.toggle 'inlined' add: (q, id) -> root = if q.parentNode.nodeName is 'FONT' then q.parentNode else if q.nextSibling then q.nextSibling else q if el = $.id id inline = QuoteInline.table id, el.innerHTML if (i = Unread.replies.indexOf el.parentNode.parentNode.parentNode) isnt -1 Unread.replies.splice i, 1 Unread.update() if /\bbacklink\b/.test q.className $.after q.parentNode, inline if conf['Forward Hiding'] table = $.x 'ancestor::table', el $.addClass table, 'forwarded' # Will only unhide if there's no inlined backlinks of it anymore. ++table.title or table.title = 1 return $.after root, inline else inline = $.el 'td', className: 'reply inline' id: "i#{id}" innerHTML: "Loading #{id}..." $.after root, inline {pathname} = q threadID = pathname.split('/').pop() $.cache pathname, (-> QuoteInline.parse @, pathname, id, threadID, inline) rm: (q, id) -> #select the corresponding table or loading td table = $.x "following::*[@id='i#{id}']", q $.rm table return unless conf['Forward Hiding'] for inlined in $$ '.backlink.inlined', table table = $.x 'ancestor::table', $.id inlined.hash[1..] $.removeClass table, 'forwarded' unless --table.title if /\bbacklink\b/.test q.className table = $.x 'ancestor::table', $.id id $.removeClass table, 'forwarded' unless --table.title parse: (req, pathname, id, threadID, inline) -> return unless inline.parentNode if req.status isnt 200 inline.textContent = "#{req.status} #{req.statusText}" return doc = d.implementation.createHTMLDocument null doc.documentElement.innerHTML = req.responseText node = if id is threadID #OP Threading.op $('body > form', doc).firstChild else doc.getElementById id newInline = QuoteInline.table id, node.innerHTML for quote in $$ '.quotelink', newInline if (href = quote.getAttribute 'href') is quote.hash #add pathname to normal quotes quote.pathname = pathname else if !g.REPLY and href isnt quote.href #fix x-thread links, not x-board ones quote.href = "res/#{href}" link = $ '.quotejs', newInline link.href = "#{pathname}##{id}" link.nextSibling.href = "#{pathname}#q#{id}" $.addClass newInline, 'crossquote' $.replace inline, newInline table: (id, html) -> $.el 'table', className: 'inline' id: "i#{id}" innerHTML: "#{html}" QuotePreview = init: -> g.callbacks.push @node node: (post) -> for quote in post.quotes $.on quote, 'mouseover', QuotePreview.mouseover if quote.hash for quote in post.backlinks $.on quote, 'mouseover', QuotePreview.mouseover return mouseover: (e) -> return if /\binlined\b/.test @className qp = ui.el = $.el 'div', id: 'qp' className: 'reply dialog' $.add d.body, qp id = @hash[1..] if el = $.id id qp.innerHTML = el.innerHTML $.addClass el, 'qphl' if conf['Quote Highlighting'] if /\bbacklink\b/.test @className replyID = $.x('preceding-sibling::input', @parentNode).name for quote in $$ '.quotelink', qp if quote.hash[1..] is replyID $.addClass quote, 'forwardlink' else qp.textContent = "Loading #{id}..." threadID = @pathname.split('/').pop() or $.x('ancestor::div[@class="thread"]', @).firstChild.id $.cache @pathname, (-> QuotePreview.parse @, id, threadID) ui.hover e $.on @, 'mousemove', ui.hover $.on @, 'mouseout', QuotePreview.mouseout $.on @, 'click', QuotePreview.mouseout mouseout: -> if el = $.id @hash[1..] $.removeClass el, 'qphl' ui.hoverend() $.off @, 'mousemove', ui.hover $.off @, 'mouseout', QuotePreview.mouseout $.off @, 'click', QuotePreview.mouseout parse: (req, id, threadID) -> return unless (qp = ui.el) and qp.textContent is "Loading #{id}..." if req.status isnt 200 qp.textContent = "#{req.status} #{req.statusText}" return doc = d.implementation.createHTMLDocument null doc.documentElement.innerHTML = req.responseText node = if id is threadID #OP Threading.op $('body > form', doc).firstChild else doc.getElementById id qp.innerHTML = node.innerHTML post = root: qp filesize: $ '.filesize', qp img: $ 'img[md5]', qp if conf['Image Auto-Gif'] AutoGif.node post if conf['Time Formatting'] Time.node post if conf['File Info Formatting'] FileInfo.node post QuoteOP = init: -> g.callbacks.push @node node: (post) -> return if post.class is 'inline' for quote in post.quotes if quote.hash[1..] is post.threadId # \u00A0 is nbsp $.add quote, $.tn '\u00A0(OP)' return QuoteCT = init: -> g.callbacks.push @node node: (post) -> return if post.class is 'inline' for quote in post.quotes unless quote.hash # Make sure this isn't a link to the board we're on. continue path = quote.pathname.split '/' # If quote leads to a different thread id and is located on the same board. if path[1] is g.BOARD and path[3] isnt post.threadId # \u00A0 is nbsp $.add quote, $.tn '\u00A0(Cross-thread)' return DeadQuotes = init: -> g.callbacks.push @node node: (post) -> return if post.class is 'inline' # XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE is 7 # We need to make sure we can quotify multiple links per text node # We need to make sure that we don't try to quotify text like `>>text` snapshot = d.evaluate './/*[not(self::a) and contains(text(),">>")]/text()', post.el.lastChild, null, 7, null for i in [0...snapshot.snapshotLength] node = snapshot.snapshotItem i data = node.data unless quote = data.match />>(\d+)/ continue index = data.indexOf quote[0] nodes = [] if text = data[0...index] nodes.push $.tn text nodes.push $.el 'a', # \u00A0 is nbsp textContent: "#{quote[0]}\u00A0(Dead)" href: "##{quote[1]}" # Here be archive link className: if $.id quote[1] then 'quotelink' else null if text = data[index + quote[0].length...] nodes.push $.tn text $.replace node, nodes return ReportButton = init: -> @a = $.el 'a', className: 'reportbutton' innerHTML: '[ ! ]' href: 'javascript:;' g.callbacks.push @node node: (post) -> unless a = $ '.reportbutton', post.el a = ReportButton.a.cloneNode true $.after $('span[id]', post.el), [$.tn(' '), a] $.on a, 'click', ReportButton.report report: -> url = "http://sys.4chan.org/#{g.BOARD}/imgboard.php?mode=report&no=#{$.x('preceding-sibling::input', @).name}" 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 ThreadStats = init: -> dialog = ui.dialog 'stats', 'bottom: 0; left: 0;', '
0 / 0
' dialog.className = 'dialog' $.add d.body, dialog @posts = @images = 0 @imgLimit = switch g.BOARD when 'a', 'mlp', 'v' 251 when 'vg' 501 else 151 g.callbacks.push @node node: (post) -> return if post.isInlined $.id('postcount').textContent = ++ThreadStats.posts return unless post.img imgcount = $.id 'imagecount' imgcount.textContent = ++ThreadStats.images if ThreadStats.images > ThreadStats.imgLimit imgcount.className = 'warning' Unread = init: -> @title = d.title @update() $.on window, 'scroll', Unread.scroll g.callbacks.push @node replies: [] foresee: [] node: (post) -> if (index = Unread.foresee.indexOf post.id) isnt -1 Unread.foresee.splice index, 1 return return if post.root.hidden or post.class Unread.replies.push post.root Unread.update() scroll: -> height = d.body.clientHeight for reply, i in Unread.replies {bottom} = reply.getBoundingClientRect() if bottom > height #post is not completely read break return if i is 0 Unread.replies = Unread.replies[i..] Unread.update() setTitle: (count) -> if @scheduled clearTimeout @scheduled delete Unread.scheduled @setTitle count return @scheduled = setTimeout (-> d.title = "(#{count}) #{Unread.title}" ), 5 update: (forceUpdate) -> return unless g.REPLY count = @replies.length if conf['Unread Count'] @setTitle count unless conf['Unread Favicon'] and (count < 2 or forceUpdate) return 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 not change $.add d.head, Favicon.el Favicon = init: -> @el = $ 'link[rel="shortcut icon"]', d.head @el.type = 'image/x-icon' {href} = @el @SFW = /ws.ico$/.test href @default = href @switch() switch: -> switch conf['favicon'] when 'ferongr' @unreadDead = 'data:unreadDead;base64,R0lGODlhEAAQAOMHAOgLAnMFAL8AAOgLAukMA/+AgP+rq////////////////////////////////////yH5BAEKAAcALAAAAAAQABAAAARZ8MhJ6xwDWIBv+AM1fEEIBIVRlNKYrtpIECuGzuwpCLg974EYiXUYkUItjGbC6VQ4omXFiKROA6qSy0A8nAo9GS3YCswIWnOvLAi0be23Z1QtdSUaqXcviQAAOw==' @unreadSFW = 'data:unreadSFW;base64,R0lGODlhEAAQAOMHAADX8QBwfgC2zADX8QDY8nnl8qLp8v///////////////////////////////////yH5BAEKAAcALAAAAAAQABAAAARZ8MhJ6xwDWIBv+AM1fEEIBIVRlNKYrtpIECuGzuwpCLg974EYiXUYkUItjGbC6VQ4omXFiKROA6qSy0A8nAo9GS3YCswIWnOvLAi0be23Z1QtdSUaqXcviQAAOw==' @unreadNSFW = 'data:unreadNSFW;base64,R0lGODlhEAAQAOMHAFT+ACh5AEncAFT+AFX/Acz/su7/5v///////////////////////////////////yH5BAEKAAcALAAAAAAQABAAAARZ8MhJ6xwDWIBv+AM1fEEIBIVRlNKYrtpIECuGzuwpCLg974EYiXUYkUItjGbC6VQ4omXFiKROA6qSy0A8nAo9GS3YCswIWnOvLAi0be23Z1QtdSUaqXcviQAAOw==' when 'xat-' @unreadDead = 'data:unreadDead;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA2ElEQVQ4y61TQQrCMBDMQ8WDIEV6LbT2A4og2Hq0veo7fIAH04dY9N4xmyYlpGmI2MCQTWYy3Wy2DAD7B2wWAzWgcTgVeZKlZRxHNYFi2jM18oBh0IcKtC6ixf22WT4IFLs0owxswXu9egm0Ls6bwfCFfNsJYJKfqoEkd3vgUgFVLWObtzNgVKyruC+ljSzr5OEnBzjvjcQecaQhbZgBb4CmGQw+PoMkTUtdbd8VSEPakcGxPOcsoIgUKy0LecY29BmdBrqRfjIwZ93KLs5loHvBnL3cLH/jF+C/+z5dgUysAAAAAElFTkSuQmCC' @unreadSFW = 'data:unreadSFW;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA30lEQVQ4y2P4//8/AyWYgSoGQMF/GJ7Y11VVUVoyKTM9ey4Ig9ggMWQ1YA1IBvzXm34YjkH8mPyJB+Nqlp8FYRAbmxoMF6ArSNrw6T0Qf8Amh9cFMEWVR/7/A+L/uORxhgEIt5/+/3/2lf//5wAxiI0uj+4CBlBgxVUvOwtydgXQZpDmi2/+/7/0GmIQSAwkB1IDUkuUAZeABlx+g2zAZ9wGlAOjChba+LwAUgNSi2HA5Am9VciBhSsQQWyoWgZiovEDsdGI1QBYQiLJAGQalpSxyWEzAJYWkGm8clTJjQCZ1hkoVG0CygAAAABJRU5ErkJggg==' @unreadNSFW = 'data:unreadNSFW;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA4ElEQVQ4y2P4//8/AyWYgSoGQMF/GJ7YNbGqrKRiUnp21lwQBrFBYshqwBqQDPifdsYYjkH8mInxB+OWx58FYRAbmxoMF6ArKPmU9B6IP2CTw+sCmKKe/5X/gPg/LnmcYQDCs/63/1/9fzYQzwGz0eXRXcAACqy4ZfFnQc7u+V/xD6T55v+LQHwJbBBIDCQHUgNSS5QBt4Cab/2/jDDgMx4DykrKJ8FCG58XQGpAajEMmNw7uQo5sHAFIogNVctATDR+IDYasRoAS0gkGYBMw5IyNjlsBsDSAjKNV44quREAx58Mr9vt5wQAAAAASUVORK5CYII=' when 'Mayhem' @unreadDead = 'data:unreadDead;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABIUlEQVQ4jZ2ScWuDMBDFgw4pIkU0WsoQkWAYIkXZH4N9/+/V3dmfXSrKYIFHwt17j8vdGWNMIkgFuaDgzgQnwRs4EQs5KdolUQtagRN0givEDBTEOjgtGs0Zq8F7cKqqusVxrMQLaDUWcjBSrXkn8gs51tpJSWLk9b3HUa0aNIL5gPBR1/V4kJvR7lTwl8GmAm1Gf9+c3S+89qBHa8502AsmSrtBaEBPbIbj0ah2madlNAPEccdgJDfAtWifBjqWKShRBT6KoiH8QlEUn/qt0CCjnNdmPUwmFWzj9Oe6LpKuZXcwqq88z78Pch3aZU3dPwwc2sWlfZKCW5tWluV8kGvXClLm6dYN4/aUqfCbnEOzNDGhGZbNargvxCzvMGfRJD8UaDVvgkzo6QAAAABJRU5ErkJggg==' @unreadSFW = 'data:unreadSFW;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABCElEQVQ4jZ2S4crCMAxF+0OGDJEPKYrIGKOsiJSx/fJRfSAfTJNyKqXfiuDg0C25N2RJjTGmEVrhTzhw7oStsIEtsVzT4o2Jo9ALThiEM8IdHIgNaHo8mjNWg6/ske8bohPo+63QOLzmooHp8fyAICBSQkVz0QKdsFQEV6WSW/D+7+BbgbIDHcb4Kp61XyjyI16zZ8JemGltQtDBSGxB4/GoN+7TpkkjDCsFArm0IYv3U0BbnYtf8BCy+JytsE0X6VyuKhPPK/GAJ14kvZZDZVV3pZIb8MZr6n4o4PDGKn0S5SdDmyq5PnXQsk+Xbhinp03FFzmHJw6xYRiWm9VxnohZ3vOcxdO8ARmXRvbWdtzQAAAAAElFTkSuQmCC' @unreadNSFW = 'data:unreadNSFW;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABCklEQVQ4jZ2S0WrDMAxF/TBCCKWMYhZKCSGYmFJMSNjD/mhf239qJXNcjBdTWODgRLpXKJKNMaYROuFTOHEehFb4gJZYrunwxsSXMApOmIQzwgOciE1oRjyaM1aDj+yR7xuiHvT9VmgcXnPRwO/9+wWCgEgJFc1FCwzCVhFclUpuw/u3g3cFyg50GPOjePZ+ocjPeM2RCXthpbUFwQAzsQ2Nx6PeuE+bJo0w7BQI5NKGLN5XAW11LX7BQ8jia7bCLl2kc7mqTLzuxAOeeJH0Wk6VVf0oldyEN15T948CDm+sMiZRfjK0pZIbUwcd+3TphnF62lR8kXN44hAbhmG5WQNnT8zynucsnuYJhFpBfkMzqD4AAAAASUVORK5CYII=' when 'Original' @unreadDead = 'data:unreadDead;base64,R0lGODlhEAAQAKECAAAAAP8AAP///////yH5BAEKAAMALAAAAAAQABAAAAI/nI95wsqygIRxDgGCBhTrwF3Zxowg5H1cSopS6FrGQ82PU1951ckRmYKJVCXizLRC9kAnT0aIiR6lCFT1cigAADs=' @unreadSFW = 'data:unreadSFW;base64,R0lGODlhEAAQAKECAAAAAC6Xw////////yH5BAEKAAMALAAAAAAQABAAAAI/nI95wsqygIRxDgGCBhTrwF3Zxowg5H1cSopS6FrGQ82PU1951ckRmYKJVCXizLRC9kAnT0aIiR6lCFT1cigAADs=' @unreadNSFW = 'data:unreadNSFW;base64,R0lGODlhEAAQAKECAAAAAGbMM////////yH5BAEKAAMALAAAAAAQABAAAAI/nI95wsqygIRxDgGCBhTrwF3Zxowg5H1cSopS6FrGQ82PU1951ckRmYKJVCXizLRC9kAnT0aIiR6lCFT1cigAADs=' @unread = if @SFW then @unreadSFW else @unreadNSFW empty: 'data:image/gif;base64,R0lGODlhEAAQAJEAAAAAAP///9vb2////yH5BAEAAAMALAAAAAAQABAAAAIvnI+pq+D9DBAUoFkPFnbs7lFZKIJOJJ3MyraoB14jFpOcVMpzrnF3OKlZYsMWowAAOw==' dead: 'data:image/gif;base64,R0lGODlhEAAQAKECAAAAAP8AAP///////yH5BAEKAAIALAAAAAAQABAAAAIvlI+pq+D9DAgUoFkPDlbs7lFZKIJOJJ3MyraoB14jFpOcVMpzrnF3OKlZYsMWowAAOw==' Redirect = init: -> url = if location.hostname is 'images.4chan.org' @image location.href else if /^\d+$/.test g.THREAD_ID @thread() location.href = url if url image: (href) -> href = href.split '/' # Do not use g.BOARD, the image url can originate from a cross-quote. return unless conf['404 Redirect'] switch href[3] when 'a', 'jp', 'm', 'tg', 'u', 'vg' "http://archive.foolz.us/#{href[3]}/full_image/#{href[5]}" thread: -> return unless conf['404 Redirect'] switch g.BOARD when 'a', 'jp', 'm', 'tg', 'tv', 'u', 'v', 'vg' "http://archive.foolz.us/#{g.BOARD}/thread/#{g.THREAD_ID}/" when 'lit' "http://fuuka.warosu.org/#{g.BOARD}/thread/#{g.THREAD_ID}" when 'diy', 'g', 'sci' "http://archive.installgentoo.net/#{g.BOARD}/thread/#{g.THREAD_ID}" when '3', 'adv', 'an', 'ck', 'co', 'fa', 'fit', 'int', 'k', 'mu', 'n', 'o', 'p', 'po', 'pol', 'r9k', 'soc', 'sp', 'toy', 'trv', 'vp', 'x' "http://archive.no-ip.org/#{g.BOARD}/thread/#{g.THREAD_ID}" else "http://boards.4chan.org/#{g.BOARD}/" ImageHover = init: -> g.callbacks.push @node node: (post) -> return unless post.img $.on post.img, 'mouseover', ImageHover.mouseover mouseover: -> ui.el = $.el 'img' id: 'ihover' src: @parentNode.href $.add d.body, ui.el $.on ui.el, 'load', ImageHover.load $.on @, 'mousemove', ui.hover $.on @, 'mouseout', ImageHover.mouseout load: -> return if @ isnt ui.el # 'Fake' mousemove event by giving required values. {style} = @ ui.hover clientX: - 45 + parseInt style.left clientY: 120 + parseInt style.top mouseout: -> ui.hoverend() $.off @, 'mousemove', ui.hover $.off @, 'mouseout', ImageHover.mouseout AutoGif = init: -> g.callbacks.push @node node: (post) -> return if post.root.hidden or not post.img src = post.img.parentNode.href if /gif$/.test(src) and !/spoiler/.test src img = $.el 'img' $.on img, 'load', -> # Replace the thumbnail once the GIF has finished loading. post.img.src = src img.src = src ImageExpand = init: -> g.callbacks.push @node @dialog() node: (post) -> return unless post.img a = post.img.parentNode $.on a, 'click', ImageExpand.cb.toggle if ImageExpand.on and !post.root.hidden and post.class isnt 'inline' ImageExpand.expand post.img 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 @ all: -> ImageExpand.on = @checked if ImageExpand.on #expand thumbs = $$ 'img[md5]' if conf['Expand From Current'] for thumb, i in thumbs if thumb.getBoundingClientRect().top > 0 break thumbs = thumbs[i...] for thumb in thumbs ImageExpand.expand thumb else #contract for thumb in $$ 'img[md5][hidden]' ImageExpand.contract thumb return typeChange: -> switch @value when 'full' klass = '' when 'fit width' klass = 'fitwidth' when 'fit height' klass = 'fitheight' when 'fit screen' klass = 'fitwidth fitheight' $('body > form').className = klass if /\bfitheight\b/.test klass $.on window, 'resize', ImageExpand.resize unless ImageExpand.style ImageExpand.style = $.addStyle '' ImageExpand.resize() else if ImageExpand.style $.off window, 'resize', ImageExpand.resize toggle: (a) -> thumb = a.firstChild if thumb.hidden rect = a.getBoundingClientRect() d.body.scrollTop += rect.top - 42 if rect.top < 0 d.body.scrollLeft += rect.left if rect.left < 0 ImageExpand.contract thumb else ImageExpand.expand thumb contract: (thumb) -> thumb.hidden = false thumb.nextSibling.hidden = true expand: (thumb, url) -> # Do not expand images of hidden/filtered replies, or already expanded pictures. return if $.x 'ancestor-or-self::*[@hidden]', thumb thumb.hidden = true if img = thumb.nextSibling # Expand already loaded picture img.hidden = false return a = thumb.parentNode img = $.el 'img', src: url or a.href $.on img, 'error', ImageExpand.error $.add a, img error: -> href = @parentNode.href thumb = @previousSibling ImageExpand.contract thumb $.rm @ unless @src.split('/')[2] is 'images.4chan.org' and url = Redirect.image href return if g.dead # CloudFlare may cache banned pages instead of images. # This will fool CloudFlare's cache. url = href + '?' + Date.now() #navigator.online is not x-browser/os yet timeoutID = setTimeout ImageExpand.expand, 10000, thumb, url # Only Chrome let userscript break through cross domain requests. # Don't check it 404s in the archivers. return unless engine is 'webkit' and url.split('/')[2] is 'images.4chan.org' $.ajax url, onreadystatechange: (-> clearTimeout timeoutID if @status is 404), type: 'head' dialog: -> controls = $.el 'div', id: 'imgControls' innerHTML: "" imageType = $.get 'imageType', 'full' select = $ 'select', controls select.value = imageType ImageExpand.cb.typeChange.call select $.on select, 'change', $.cb.value $.on select, 'change', ImageExpand.cb.typeChange $.on $('input', controls), 'click', ImageExpand.cb.all form = $ 'body > form' $.prepend form, controls resize: -> ImageExpand.style.textContent = ".fitheight img[md5] + img {max-height:#{d.body.clientHeight}px;}" Main = init: -> path = location.pathname pathname = path[1..].split '/' [g.BOARD, temp] = pathname if temp is 'res' g.REPLY = true g.THREAD_ID = pathname[2] else g.PAGENUM = parseInt(temp) or 0 #load values from localStorage for key, val of conf conf[key] = $.get key, val $.on window, 'message', Main.message switch location.hostname when 'sys.4chan.org' if path is '/robots.txt' qr.message.send req: 'status', ready: true else if /report/.test location.search $.ready -> $.on $.id('recaptcha_response_field'), 'keydown', (e) -> window.location = 'javascript:Recaptcha.reload()' if e.keyCode is 8 and not e.target.value return when 'www.4chan.org' if path is '/banned' qr.message.send req: 'status', ready: true, banned: true return when 'images.4chan.org' $.ready -> Redirect.init() if d.title is '4chan - 404' return $.ready Options.init if conf['Quick Reply'] and conf['Hide Original Post Form'] Main.css += 'form[name=post] { display: none; }' Main.addStyle() now = Date.now() if conf['Check for Updates'] and $.get('lastUpdate', 0) < now - 6*HOUR $.ready -> $.add d.head, $.el 'script', src: 'https://raw.github.com/mayhemydg/4chan-x/master/latest.js' $.set 'lastUpdate', now g.hiddenReplies = $.get "hiddenReplies/#{g.BOARD}/", {} if $.get('lastChecked', 0) < now - 1*DAY $.set 'lastChecked', now cutoff = now - 7*DAY hiddenThreads = $.get "hiddenThreads/#{g.BOARD}/", {} for id, timestamp of hiddenThreads if timestamp < cutoff delete hiddenThreads[id] for id, timestamp of g.hiddenReplies if timestamp < cutoff delete g.hiddenReplies[id] $.set "hiddenThreads/#{g.BOARD}/", hiddenThreads $.set "hiddenReplies/#{g.BOARD}/", g.hiddenReplies #major features if conf['Filter'] Filter.init() if conf['Reply Hiding'] ReplyHiding.init() if conf['Filter'] or conf['Reply Hiding'] StrikethroughQuotes.init() if conf['Anonymize'] Anonymize.init() if conf['Time Formatting'] Time.init() if conf['File Info Formatting'] FileInfo.init() if conf['Sauce'] Sauce.init() if conf['Reveal Spoilers'] RevealSpoilers.init() if conf['Image Auto-Gif'] AutoGif.init() if conf['Image Hover'] ImageHover.init() if conf['Report Button'] ReportButton.init() if conf['Resurrect Quotes'] DeadQuotes.init() if conf['Quote Inline'] QuoteInline.init() if conf['Quote Preview'] QuotePreview.init() if conf['Quote Backlinks'] QuoteBacklink.init() if conf['Indicate OP quote'] QuoteOP.init() if conf['Indicate Cross-thread Quotes'] QuoteCT.init() $.ready Main.ready ready: -> if d.title is '4chan - 404' Redirect.init() return unless $.id 'navtopr' return $.addClass d.body, "chanx_#{VERSION.split('.')[1]}" $.addClass d.body, engine for nav in ['navtop', 'navbot'] $.addClass $("a[href$='/#{g.BOARD}/']", $.id nav), 'current' form = $ 'form[name=delform]' Threading.thread form.firstElementChild Favicon.init() # Major features. if conf['Quick Reply'] qr.init() if conf['Image Expansion'] ImageExpand.init() if conf['Thread Watcher'] setTimeout -> Watcher.init() if conf['Keybinds'] setTimeout -> Keybinds.init() if g.REPLY if conf['Thread Updater'] setTimeout -> Updater.init() if conf['Thread Stats'] ThreadStats.init() if conf['Reply Navigation'] setTimeout -> Nav.init() if conf['Post in Title'] TitlePost.init() if conf['Unread Count'] or conf['Unread Favicon'] Unread.init() else #not reply if conf['Thread Hiding'] setTimeout -> ThreadHiding.init() if conf['Thread Expansion'] setTimeout -> ExpandThread.init() if conf['Comment Expansion'] setTimeout -> ExpandComment.init() if conf['Index Navigation'] setTimeout -> Nav.init() nodes = [] for node in $$ '.op, a + table', form nodes.push Main.preParse node Main.node nodes, true if MutationObserver = window.WebKitMutationObserver or window.MozMutationObserver or window.OMutationObserver or window.MutationObserver observer = new MutationObserver Main.observer observer.observe form, childList: true subtree: true else $.on form, 'DOMNodeInserted', Main.listener addStyle: -> $.off d, 'DOMNodeInserted', Main.addStyle if d.head $.addStyle Main.css else # XXX fox $.on d, 'DOMNodeInserted', Main.addStyle message: (e) -> {data} = e if data.qr qr.message.receive data return {version} = data if version and version isnt VERSION and confirm 'An updated version of 4chan X is available, would you like to install it now?' window.location = "https://raw.github.com/mayhemydg/4chan-x/#{version}/4chan_x.user.js" preParse: (node) -> klass = node.className post = root: node el: if klass is 'op' then node else node.firstChild.firstChild.lastChild class: klass id: node.getElementsByTagName('input')[0].name threadId: g.THREAD_ID or $.x('ancestor::div[@class="thread"]', node).firstChild.id isOP: klass is 'op' isInlined: /\binline\b/.test klass filesize: node.getElementsByClassName('filesize')[0] or false quotes: node.getElementsByClassName 'quotelink' backlinks: node.getElementsByClassName 'backlink' post.img = if post.filesize then node.getElementsByTagName('img')[0] else false post node: (nodes, notify) -> for callback in g.callbacks try callback node for node in nodes catch err alert "4chan X (#{VERSION}) error: #{err.message}\nhttp://mayhemydg.github.com/4chan-x/#bug-report\n\n#{err.stack}" if notify return observer: (mutations) -> nodes = [] for mutation in mutations for addedNode in mutation.addedNodes nodes.push Main.preParse addedNode if addedNode.nodeName is 'TABLE' Main.node nodes if nodes.length listener: (e) -> {target} = e Main.node [Main.preParse target] if target.nodeName is 'TABLE' css: ' /* dialog styling */ .dialog { border: 1px solid rgba(0,0,0,.25); } .move { cursor: move; } label, .favicon { cursor: pointer; } a[href="javascript:;"] { text-decoration: none; } .block ~ .op, .block ~ .omittedposts, .block ~ table, #content > [name=tab]:not(:checked) + div, #updater:not(:hover) > :not(.move), #qp > input, #qp .inline, .forwarded { display: none; } .autohide:not(:hover) > form { display: none; } #qr > .move { min-width: 300px; overflow: hidden; box-sizing: border-box; -moz-box-sizing: border-box; padding: 0 2px; } #qr > .move > span { float: right; } #autohide, .close, #qr select, #dump, .remove, .captcha, #qr .warning { cursor: pointer; } #qr select, #qr > form { margin: 0; } #dump { background: -webkit-linear-gradient(#EEE, #CCC); background: -moz-linear-gradient(#EEE, #CCC); background: -o-linear-gradient(#EEE, #CCC); background: linear-gradient(#EEE, #CCC); width: 10%; padding: -moz-calc(1px) 0 2px; } #dump:hover, #dump:focus { background: -webkit-linear-gradient(#FFF, #DDD); background: -moz-linear-gradient(#FFF, #DDD); background: -o-linear-gradient(#FFF, #DDD); background: linear-gradient(#FFF, #DDD); } #dump:active, .dump #dump:not(:hover):not(:focus) { background: -webkit-linear-gradient(#CCC, #DDD); background: -moz-linear-gradient(#CCC, #DDD); background: -o-linear-gradient(#CCC, #DDD); background: linear-gradient(#CCC, #DDD); } #qr:not(.dump) #replies, .dump > form > label { display: none; } #replies { display: block; height: 100px; position: relative; -webkit-user-select: none; -moz-user-select: none; -o-user-select: none; user-select: none; } #replies > div { counter-reset: previews; top: 0; right: 0; bottom: 0; left: 0; margin: 0; padding: 0; overflow: hidden; position: absolute; white-space: pre; } #replies > div:hover { bottom: -10px; overflow-x: auto; z-index: 1; } .preview { background-color: rgba(0,0,0,.2) !important; background-position: 50% 20% !important; background-size: cover !important; border: 1px solid #666; box-sizing: border-box; -moz-box-sizing: border-box; cursor: move; display: inline-block; height: 90px; width: 90px; margin: 5px; padding: 2px; opacity: .5; outline: none; overflow: hidden; position: relative; text-shadow: 0 1px 1px #000; -webkit-transition: .25s ease-in-out; -moz-transition: .25s ease-in-out; -o-transition: .25s ease-in-out; transition: .25s ease-in-out; vertical-align: top; } .preview:hover, .preview:focus { opacity: .9; } .preview#selected { opacity: 1; } .preview::before { counter-increment: previews; content: counter(previews); color: #FFF; font-weight: 700; padding: 3px; position: absolute; top: 0; right: 0; text-shadow: 0 0 3px #000, 0 0 8px #000; } .preview.drag { box-shadow: 0 0 10px rgba(0,0,0,.5); } .preview.over { border-color: #FFF; } .preview > span { color: #FFF; } .remove { background: none; color: #E00; font-weight: 700; padding: 3px; } .remove:hover::after { content: " Remove"; } .preview > label { background: rgba(0,0,0,.5); color: #FFF; right: 0; bottom: 0; left: 0; position: absolute; text-align: center; } .preview > label > input { margin: 0; } #addReply { color: #333; font-size: 3.5em; line-height: 100px; } #addReply:hover, #addReply:focus { color: #000; } .field { border: 1px solid #CCC; color: #333; font: 13px sans-serif; margin: 0; padding: 2px 4px 3px; width: 30%; -webkit-transition: color .25s, border .25s; -moz-transition: color .25s, border .25s; -o-transition: color .25s, border .25s; transition: color .25s, border .25s; } .field:-moz-placeholder, .field:hover:-moz-placeholder { color: #AAA; } .field:hover, .field:focus { border-color: #999; color: #000; outline: none; } textarea.field { min-height: 120px; } .field:only-child { min-width: 100%; } .captcha { background: #FFF; outline: 1px solid #CCC; outline-offset: -1px; text-align: center; } .captcha > img { height: 57px; width: 300px; } #qr [type=file] { margin: 1px 0; width: 70%; } #qr [type=submit] { margin: 1px 0; padding: 1px; /* not Gecko */ padding: 0 -moz-calc(1px); /* Gecko does not respect box-sizing: border-box */ width: 30%; } .new { background: lime; } .warning { color: red; } .replyhider { vertical-align: top; } .filesize + br + a { float: left; pointer-events: none; } .filename:hover > .fntrunc, .filename:not(:hover) > .fnfull { display: none; } img[md5], img[md5] + img { pointer-events: all; } .fitwidth img[md5] + img { max-width: 100%; } .gecko > .fitwidth img[md5] + img, .presto > .fitwidth img[md5] + img { width: 100%; } /* revealed spoilers do not have height/width, this fixed "expanded" auto-gifs */ img[md5] { max-height: 251px; max-width: 251px; } td > .filesize > img[md5] { max-height: 126px; max-width: 126px; } #qr, #qp, #updater, #stats, #ihover, #overlay, #navlinks { position: fixed; } #ihover { max-height: 97%; max-width: 75%; padding-bottom: 18px; } #navlinks { font-size: 16px; top: 25px; right: 5px; } #overlay { top: 0; right: 0; left: 0; bottom: 0; text-align: center; background: rgba(0,0,0,.5); z-index: 1; } #overlay::after { content: ""; display: inline-block; height: 100%; vertical-align: middle; } #options { display: inline-block; padding: 5px; text-align: left; vertical-align: middle; width: 600px; } #credits { float: right; } #options ul { list-style: none; padding: 0; } #options label { text-decoration: underline; } #content > div { height: 450px; overflow: auto; } #content textarea { margin: 0; min-height: 100px; resize: vertical; width: 100%; } #sauces { height: 320px; } #updater { text-align: right; } #updater input[type=text] { width: 50px; } #updater:not(:hover) { border: none; background: transparent; } #stats { border: none; } #watcher { padding-bottom: 5px; position: absolute; overflow: hidden; white-space: nowrap; } #watcher:not(:hover) { max-height: 220px; } #watcher > div { max-width: 200px; overflow: hidden; padding-left: 5px; padding-right: 5px; text-overflow: ellipsis; } #watcher > .move { padding-top: 5px; text-decoration: underline; } #qp { padding-bottom: 5px; } #qp > a > img { max-height: 300px; max-width: 500px; } .qphl { outline: 2px solid rgba(216, 94, 49, .7); } .inlined { opacity: .5; } .inline .reply { background-color: rgba(255, 255, 255, 0.15); border: 1px solid rgba(128, 128, 128, 0.5); } .filetitle, .replytitle, .postername, .commentpostername, .postertrip { background: none; } .filter_highlight.op, .filter_highlight > td[id] { box-shadow: -5px 0 rgba(255,0,0,0.5); } .filtered { text-decoration: line-through; } .quotelink.forwardlink { color: #ef5411; } ' Main.init()