config = main: Enhancing: '404 Redirect': [true, 'Redirect dead threads'] 'Anonymize': [false, 'Make everybody anonymous'] 'Keybinds': [false, 'Binds actions to keys'] 'Time Formatting': [true, 'Arbitrarily formatted timestamps, using your local time'] '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'] Hiding: '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'] 'Image Preloading': [false, 'Preload Images'] 'Sauce': [true, 'Add sauce to images'] 'Reveal Spoilers': [false, 'Replace spoiler thumbnails by the original thumbnail'] Monitoring: 'Thread Updater': [true, 'Update threads. Has more options in its own dialog.'] 'Unread Count': [true, 'Show unread post count in tab title'] '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: 'Auto Noko': [true, 'Always redirect to your post'] 'Cooldown': [true, 'Prevent \'flood detected\' errors'] 'Quick Reply': [true, 'Reply without leaving the page'] 'Persistent QR': [false, 'Quick reply won\'t disappear after posting. Only in replies.'] 'Auto Hide QR': [true, 'Automatically auto-hide the quick reply when posting'] 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'] 'Indicate OP quote': [true, 'Add \'(OP)\' to OP quotes'] flavors: [ 'http://regex.info/exif.cgi?url=' 'http://iqdb.org/?url=' 'http://google.com/searchbyimage?image_url=' '#http://tineye.com/search?url=' '#http://saucenao.com/search.php?db=999&url=' '#http://imgur.com/upload?url=' '#http://anonym.to/?' ].join '\n' time: '%m/%d/%y(%a)%H:%M' hotkeys: close: 'Esc' spoiler: 'ctrl+s' openQR: 'i' openEmptyQR: 'I' submit: 'alt+s' nextReply: 'J' previousReply: 'K' nextThread: 'n' previousThread: 'p' nextPage: 'L' previousPage: 'H' zero: '0' openThreadTab: 'o' openThread: 'O' expandThread: 'e' watch: 'w' hide: 'x' expandImages: 'm' expandAllImages: 'M' update: 'u' unreadCountTo0: 'z' updater: checkbox: 'Scrolling': [false, 'Scroll updated posts into view. Only enabled at bottom of page.'] 'Verbose': [true, 'Show countdown timer, new post count'] 'Auto Update': [true, 'Automatically fetch new posts'] 'Interval': 30 # flatten the config conf = {} ((parent, obj) -> if obj.length #array if typeof obj[0] is 'boolean' conf[parent] = obj[0] else conf[parent] = obj else if typeof obj is 'object' for key, val of obj arguments.callee key, val else #constant conf[parent] = obj ) null, config # XXX chrome can't into `{log} = console` if console? log = (arg) -> console.log arg # XXX opera cannot into Object.keys if not Object.keys Object.keys = (o) -> key for key in o NAMESPACE = 'AEOS.4chan_x.' d = document g = callbacks: [] ui = dialog: (id, position, html) -> el = d.createElement 'div' el.className = 'reply dialog' el.innerHTML = html el.id = id {left, top} = position left = localStorage["#{NAMESPACE}#{id}Left"] ? left top = localStorage["#{NAMESPACE}#{id}Top"] ? top if left then el.style.left = left else el.style.right = 0 if top then el.style.top = top else el.style.bottom = 0 el.querySelector('div.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) -> {el} = ui left = e.clientX - ui.dx if left < 10 then left = '0' else if ui.width - left < 10 then left = null right = if left then null else 0 top = e.clientY - ui.dy if top < 10 then top = '0' else if ui.height - top < 10 then top = null bottom = if top then null else 0 #using null instead of '' is 4% faster #these 4 statements are 40% faster than 1 style.cssText el.style.top = top el.style.right = right el.style.bottom = bottom el.style.left = left dragend: -> #$ coffee -bpe '{a} = {b} = c' #var a, b; #a = (b = c.b, c).a; {el} = ui {id} = el localStorage["#{NAMESPACE}#{id}Left"] = el.style.left localStorage["#{NAMESPACE}#{id}Top"] = el.style.top d.removeEventListener 'mousemove', ui.drag, false d.removeEventListener 'mouseup', ui.dragend, false hover: (e) -> {clientX, clientY} = e {el} = ui {clientHeight, clientWidth} = d.body height = el.offsetHeight top = clientY - 120 el.style.top = if clientHeight < height or top < 0 0 else if top + height > clientHeight clientHeight - height else top if clientX < clientWidth - 400 el.style.left = clientX + 45 el.style.right = null else el.style.left = null el.style.right = clientWidth - clientX + 45 hoverend: (e) -> ui.el.parentNode.removeChild 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 object $.extend $, id: (id) -> d.getElementById id globalEval: (code) -> script = $.el 'script', textContent: "(#{code})()" $.append d.head, script $.rm script get: (url, cb) -> r = new XMLHttpRequest() r.onload = cb r.open 'get', url, true r.send() r cache: (url, cb) -> if req = $.cache.requests[url] if req.readyState is 4 cb.call req else req.callbacks.push cb else req = $.get url, (-> cb.call @ for cb in @callbacks) req.callbacks = [cb] $.cache.requests[url] = req cb: checked: -> $.setValue @name, @checked value: -> $.setValue @name, @value addStyle: (css) -> style = $.el 'style', textContent: css $.append d.head, style style x: (path, root=d.body) -> d.evaluate(path, root, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null). singleNodeValue tn: (s) -> d.createTextNode s replace: (root, el) -> root.parentNode.replaceChild el, root hide: (el) -> el.hidden = true show: (el) -> el.hidden = false addClass: (el, className) -> el.className += ' ' + className removeClass: (el, className) -> el.className = el.className.replace ' ' + className, '' rm: (el) -> el.parentNode.removeChild el append: (parent, children...) -> for child in children parent.appendChild child prepend: (parent, child) -> parent.insertBefore child, parent.firstChild after: (root, el) -> root.parentNode.insertBefore el, root.nextSibling before: (root, el) -> root.parentNode.insertBefore el, root el: (tag, properties) -> el = d.createElement tag $.extend el, properties if properties el bind: (el, eventType, handler) -> el.addEventListener eventType, handler, false unbind: (el, eventType, handler) -> el.removeEventListener eventType, handler, false isDST: -> # XXX this should check for DST in NY ### http://en.wikipedia.org/wiki/Daylight_saving_time_in_the_United_States Since 2007, daylight saving time starts on the second Sunday of March and ends on the first Sunday of November, with all time changes taking place at 2:00 AM (0200) local time. ### date = new Date() month = date.getMonth() #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.getDate() - date.getDay() if month is 2 #before second sunday if sunday < 8 return false #during second sunday if sunday < 15 and date.getDay() is 0 if date.getHour() < 1 return false return true #after second sunday return true if month is 10 # before first sunday if sunday < 1 return true # during first sunday if sunday < 8 and date.getDay() is 0 if date.getHour() < 1 return true return false #after first sunday return false $.cache.requests = {} if GM_deleteValue? $.extend $, deleteValue: (name) -> name = NAMESPACE + name GM_deleteValue name getValue: (name, defaultValue) -> name = NAMESPACE + name if value = GM_getValue name JSON.parse value else defaultValue openInTab: (url) -> GM_openInTab url setValue: (name, value) -> name = NAMESPACE + name # for `storage` events localStorage[name] = JSON.stringify value GM_setValue name, JSON.stringify value else $.extend $, deleteValue: (name) -> name = NAMESPACE + name delete localStorage[name] getValue: (name, defaultValue) -> name = NAMESPACE + name if value = localStorage[name] JSON.parse value else defaultValue openInTab: (url) -> window.open url, "_blank" setValue: (name, value) -> name = NAMESPACE + name localStorage[name] = JSON.stringify value #load values from localStorage for key, val of conf conf[key] = $.getValue key, val $$ = (selector, root=d.body) -> Array::slice.call root.querySelectorAll selector expandComment = init: -> for a in $$ 'span.abbr a' $.bind a, 'click', expandComment.expand 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 body = $.el 'body', innerHTML: req.responseText if threadID is replyID #OP bq = $ 'blockquote', body else #css selectors don't like ids starting with numbers, # getElementById only works for root document. for reply in $$ 'td[id]', body if reply.id == replyID bq = $ 'blockquote', reply break $.replace a.parentNode.parentNode, bq expandThread = init: -> for span in $$ 'span.omittedposts' a = $.el 'a', textContent: "+ #{span.textContent}" className: 'omittedposts' $.bind a, 'click', expandThread.cb.toggle $.replace span, a cb: toggle: (e) -> thread = @parentNode expandThread.toggle thread toggle: (thread) -> threadID = thread.firstChild.id pathname = "/#{g.BOARD}/res/#{threadID}" a = $ 'a.omittedposts', thread switch a.textContent[0] when '+' $('.op .container', thread)?.innerHTML = '' a.textContent = a.textContent.replace '+', 'X Loading...' $.cache pathname, (-> expandThread.parse @, pathname, thread, a) when 'X' a.textContent = a.textContent.replace 'X Loading...', '+' #FIXME this will kill all callbacks $.cache[pathname].abort() when '-' a.textContent = a.textContent.replace '-', '+' #goddamit moot num = switch g.BOARD when 'b' then 3 when 't' then 1 else 5 table = $.x "following::br[@clear][1]/preceding::table[#{num}]", a while (prev = table.previousSibling) and (prev.nodeName is 'TABLE') $.rm prev for backlink in $$ '.op a.backlink' $.rm backlink if !$.id backlink.hash[1..] parse: (req, pathname, thread, a) -> if req.status isnt 200 a.textContent = "#{req.status} #{req.statusText}" $.unbind a, 'click', expandThread.cb.toggle return a.textContent = a.textContent.replace 'X Loading...', '-' # eat everything, then replace with fresh full posts while (next = a.nextSibling) and not next.clear #br[clear] $.rm next br = next body = $.el 'body', innerHTML: req.responseText for reply in $$ 'td[id]', body for quote in $$ 'a.quotelink', reply if quote.getAttribute('href') is quote.hash quote.pathname = pathname link = $ 'a.quotejs', reply link.href = "res/#{thread.firstChild.id}##{reply.id}" link.nextSibling.href = "res/#{thread.firstChild.id}#q#{reply.id}" tables = $$ 'form[name=delform] table', body tables.pop() for table in tables $.before br, table replyHiding = init: -> g.callbacks.push (root) -> return unless dd = $ 'td.doubledash', root dd.className = 'replyhider' a = $.el 'a', textContent: '[ - ]' $.bind a, 'click', replyHiding.cb.hide $.replace dd.firstChild, a reply = dd.nextSibling id = reply.id if id of g.hiddenReplies replyHiding.hide reply cb: hide: (e) -> reply = @parentNode.nextSibling replyHiding.hide reply show: (e) -> div = @parentNode table = div.nextSibling replyHiding.show table $.rm div hide: (reply) -> table = reply.parentNode.parentNode.parentNode $.hide table if conf['Show Stubs'] name = $('span.commentpostername', reply).textContent trip = $('span.postertrip', reply)?.textContent or '' a = $.el 'a', textContent: "[ + ] #{name} #{trip}" $.bind a, 'click', replyHiding.cb.show div = $.el 'div', className: 'stub' $.append div, a $.before table, div id = reply.id g.hiddenReplies[id] = Date.now() $.setValue "hiddenReplies/#{g.BOARD}/", g.hiddenReplies show: (table) -> $.show table id = $('td[id]', table).id delete g.hiddenReplies[id] $.setValue "hiddenReplies/#{g.BOARD}/", g.hiddenReplies keybinds = init: -> for node in $$ '[accesskey]' node.removeAttribute 'accesskey' $.bind d, 'keydown', keybinds.keydown keydown: (e) -> updater.focus = true return if e.target.nodeName in ['TEXTAREA', 'INPUT'] and not e.altKey and not e.ctrlKey and not (e.keyCode is 27) return unless key = keybinds.keyCode e thread = nav.getThread() switch key when conf.close if o = $ '#overlay' $.rm o else if qr.el qr.close() when conf.spoiler ta = e.target return unless ta.nodeName is 'TEXTAREA' value = ta.value selStart = ta.selectionStart selEnd = ta.selectionEnd valStart = value[0...selStart] + '[spoiler]' valMid = value[selStart...selEnd] valEnd = '[/spoiler]' + value[selEnd..] ta.value = valStart + valMid + valEnd range = valStart.length + valMid.length ta.setSelectionRange range, range when conf.zero window.location = "/#{g.BOARD}/0#0" when conf.openEmptyQR keybinds.qr thread when conf.nextReply keybinds.hl.next thread when conf.previousReply keybinds.hl.prev thread when conf.expandAllImages keybinds.img thread, true when conf.openThread keybinds.open thread when conf.expandThread expandThread.toggle thread when conf.openQR keybinds.qr thread, true when conf.expandImages keybinds.img thread when conf.nextThread nav.next() when conf.openThreadTab keybinds.open thread, true when conf.previousThread nav.prev() when conf.update updater.update() when conf.watch watcher.toggle thread when conf.hide threadHiding.toggle thread when conf.nextPage $('input[value=Next]')?.click() when conf.previousPage $('input[value=Previous]')?.click() when conf.submit if qr.el qr.submit.call $ 'form', qr.el else $('.postarea form').submit() when conf.unreadCountTo0 unread.replies.length = 0 unread.updateTitle() Favicon.update() else return e.preventDefault() keyCode: (e) -> kc = e.keyCode if 65 <= kc <= 90 #A-Z key = String.fromCharCode kc if !e.shiftKey key = key.toLowerCase() else if 48 <= kc <= 57 #0-9 key = String.fromCharCode kc else if kc is 27 key = 'Esc' else if kc is 8 key = '' else key = null if key if e.altKey then key = 'alt+' + key if e.ctrlKey then key = 'ctrl+' + key key img: (thread, all) -> if all $("#imageExpand").click() else root = $('td.replyhl', thread) or thread thumb = $ 'img[md5]', root imgExpand.toggle thumb.parentNode qr: (thread, quote) -> unless qrLink = $ 'td.replyhl span[id] a:not(:first-child)', thread qrLink = $ "span[id^=nothread] a:not(:first-child)", thread if quote qr.quote qrLink else unless qr.el qr.dialog qrLink $('textarea', qr.el).focus() open: (thread, tab) -> id = thread.firstChild.id url = "http://boards.4chan.org/#{g.BOARD}/res/#{id}" if tab $.openInTab url else location.href = url hl: next: (thread) -> if td = $ 'td.replyhl', thread td.className = 'reply' rect = td.getBoundingClientRect() if rect.top > 0 and rect.bottom < d.body.clientHeight #you're fully visible next = $.x 'following::td[@class="reply"]', td return if $.x('ancestor::div[@class="thread"]', next) isnt thread rect = next.getBoundingClientRect() if rect.top > 0 and rect.bottom < d.body.clientHeight #and so is the next next.className = 'replyhl' return replies = $$ 'td.reply', thread for reply in replies top = reply.getBoundingClientRect().top if top > 0 reply.className = 'replyhl' return prev: (thread) -> if td = $ 'td.replyhl', thread td.className = 'reply' rect = td.getBoundingClientRect() if rect.top > 0 and rect.bottom < d.body.clientHeight #you're fully visible prev = $.x 'preceding::td[@class="reply"][1]', td rect = prev.getBoundingClientRect() if rect.top > 0 and rect.bottom < d.body.clientHeight #and so is the prev prev.className = 'replyhl' return replies = $$ 'td.reply', thread replies.reverse() height = d.body.clientHeight for reply in replies bot = reply.getBoundingClientRect().bottom if bot < height reply.className = 'replyhl' return nav = # ◀ ▶ init: -> span = $.el 'span', id: 'navlinks' prev = $.el 'a', textContent: '▲' next = $.el 'a', textContent: '▼' $.bind prev, 'click', nav.prev $.bind next, 'click', nav.next $.append span, prev, $.tn(' '), next $.append d.body, span prev: -> nav.scroll -1 next: -> nav.scroll +1 threads: [] getThread: (full) -> nav.threads = $$ 'div.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 null scroll: (delta) -> if g.REPLY if delta is -1 window.scrollTo 0,0 else window.scrollTo 0, d.body.scrollHeight return [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 if i is -1 if g.PAGENUM is 0 window.scrollTo 0, 0 else window.location = "#{g.PAGENUM - 1}#0" return if delta is +1 # if we're at the last thread, or we're at the bottom of the page. # kind of hackish, what we really need to do is make nav.getThread smarter. if i is nav.threads.length or (innerHeight + pageYOffset == d.body.scrollHeight) if $ 'table.pages input[value="Next"]' window.location = "#{g.PAGENUM + 1}#0" return #TODO sfx {top} = nav.threads[i].getBoundingClientRect() window.scrollBy 0, top options = init: -> home = $ '#navtopr a' a = $.el 'a', textContent: '4chan X' $.bind a, 'click', options.dialog $.replace home, a home = $ '#navbotr a' a = $.el 'a', textContent: '4chan X' $.bind a, 'click', options.dialog $.replace home, a dialog: -> hiddenThreads = $.getValue "hiddenThreads/#{g.BOARD}/", {} hiddenNum = Object.keys(g.hiddenReplies).length + Object.keys(hiddenThreads).length html = "

" dialog = $.el 'div', id: 'options', innerHTML: html main = $('#main', dialog) for key, obj of config.main ul = $.el 'ul', textContent: key hidingul = ul if key is 'Hiding' for key, arr of obj checked = if conf[key] then "checked" else "" description = arr[1] li = $.el 'li', innerHTML: ": #{description}" $.append ul, li $.append main, ul li = $.el 'li', innerHTML: " : Forget all hidden posts. Useful if you accidentally hide a post and have `show stubs` disabled." $.append hidingul, li for input in $$ 'input[type=checkbox]', dialog $.bind input, 'click', $.cb.checked $.bind $('input[type=button]', dialog), 'click', options.clearHidden $.bind link, 'click', options.tab for link in $$ '#floaty a', dialog $.bind $('textarea[name=flavors]', dialog), 'change', $.cb.value $.bind $('input[name=time]', dialog), 'keyup', options.time for input in $$ '#keybinds input', dialog input.value = conf[input.name] $.bind input, 'keydown', options.keybind ### Two parent divs are necessary to center on all browsers. Only one when Firefox and Opera will support flexboxes correctly. https://bugzilla.mozilla.org/show_bug.cgi?id=579776 ### overlay = $.el 'div', id: 'overlay' $.append overlay, dialog $.append d.body, overlay options.time.call $('input[name=time]', dialog) $.bind overlay, 'click', -> $.rm overlay $.bind dialog.firstElementChild, 'click', (e) -> e.stopPropagation() tab: -> for div in $('#content').children if div.id is @name $.show div else $.hide div clearHidden: (e) -> #'hidden' might be misleading; it's the number of IDs we're *looking* for, # not the number of posts actually hidden on the page. $.deleteValue "hiddenReplies/#{g.BOARD}/" $.deleteValue "hiddenThreads/#{g.BOARD}/" @value = "hidden: 0" g.hiddenReplies = {} keybind: (e) -> e.preventDefault() e.stopPropagation() return unless (key = keybinds.keyCode e)? @value = key $.setValue @name, key conf[@name] = key time: (e) -> $.setValue 'time', @value conf['time'] = @value Time.foo() Time.date = new Date() $('#timePreview').textContent = Time.funk Time cooldown = init: -> if /cooldown/.test location.search [_, time] = location.search.match /cooldown=(\d+)/ $.setValue g.BOARD+'/cooldown', time if $.getValue(g.BOARD+'/cooldown', 0) < time cooldown.start() if Date.now() < $.getValue g.BOARD+'/cooldown', 0 $.bind window, 'storage', (e) -> cooldown.start() if e.key is "#{NAMESPACE}#{g.BOARD}/cooldown" if g.REPLY form = $('.postarea form') form.action += '?cooldown' input = $('.postarea input[name=email]') if /sage/i.test input.value form.action += '?sage' $.bind input, 'keyup', cooldown.sage sage: -> form = $('.postarea form') if /sage/i.test @value unless /sage/.test form.action form.action += '?sage' else form.action = form.action.replace '?sage', '' start: -> cooldown.duration = Math.ceil ($.getValue(g.BOARD+'/cooldown', 0) - Date.now()) / 1000 for submit in $$ '#com_submit' submit.value = cooldown.duration submit.disabled = true cooldown.interval = window.setInterval cooldown.cb, 1000 cb: -> submits = $$ '#com_submit' if --cooldown.duration > 0 for submit in submits submit.value = cooldown.duration else window.clearInterval cooldown.interval for submit in submits submit.disabled = false submit.value = 'Submit' if qr.el and $('#auto', qr.el).checked qr.auto() qr = init: -> g.callbacks.push qr.cb.node iframe = $.el 'iframe', name: 'iframe' hidden: true $.append d.body, iframe $.bind window, 'message', qr.cb.message #hack - nuke id so it doesn't grab focus when reloading $('#recaptcha_response_field').id = '' qr.captcha = [] autohide: set: -> $('#autohide:not(:checked)', qr.el)?.click() unset: -> $('#autohide:checked', qr.el)?.click() cb: autohide: (e) -> if @checked $.addClass qr.el, 'auto' else $.removeClass qr.el, 'auto' message: (e) -> Recaptcha.reload() $('iframe[name=iframe]').src = 'about:blank' {data} = e if data # error message data = JSON.parse data $.extend $('#error', qr.el), data $('input[name=recaptcha_response_field]', qr.el).value = '' qr.autohide.unset() if data.textContent is 'You seem to have mistyped the verification.' qr.auto() return if qr.el file = $ '#files input', qr.el if g.REPLY and (conf['Persistent QR'] or file) qr.refresh() if file oldFile = $ '#qr_form input[type=file]', qr.el $.replace oldFile, file else qr.close() if conf['Cooldown'] duration = if qr.sage then 60 else 30 $.setValue g.BOARD+'/cooldown', Date.now() + duration * 1000 cooldown.start() node: (root) -> quote = $ 'a.quotejs:not(:first-child)', root $.bind quote, 'click', qr.cb.quote quote: (e) -> e.preventDefault() qr.quote @ auto: -> responseField = $ 'input[name=recaptcha_response_field]', qr.el if !responseField.value and captcha = qr.captcha.shift() $('input[name=recaptcha_challenge_field]', qr.el).value = captcha.challenge responseField.value = captcha.response responseField.nextSibling.textContent = qr.captcha.length qr.submit.call $ 'form', qr.el push: -> @nextSibling.textContent = qr.captcha.push challenge: $('input[name=recaptcha_challenge_field]', qr.el).value response: @value Recaptcha.reload() @value = '' submit: (e) -> if conf['Auto Watch Reply'] and conf['Thread Watcher'] if g.REPLY and $('img.favicon').src is Favicon.empty watcher.watch null, g.THREAD_ID else id = $('input[name=resto]', qr.el).value op = $.id id if $('img.favicon', op).src is Favicon.empty watcher.watch op, id isQR = @id is 'qr_form' inputfile = $('input[type=file]', @) if inputfile.value and inputfile.files[0].size > $('input[name=MAX_FILE_SIZE]').value e.preventDefault() if e if isQR $('#error', qr.el).textContent = 'Error: File too large.' else alert 'Error: File too large.' else if isQR if !e then @submit() $('#error', qr.el).textContent = '' qr.autohide.set() if conf['Auto Hide QR'] qr.sage = /sage/i.test $('input[name=email]', @).value quote: (link) -> if qr.el qr.autohide.unset() else qr.dialog link id = link.textContent text = ">>#{id}\n" selection = window.getSelection() if s = selection.toString() selectionID = $.x('preceding::input[@type="checkbox"][1]', selection.anchorNode)?.name if selectionID == id s = s.replace /\n/g, '\n>' text += ">#{s}\n" ta = $ 'textarea', qr.el ta.focus() ta.value += text refresh: -> auto = $('#auto', qr.el).checked $('form', qr.el).reset() $('#auto', qr.el).checked = auto c = d.cookie $('input[name=name]', qr.el).value = if m = c.match(/4chan_name=([^;]+)/) then decodeURIComponent m[1] else '' $('input[name=email]', qr.el).value = if m = c.match(/4chan_email=([^;]+)/) then decodeURIComponent m[1] else '' $('input[name=pwd]', qr.el).value = if m = c.match(/4chan_pass=([^;]+)/) then decodeURIComponent m[1] else $('input[name=pwd]').value add: -> file = $.el 'input', type: 'file', name: 'upfile' files = $ '#files', qr.el $.append files, file dialog: (link) -> submitValue = $('#com_submit').value submitDisabled = if $('#com_submit').disabled then 'disabled' else '' #FIXME inlined cross-thread quotes THREAD_ID = g.THREAD_ID or $.x('ancestor::div[@class="thread"]/div', link).id spoiler = if $('.postarea label') then '' else '' challenge = $('input[name=recaptcha_challenge_field]').value html = "
Quick Reply X
#{spoiler}
0
attach another file
" qr.el = ui.dialog 'qr', top: '0px', left: '0px', html qr.refresh() $('textarea', qr.el).value = $('textarea').value $.bind $('input[name=name]', qr.el), 'mousedown', (e) -> e.stopPropagation() $.bind $('#autohide', qr.el), 'click', qr.cb.autohide $.bind $('a[name=close]', qr.el), 'click', qr.close $.bind $('form', qr.el), 'submit', qr.submit $.bind $('a[name=add]', qr.el), 'click', qr.add $.bind $('img', qr.el), 'click', Recaptcha.reload $.bind $('input[name=recaptcha_response_field]', qr.el), 'keydown', Recaptcha.listener $.append d.body, qr.el persist: -> qr.dialog() qr.autohide.set() if conf['Auto Hide QR'] close: -> $.rm qr.el qr.el = null sys: -> if recaptcha = $ '#recaptcha_response_field' #post reporting $.bind recaptcha, 'keydown', Recaptcha.listener return ### http://code.google.com/p/chromium/issues/detail?id=20773 Let content scripts see other frames (instead of them being undefined) To access the parent, we have to break out of the sandbox and evaluate in the global context. ### $.globalEval -> if node = document.querySelector('table font b')?.firstChild {textContent, href} = node data = JSON.stringify {textContent, href} else data = '' parent.postMessage data, '*' c = $('b').lastChild if c.nodeType is 8 #comment node [_, thread, id] = c.textContent.match(/thread:(\d+),no:(\d+)/) {search} = location cooldown = /cooldown/.test search noko = /noko/ .test search sage = /sage/ .test search watch = /watch/ .test search url = "http://boards.4chan.org/#{g.BOARD}" if watch and thread is '0' url += "/res/#{id}?watch" else if noko url += '/res/' url += if thread is '0' then id else thread if cooldown duration = Date.now() + (if sage then 60 else 30) * 1000 url += '?cooldown=' + duration if noko url += '#' + id window.location = url threading = init: -> # don't thread image controls node = $ 'form[name=delform] > *:not([id])' threading.thread node op: (node) -> op = $.el 'div', className: 'op' $.before node, op while node.nodeName isnt 'BLOCKQUOTE' $.append op, node node = op.nextSibling $.append op, node #add the blockquote op.id = $('input[name]', op).name op thread: (node) -> node = threading.op node return if g.REPLY div = $.el 'div', className: 'thread' $.before node, div while node.nodeName isnt 'HR' $.append div, node node = div.nextSibling node = node.nextElementSibling #skip text node #{N,}SFW unless node.align or node.nodeName is 'CENTER' threading.thread node threadHiding = init: -> hiddenThreads = $.getValue "hiddenThreads/#{g.BOARD}/", {} for thread in $$ 'div.thread' op = thread.firstChild a = $.el 'a', textContent: '[ - ]' $.bind a, 'click', threadHiding.cb.hide $.prepend op, a if op.id of hiddenThreads threadHiding.hideHide thread cb: hide: (e) -> thread = @parentNode.parentNode threadHiding.hide thread show: (e) -> thread = @parentNode.parentNode threadHiding.show thread toggle: (thread) -> if thread.className.indexOf('stub') != -1 or thread.hidden threadHiding.show thread else threadHiding.hide thread hide: (thread) -> threadHiding.hideHide thread id = thread.firstChild.id hiddenThreads = $.getValue "hiddenThreads/#{g.BOARD}/", {} hiddenThreads[id] = Date.now() $.setValue "hiddenThreads/#{g.BOARD}/", hiddenThreads hideHide: (thread) -> if conf['Show Stubs'] if span = $ '.omittedposts', thread num = Number span.textContent.match(/\d+/)[0] else num = 0 num += $$('table', thread).length text = if num is 1 then "1 reply" else "#{num} replies" name = $('span.postername', thread).textContent trip = $('span.postername + span.postertrip', thread)?.textContent or '' a = $.el 'a', textContent: "[ + ] #{name}#{trip} (#{text})" $.bind a, 'click', threadHiding.cb.show div = $.el 'div', className: 'block' $.append div, a $.append thread, div $.addClass thread, 'stub' else $.hide thread $.hide thread.nextSibling show: (thread) -> $.rm $ 'div.block', thread $.removeClass thread, 'stub' $.show thread $.show thread.nextSibling id = thread.firstChild.id hiddenThreads = $.getValue "hiddenThreads/#{g.BOARD}/", {} delete hiddenThreads[id] $.setValue "hiddenThreads/#{g.BOARD}/", hiddenThreads updater = init: -> if conf['Scrolling'] $.bind window, 'focus', (-> updater.focus = true) $.bind window, 'blur', (-> updater.focus = false) 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 updater.count = $ '#count', dialog updater.timer = $ '#timer', dialog for input in $$ 'input', dialog if input.type is 'checkbox' $.bind input, 'click', $.cb.checked $.bind input, 'click', -> conf[@name] = @checked if input.name is 'Verbose' $.bind input, 'click', updater.cb.verbose updater.cb.verbose.call input else if input.name is 'Auto Update This' $.bind input, 'click', updater.cb.autoUpdate updater.cb.autoUpdate.call input else if input.name is 'Interval' $.bind input, 'change', -> conf['Interval'] = @value = parseInt(@value) or conf['Interval'] $.bind input, 'change', $.cb.value else if input.type is 'button' $.bind input, 'click', updater.updateNow $.append d.body, dialog cb: verbose: -> if conf['Verbose'] updater.count.textContent = '+0' $.show updater.timer else $.extend updater.count, className: '' textContent: 'Thread Updater' $.hide updater.timer autoUpdate: -> if @checked updater.intervalID = window.setInterval updater.timeout, 1000 else window.clearInterval updater.intervalID update: -> if @status is 404 updater.timer.textContent = '' updater.count.textContent = 404 updater.count.className = 'error' window.clearInterval updater.intervalID for input in $$ '#com_submit' input.disabled = true input.value = 404 d.title = d.title.match(/.+- /)[0] + 404 g.dead = true Favicon.update() return br = $ 'br[clear]' id = Number $('td[id]', br.previousElementSibling)?.id or 0 arr = [] body = $.el 'body', innerHTML: @responseText replies = $$ 'td[id]', body while (reply = replies.pop()) and (reply.id > id) arr.push reply.parentNode.parentNode.parentNode #table scroll = conf['Scrolling'] && updater.focus && arr.length && (d.body.scrollHeight - d.body.clientHeight - window.scrollY < 20) updater.timer.textContent = '-' + conf['Interval'] if conf['Verbose'] updater.count.textContent = '+' + arr.length if arr.length is 0 updater.count.className = '' else updater.count.className = 'new' #XXX add replies in correct order so backlinks resolve while reply = arr.pop() $.before br, reply if scroll scrollTo 0, d.body.scrollHeight timeout: -> n = Number updater.timer.textContent ++n if n is 10 updater.count.textContent = 'retry' updater.count.className = '' n = 0 updater.timer.textContent = n if n is 0 updater.update() updateNow: -> updater.timer.textContent = 0 updater.update() update: -> updater.request?.abort() url = location.pathname + '?' + Date.now() # fool the cache cb = updater.cb.update updater.request = $.get url, cb watcher = init: -> html = '
Thread Watcher
' watcher.dialog = ui.dialog 'watcher', top: '50px', left: '0px', html $.append d.body, watcher.dialog #add watch buttons inputs = $$ '.op input' for input in inputs favicon = $.el 'img', className: 'favicon' $.bind favicon, 'click', watcher.cb.toggle $.before input, favicon #populate watcher, display watch buttons watcher.refresh() $.bind window, 'storage', (e) -> watcher.refresh() if e.key is "#{NAMESPACE}watched" refresh: -> watched = $.getValue 'watched', {} for div in $$ 'div:not(.move)', watcher.dialog $.rm div for board of watched for id, props of watched[board] div = $.el 'div' x = $.el 'a', textContent: 'X' $.bind x, 'click', watcher.cb.x link = $.el 'a', props $.append div, x, $.tn(' '), link $.append watcher.dialog, div watchedBoard = watched[g.BOARD] or {} for favicon in $$ 'img.favicon' id = favicon.nextSibling.name if id of watchedBoard favicon.src = Favicon.default else favicon.src = Favicon.empty cb: toggle: (e) -> watcher.toggle @parentNode x: (e) -> [board, _, id] = @nextElementSibling .getAttribute('href').substring(1).split('/') watcher.unwatch board, id toggle: (thread) -> favicon = $ 'img.favicon', thread id = favicon.nextSibling.name if favicon.src == Favicon.empty watcher.watch thread, id else # favicon.src == Favicon.default watcher.unwatch g.BOARD, id unwatch: (board, id) -> watched = $.getValue 'watched', {} delete watched[board][id] $.setValue 'watched', watched watcher.refresh() watch: (thread, id) -> tc = $('span.filetitle', thread).textContent or $('blockquote', thread).textContent props = textContent: "/#{g.BOARD}/ - #{tc[...25]}" href: "/#{g.BOARD}/res/#{id}" watched = $.getValue 'watched', {} watched[g.BOARD] or= {} watched[g.BOARD][id] = props $.setValue 'watched', watched watcher.refresh() anonymize = init: -> g.callbacks.push (root) -> name = $ 'span.commentpostername, span.postername', root name.textContent = 'Anonymous' if trip = $ 'span.postertrip', root if trip.parentNode.nodeName is 'A' $.rm trip.parentNode else $.rm trip sauce = init: -> sauce.prefixes = (s for s in (conf['flavors'].split '\n') when s[0] != '#') sauce.names = (prefix.match(/(\w+)\./)[1] for prefix in sauce.prefixes) g.callbacks.push (root) -> return if root.className is 'inline' if span = $ 'span.filesize', root suffix = $('a', span).href for prefix, i in sauce.prefixes link = $.el 'a', textContent: sauce.names[i] href: prefix + suffix $.append span, $.tn(' '), link revealSpoilers = init: -> g.callbacks.push (root) -> return if not (img = $ 'img[alt^=Spoiler]', root) or root.className is 'inline' img.removeAttribute 'height' img.removeAttribute 'width' [_, board, nb] = img.parentNode.href.match /(\w+)\/src\/(\d+)/ img.src = "http://0.thumbs.4chan.org/#{board}/thumb/#{nb}s.jpg" Time = init: -> Time.foo() g.callbacks.push Time.node node: (root) -> return if root.className is 'inline' s = $('span[id^=no]', root).previousSibling [_, month, day, year, hour, min] = s.textContent.match /(\d+)\/(\d+)\/(\d+)\(\w+\)(\d+):(\d+)/ year = "20#{year}" month -= 1 #months start at 0 hour = g.chanOffset + Number hour Time.date = new Date year, month, day, hour, min #XXX /b/ will have seconds cut off time = $.el 'time', textContent: ' ' + Time.funk(Time) + ' ' $.replace s, 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 titlePost = init: -> if tc = $('span.filetitle').textContent or $('blockquote').textContent d.title = "/#{g.BOARD}/ - #{tc}" quoteBacklink = init: -> g.callbacks.push (root) -> return if /inline/.test root.className # op or reply id = root.id or $('td[id]', root).id quotes = {} for quote in $$ 'a.quotelink', root #don't process >>>/b/ continue unless qid = quote.hash[1..] #duplicate quotes get overwritten quotes[qid] = quote for qid of quotes continue unless el = $.id qid #don't backlink the op continue if !conf['OP Backlinks'] and el.className is 'op' link = $.el 'a', href: "##{id}" className: 'backlink' textContent: ">>#{id}" if conf['Quote Preview'] $.bind link, 'mouseover', quotePreview.mouseover $.bind link, 'mousemove', ui.hover $.bind link, 'mouseout', quotePreview.mouseout if conf['Quote Inline'] $.bind link, 'click', quoteInline.toggle unless container = $ '.container', el container = $.el 'span', className: 'container' root = $('.reportbutton', el) or $('span[id^=no]', el) $.after root, container $.append container, $.tn(' '), link quoteInline = init: -> g.callbacks.push (root) -> for quote in $$ 'a.quotelink, a.backlink', root continue unless quote.hash quote.removeAttribute 'onclick' $.bind quote, 'click', quoteInline.toggle toggle: (e) -> return if e.shiftKey or e.altKey or e.ctrlKey or e.button isnt 0 e.preventDefault() id = @hash[1..] if table = $ "#i#{id}", $.x 'ancestor::td[1]', @ $.rm table $.removeClass @, 'inlined' for inlined in $$ 'input', table if hidden = $.id inlined.name $.show $.x 'ancestor::table[1]', hidden return root = if @parentNode.nodeName is 'FONT' then @parentNode else if @nextSibling then @nextSibling else @ if el = $.id id inline = quoteInline.table id, el.innerHTML if @className is 'backlink' return if $("a.backlink[href='##{id}']", el) $.after @parentNode, inline $.hide $.x 'ancestor::table[1]', el else $.after root, inline else inline = $.el 'td', className: 'reply inline' id: "i#{id}" innerHTML: "Loading #{id}..." $.after root, inline {pathname} = @ threadID = pathname.split('/').pop() $.cache pathname, (-> quoteInline.parse @, pathname, id, threadID, inline) $.addClass @, 'inlined' parse: (req, pathname, id, threadID, inline) -> return unless inline.parentNode if req.status isnt 200 inline.innerHTML = "#{req.status} #{req.statusText}" return body = $.el 'body', innerHTML: req.responseText if id == threadID #OP op = threading.op $ 'form[name=delform] > *', body html = op.innerHTML else for reply in $$ 'td.reply', body if reply.id == id html = reply.innerHTML break newInline = quoteInline.table id, html for quote in $$ 'a.quotelink', newInline if quote.getAttribute('href') is quote.hash quote.pathname = pathname $.addClass newInline, 'crossquote' $.replace inline, newInline table: (id, html) -> $.el 'table', className: 'inline' id: "i#{id}" innerHTML: "#{html}" quotePreview = init: -> g.callbacks.push (root) -> for quote in $$ 'a.quotelink, a.backlink', root continue unless quote.hash $.bind quote, 'mouseover', quotePreview.mouseover $.bind quote, 'mousemove', ui.hover $.bind quote, 'mouseout', quotePreview.mouseout mouseover: (e) -> qp = ui.el = $.el 'div', id: 'qp' className: 'replyhl' $.append d.body, qp id = @hash[1..] if el = $.id id qp.innerHTML = el.innerHTML $.addClass el, 'qphl' if conf['Quote Highlighting'] if /backlink/.test @className replyID = $.x('ancestor::*[@id][1]', @).id.match(/\d+/)[0] for quote in $$ 'a.quotelink', qp if quote.hash[1..] is replyID quote.className = 'forwardlink' else qp.innerHTML = "Loading #{id}..." threadID = @pathname.split('/').pop() or $.x('ancestor::div[@class="thread"]/div', @).id $.cache @pathname, (-> quotePreview.parse @, id, threadID) mouseout: -> $.removeClass el, 'qphl' if el = $.id @hash[1..] ui.hoverend() parse: (req, id, threadID) -> return unless (qp = ui.el) and (qp.innerHTML is "Loading #{id}...") if req.status isnt 200 qp.innerHTML = "#{req.status} #{req.statusText}" return body = $.el 'body', innerHTML: req.responseText if id == threadID #OP op = threading.op $ 'form[name=delform] > *', body html = op.innerHTML else for reply in $$ 'td.reply', body if reply.id == id html = reply.innerHTML break qp.innerHTML = html Time.node qp quoteOP = init: -> g.callbacks.push (root) -> return if root.className is 'inline' tid = g.THREAD_ID or $.x('ancestor::div[contains(@class,"thread")]/div', root).id for quote in $$ 'a.quotelink', root if quote.hash[1..] is tid quote.innerHTML += ' (OP)' reportButton = init: -> g.callbacks.push (root) -> if not a = $ 'a.reportbutton', root span = $ 'span[id^=no]', root a = $.el 'a', className: 'reportbutton' innerHTML: '[ ! ]' $.after span, a $.after span, $.tn(' ') $.bind a, 'click', reportButton.cb.report cb: report: (e) -> reportButton.report @ report: (target) -> input = $ 'input', target.parentNode input.click() $('input[value="Report"]').click() input.click() threadStats = init: -> threadStats.posts = 1 threadStats.images = if $ '.op img[md5]' then 1 else 0 html = "
#{threadStats.posts} / #{threadStats.images}
" dialog = ui.dialog 'stats', bottom: '0px', left: '0px', html dialog.className = 'dialog' threadStats.postcountEl = $ '#postcount', dialog threadStats.imagecountEl = $ '#imagecount', dialog $.append d.body, dialog g.callbacks.push threadStats.node node: (root) -> return if root.className threadStats.postcountEl.textContent = ++threadStats.posts if $ 'img[md5]', root threadStats.imagecountEl.textContent = ++threadStats.images if threadStats.images > 150 threadStats.imagecountEl.className = 'error' unread = init: -> unread.replies = [] d.title = '(0) ' + d.title $.bind window, 'scroll', unread.scroll g.callbacks.push unread.node node: (root) -> return if root.className unread.replies.push root unread.updateTitle() Favicon.update() scroll: (e) -> updater.focus = true 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.updateTitle() if unread.replies.length is 0 Favicon.update() updateTitle: -> d.title = d.title.replace /\d+/, unread.replies.length Favicon = dead: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAgMAAABinRfyAAAACVBMVEUAAAAAAAD/AAA9+90tAAAAAXRSTlMAQObYZgAAADtJREFUCB0FwUERxEAIALDszMG730PNSkBEBSECoU0AEPe0mly5NWprRUcDQAdn68qtkVsj3/84z++CD5u7CsnoBJoaAAAAAElFTkSuQmCC' deadHalo: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAWUlEQVR4XrWSAQoAIAgD/f+njSApsTqjGoTQ5oGWPJMOOs60CzsWwIwz1I4PUIYh+WYEMGQ6I/txw91kP4oA9BdwhKp1My4xQq6e8Q9ANgDJjOErewFiNesV2uGSfGv1/HYAAAAASUVORK5CYII=' default: $('link[rel="shortcut icon"]', d.head)?.href or '' #no favicon in `post successful` page empty: 'data:image/gif;base64,R0lGODlhEAAQAJEAAAAAAP///9vb2////yH5BAEAAAMALAAAAAAQABAAAAIvnI+pq+D9DBAUoFkPFnbs7lFZKIJOJJ3MyraoB14jFpOcVMpzrnF3OKlZYsMWowAAOw==' haloSFW: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAZklEQVR4XrWRQQoAIQwD+6L97j7Ih9WTQQxhDqJQCk4Mranuvqod6LgwawSqSuUmWSPw/UNlJlnDAmA2ARjABLYj8ZyCzJHHqOg+GdAKZmKPIQUzuYrxicHqEgHzP9g7M0+hj45sAnRWxtPj3zSPAAAAAElFTkSuQmCC' haloNSFW: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAgMAAABinRfyAAAADFBMVEUAAABmzDP///8AAABet0i+AAAAAXRSTlMAQObYZgAAAExJREFUeF4tyrENgDAMAMFXKuQswQLBG3mOlBnFS1gwDfIYLpEivvjq2MlqjmYvYg5jWEzCwtDSQlwcXKCVLrpFbvLvvSf9uZJ2HusDtJAY7Tkn1oYAAAAASUVORK5CYII=' update: -> l = unread.replies.length if g.dead if l > 0 href = Favicon.deadHalo else href = Favicon.dead else if l > 0 href = Favicon.halo else href = Favicon.default #XXX `favicon.href = href` doesn't work on Firefox favicon = $ 'link[rel="shortcut icon"]', d.head clone = favicon.cloneNode true clone.href = href $.replace favicon, clone redirect = -> switch g.BOARD when 'g', 'lit', 'sci', 'tv' url = "http://green-oval.net/cgi-board.pl/#{g.BOARD}/thread/#{g.THREAD_ID}" when 'a', 'jp', 'm', 'tg' url = "http://archive.easymodo.net/cgi-board.pl/#{g.BOARD}/thread/#{g.THREAD_ID}" when '3', 'adv', 'an', 'ck', 'co', 'fa', 'fit', 'int', 'k', 'mu', 'n', 'o', 'p', 'po', 'soc', 'sp', 'toy', 'trv', 'v', 'vp', 'x' url = "http://archive.no-ip.org/#{g.BOARD}/thread/#{g.THREAD_ID}" else url = "http://boards.4chan.org/#{g.BOARD}" location.href = url Recaptcha = init: -> #hack to tab from comment straight to recaptcha for el in $$ '#recaptcha_table a' el.tabIndex = 1 $.bind $('#recaptcha_challenge_field_holder'), 'DOMNodeInserted', Recaptcha.reloaded $.bind $('#recaptcha_response_field'), 'keydown', Recaptcha.listener listener: (e) -> if e.keyCode is 8 and @value is '' # backspace to reload Recaptcha.reload() if e.keyCode is 13 and cooldown.duration # press enter to enable auto-post if cooldown is still running $('#auto', qr.el).checked = true qr.autohide.set() if conf['Auto Hide QR'] qr.push.call this reload: -> window.location = 'javascript:Recaptcha.reload()' reloaded: (e) -> return unless qr.el {target} = e $('img', qr.el).src = "http://www.google.com/recaptcha/api/image?c=" + target.value $('input[name=recaptcha_challenge_field]', qr.el).value = target.value nodeInserted = (e) -> {target} = e if target.nodeName is 'TABLE' for callback in g.callbacks callback target imgHover = init: -> g.callbacks.push (root) -> return unless thumb = $ 'img[md5]', root $.bind thumb, 'mouseover', imgHover.mouseover $.bind thumb, 'mousemove', ui.hover $.bind thumb, 'mouseout', ui.hoverend mouseover: (e) -> ui.el = $.el 'img' id: 'iHover' src: @parentNode.href $.append d.body, ui.el imgPreloading = init: -> g.callbacks.push (root) -> return unless thumb = $ 'img[md5]', root src = thumb.parentNode.href el = $.el 'img', { src } imgGif = init: -> g.callbacks.push (root) -> return unless thumb = $ 'img[md5]', root src = thumb.parentNode.href if /gif$/.test src thumb.src = src imgExpand = init: -> g.callbacks.push imgExpand.node imgExpand.dialog() $.bind window, 'resize', imgExpand.resize imgExpand.style = $.addStyle '' imgExpand.resize() node: (root) -> return unless thumb = $ 'img[md5]', root a = thumb.parentNode $.bind a, 'click', imgExpand.cb.toggle if imgExpand.on and root.className isnt 'inline' then imgExpand.toggle a cb: toggle: (e) -> return if e.shiftKey or e.altKey or e.ctrlKey or e.button isnt 0 e.preventDefault() imgExpand.toggle @ all: (e) -> thumbs = $$ 'img[md5]' imgExpand.on = @checked if imgExpand.on #expand for thumb in thumbs unless thumb.hidden #thumbnail hidden, image already expanded imgExpand.expand thumb else #contract for thumb in thumbs if thumb.hidden #thumbnail hidden - unhide it imgExpand.contract thumb typeChange: (e) -> switch @value when 'full' klass = '' when 'fit width' klass = 'fitwidth' when 'fit height' klass = 'fitheight' when 'fit screen' klass = 'fitwidth fitheight' d.body.className = klass toggle: (a) -> thumb = a.firstChild if thumb.hidden imgExpand.contract thumb else imgExpand.expand thumb contract: (thumb) -> $.show thumb $.rm thumb.nextSibling expand: (thumb) -> $.hide thumb a = thumb.parentNode img = $.el 'img', src: a.href unless a.parentNode.className is 'op' filesize = $ 'span.filesize', a.parentNode [_, max] = filesize.textContent.match /(\d+)x/ img.style.maxWidth = "-moz-calc(#{max}px)" $.append a, img dialog: -> controls = $.el 'div', id: 'imgControls' innerHTML: " " imageType = $.getValue 'imageType', 'full' for option in $$ 'option', controls if option.textContent is imageType option.selected = true break select = $ 'select', controls imgExpand.cb.typeChange.call select $.bind select, 'change', $.cb.value $.bind select, 'change', imgExpand.cb.typeChange $.bind $('input', controls), 'click', imgExpand.cb.all delform = $ 'form[name=delform]' $.prepend delform, controls resize: (e) -> imgExpand.style.innerHTML = "body.fitheight img[md5] + img { max-height: #{d.body.clientHeight}px }" firstRun = init: -> style = $.addStyle " #navtopr, #navbotr { position: relative; } #navtopr::before { content: ''; height: 50px; width: 100px; background: red; -webkit-transform: rotate(-45deg); -moz-transform: rotate(-45deg); -o-transform: rotate(-45deg); -webkit-transform-origin: 100% 200%; -moz-transform-origin: 100% 200%; -o-transform-origin: 100% 200%; position: absolute; top: 100%; right: 100%; z-index: 999; } #navtopr::after { content: ''; border-top: 100px solid red; border-left: 100px solid transparent; position: absolute; top: 100%; right: 100%; z-index: 999; } #navbotr::before { content: ''; height: 50px; width: 100px; background: red; -webkit-transform: rotate(45deg); -moz-transform: rotate(45deg); -o-transform: rotate(45deg); -webkit-transform-origin: 100% -100%; -moz-transform-origin: 100% -100%; -o-transform-origin: 100% -100%; position: absolute; bottom: 100%; right: 100%; z-index: 999; } #navbotr::after { content: ''; border-bottom: 100px solid red; border-left: 100px solid transparent; position: absolute; bottom: 100%; right: 100%; z-index: 999; } " style.className = 'firstrun' dialog = $.el 'div', id: 'overlay' className: 'firstrun' innerHTML: "

Click the 4chan X buttons for options; they are at the top and bottom of the page.

Updater options are in the updater dialog in replies at the bottom-right corner of the window.

If you don't see the buttons, try disabling your userstyles.

" $.append d.body, dialog $.bind window, 'click', firstRun.close close: -> $.setValue 'firstrun', true $.rm $ 'style.firstrun', d.head $.rm $ '#overlay' $.unbind window, 'click', firstRun.close main = init: -> pathname = location.pathname.substring(1).split('/') [g.BOARD, temp] = pathname if temp is 'res' g.REPLY = temp g.THREAD_ID = pathname[2] else g.PAGENUM = parseInt(temp) or 0 if location.hostname is 'sys.4chan.org' qr.sys() return if conf['404 Redirect'] and d.title is '4chan - 404' and /^\d+$/.test g.THREAD_ID redirect() return if not $ '#navtopr' return Favicon.halo = if /ws/.test Favicon.default then Favicon.haloSFW else Favicon.haloNSFW $('link[rel="shortcut icon"]', d.head).setAttribute 'type', 'image/x-icon' g.hiddenReplies = $.getValue "hiddenReplies/#{g.BOARD}/", {} tzOffset = (new Date()).getTimezoneOffset() / 60 # GMT -8 is given as +480; would GMT +8 be -480 ? g.chanOffset = 5 - tzOffset# 4chan = EST = GMT -5 if $.isDST() then g.chanOffset -= 1 lastChecked = $.getValue 'lastChecked', 0 now = Date.now() DAY = 1000 * 60 * 60 * 24 if lastChecked < now - 1*DAY cutoff = now - 7*DAY hiddenThreads = $.getValue "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] $.setValue "hiddenThreads/#{g.BOARD}/", hiddenThreads $.setValue "hiddenReplies/#{g.BOARD}/", g.hiddenReplies $.setValue 'lastChecked', now $.addStyle main.css if (form = $ 'form[name=post]') and canPost = !!$ '#recaptcha_response_field' Recaptcha.init() $.bind form, 'submit', qr.submit #major features threading.init() # scroll to bottom if post isn't found # thumbnail generation takes time if g.REPLY and (id = location.hash[1..]) and /\d/.test(id[0]) and !$.id(id) scrollTo 0, d.body.scrollHeight if conf['Auto Noko'] $('.postarea form').action += '?noko' if conf['Cooldown'] cooldown.init() if conf['Image Expansion'] imgExpand.init() if conf['Image Auto-Gif'] imgGif.init() if conf['Time Formatting'] Time.init() if conf['Sauce'] sauce.init() if conf['Reveal Spoilers'] revealSpoilers.init() if conf['Anonymize'] anonymize.init() if conf['Image Hover'] imgHover.init() if conf['Reply Hiding'] replyHiding.init() if canPost and conf['Quick Reply'] qr.init() if conf['Report Button'] reportButton.init() if conf['Quote Backlinks'] quoteBacklink.init() if conf['Quote Inline'] quoteInline.init() if conf['Quote Preview'] quotePreview.init() if conf['Indicate OP quote'] quoteOP.init() if conf['Thread Watcher'] watcher.init() if conf['Keybinds'] keybinds.init() if g.REPLY if conf['Thread Updater'] updater.init() if conf['Image Preloading'] imgPreloading.init() if conf['Quick Reply'] and conf['Persistent QR'] qr.persist() if conf['Post in Title'] titlePost.init() if conf['Thread Stats'] threadStats.init() if conf['Unread Count'] unread.init() if conf['Reply Navigation'] nav.init() if conf['Auto Watch'] and conf['Thread Watcher'] and /watch/.test(location.search) and $('img.favicon').src is Favicon.empty watcher.watch null, g.THREAD_ID else #not reply if conf['Index Navigation'] nav.init() if conf['Thread Hiding'] threadHiding.init() if conf['Thread Expansion'] expandThread.init() if conf['Comment Expansion'] expandComment.init() if conf['Auto Watch'] $('.postarea form').action += '?watch' for op in $$ 'div.op' for callback in g.callbacks callback op for table in $$ 'a + table' for callback in g.callbacks callback table $.bind $('form[name=delform]'), 'DOMNodeInserted', nodeInserted options.init() unless $.getValue 'firstrun' firstRun.init() css: ' /* dialog styling */ div.dialog { border: 1px solid; } div.dialog > div.move { cursor: move; } label, a, .favicon, #qr img { cursor: pointer; } .new { background: lime; } .error { color: red; } #error { cursor: default; } #error[href] { cursor: pointer; } td.replyhider { vertical-align: top; } div.thread.stub > *:not(.block) { display: none; } .filesize + br + a { float: left; pointer-events: none; } img[md5], img[md5] + img { pointer-events: all; } body.fitwidth img[md5] + img { max-width: 100%; width: -moz-calc(100%); /* hack so only firefox sees this */ } #qp, #iHover { position: fixed; } #iHover { max-height: 100%; } #navlinks { font-size: 16px; position: fixed; top: 25px; right: 5px; } #overlay { display: table; position: fixed; top: 0; left: 0; height: 100%; width: 100%; background: rgba(0,0,0,.5); } #options { display: table-cell; vertical-align: middle; } #options .dialog { margin: auto; padding: 5px; width: 500px; } #credits { text-align: right; } #options ul { list-style: none; padding: 0; } #floaty { float: left; } #content > * { height: 450px; overflow: auto; } #content textarea { margin: 0; width: 100%; } #qr { position: fixed; } #qr > div.move { text-align: right; } #qr input[name=name] { float: left; } #qr_form { clear: left; } #qr_form, #qr #com_submit, #qr input[name=upfile] { margin: 0; } #qr textarea { width: 100%; height: 120px; } #qr.auto:not(:hover) > form { height: 0; overflow: hidden; } /* http://stackoverflow.com/questions/2610497/change-an-inputs-html5-placeholder-color-with-css */ #qr input::-webkit-input-placeholder { color: grey; } #qr input:-moz-placeholder { color: grey; } /* qr reCAPTCHA */ #qr img { border: 1px solid #AAA; } #updater { position: fixed; text-align: right; } #updater input[type=text] { width: 50px; } #updater:not(:hover) { border: none; background: transparent; } #updater:not(:hover) > div:not(.move) { display: none; } #stats { border: none; position: fixed; } #watcher { position: absolute; } #watcher > div { padding-right: 5px; padding-left: 5px; } #watcher > div.move { text-decoration: underline; padding-top: 5px; } #watcher > div:last-child { padding-bottom: 5px; } #qp { border: 1px solid; padding-bottom: 5px; } #qp input { display: none; } .qphl { outline: 2px solid rgba(216, 94, 49, .7); } .inlined { opacity: .5; } /* Firefox bug: hidden tables are not hidden */ [hidden] { display: none; } #files > input { display: block; } ' main.init()