QR = init: -> return if g.VIEW is 'catalog' or !Conf['Quick Reply'] Misc.clearThreads "yourPosts.#{g.BOARD}" @syncYourPosts() if Conf['Hide Original Post Form'] $.addClass doc, 'hide-original-post-form' link = $.el 'a', className: 'qr-shortcut' textContent: 'Quick Reply' href: 'javascript:;' $.on link, 'click', -> $.event 'CloseMenu' QR.open() if g.BOARD.ID is 'f' if g.VIEW is 'index' QR.threadSelector.value = '9999' else if g.VIEW is 'thread' QR.threadSelector.value = g.THREAD else QR.threadSelector.value = 'new' $('textarea', QR.el).focus() $.event 'AddMenuEntry', type: 'header' el: link order: 10 $.on d, 'dragover', QR.dragOver $.on d, 'drop', QR.dropFile $.on d, 'dragstart dragend', QR.drag $.on d, '4chanXInitFinished', QR.persist if Conf['Persistent QR'] $.on d, 'ThreadUpdate', -> if g.DEAD QR.abort() else QR.status() Post::callbacks.push name: 'Quick Reply' cb: @node node: -> $.on $('a[title="Quote this post"]', @nodes.info), 'click', QR.quote persist: -> QR.open() QR.hide() if Conf['Auto Hide QR'] open: -> if QR.el QR.el.hidden = false QR.unhide() return try QR.dialog() catch err delete QR.el Main.handleErrors message: 'Quick Reply dialog creation crashed.' error: err close: -> QR.el.hidden = true QR.abort() d.activeElement.blur() $.rmClass QR.el, 'dump' for i in QR.replies QR.replies[0].rm() QR.cooldown.auto = false QR.status() QR.resetFileInput() if not Conf['Remember Spoiler'] and (spoiler = $.id 'spoiler').checked spoiler.click() QR.cleanNotifications() hide: -> d.activeElement.blur() $.addClass QR.el, 'autohide' $.id('autohide').checked = true unhide: -> $.rmClass QR.el, 'autohide' $.id('autohide').checked = false toggleHide: -> if @checked QR.hide() else QR.unhide() syncYourPosts: (yourPosts) -> if yourPosts QR.yourPosts = yourPosts return QR.yourPosts = $.get "yourPosts.#{g.BOARD}", threads: {} $.sync "yourPosts.#{g.BOARD}", QR.syncYourPosts error: (err) -> QR.open() if typeof err is 'string' el = $.tn err else el = err el.removeAttribute 'style' if QR.captcha.isEnabled and /captcha|verification/i.test el.textContent # Focus the captcha input on captcha error. $('[autocomplete]', QR.el).focus() alert el.textContent if d.hidden QR.lastNotifications.push new Notification 'warning', el lastNotifications: [] cleanNotifications: -> for notification in QR.lastNotifications notification.close() QR.lastNotification = [] status: (data={}) -> return unless QR.el if g.DEAD value = 404 disabled = true QR.cooldown.auto = false value = data.progress or QR.cooldown.seconds or value {input} = QR.status input.value = if QR.cooldown.auto if value then "Auto #{value}" else 'Auto' else value or 'Submit' input.disabled = disabled or false cooldown: init: -> board = g.BOARD.ID QR.cooldown.types = thread: switch board when 'q' then 86400 when 'b', 'soc', 'r9k' then 600 else 300 sage: if board is 'q' then 600 else 60 file: if board is 'q' then 300 else 30 post: if board is 'q' then 60 else 30 QR.cooldown.cooldowns = $.get "#{board}.cooldown", {} QR.cooldown.start() $.sync "#{board}.cooldown", QR.cooldown.sync start: -> return if QR.cooldown.isCounting QR.cooldown.isCounting = true QR.cooldown.count() sync: (cooldowns) -> # Add each cooldowns, don't overwrite everything in case we # still need to prune one in the current tab to auto-post. for id of cooldowns QR.cooldown.cooldowns[id] = cooldowns[id] QR.cooldown.start() set: (data) -> start = Date.now() if data.delay cooldown = delay: data.delay else isSage = /sage/i.test data.post.email hasFile = !!data.post.file isReply = data.isReply type = unless isReply 'thread' else if isSage 'sage' else if hasFile 'file' else 'post' cooldown = isReply: isReply isSage: isSage hasFile: hasFile timeout: start + QR.cooldown.types[type] * $.SECOND QR.cooldown.cooldowns[start] = cooldown $.set "#{g.BOARD}.cooldown", QR.cooldown.cooldowns QR.cooldown.start() unset: (id) -> delete QR.cooldown.cooldowns[id] $.set "#{g.BOARD}.cooldown", QR.cooldown.cooldowns count: -> if Object.keys(QR.cooldown.cooldowns).length setTimeout QR.cooldown.count, 1000 else $.delete "#{g.BOARD}.cooldown" delete QR.cooldown.isCounting delete QR.cooldown.seconds QR.status() return isReply = if g.BOARD.ID is 'f' and g.VIEW is 'thread' true else QR.threadSelector.value isnt 'new' if isReply post = QR.replies[0] isSage = /sage/i.test post.email hasFile = !!post.file now = Date.now() seconds = null {types, cooldowns} = QR.cooldown for start, cooldown of cooldowns if 'delay' of cooldown if cooldown.delay seconds = Math.max seconds, cooldown.delay-- else seconds = Math.max seconds, 0 QR.cooldown.unset start continue if isReply is cooldown.isReply # Only cooldowns relevant to this post can set the seconds value. # Unset outdated cooldowns that can no longer impact us. type = unless isReply 'thread' else if isSage and cooldown.isSage 'sage' else if hasFile and cooldown.hasFile 'file' else 'post' elapsed = Math.floor (now - start) / 1000 if elapsed >= 0 # clock changed since then? seconds = Math.max seconds, types[type] - elapsed unless start <= now <= cooldown.timeout QR.cooldown.unset start # Update the status when we change posting type. # Don't get stuck at some random number. # Don't interfere with progress status updates. update = seconds isnt null or !!QR.cooldown.seconds QR.cooldown.seconds = seconds QR.status() if update QR.submit() if seconds is 0 and QR.cooldown.auto quote: (e) -> e?.preventDefault() text = "" sel = d.getSelection() selectionRoot = $.x 'ancestor::div[contains(@class,"postContainer")][1]', sel.anchorNode post = Get.postFromNode @ thread = g.BOARD.posts[Get.contextFromLink(@).thread] if (s = sel.toString().trim()) and post.nodes.root is selectionRoot # XXX Opera doesn't retain `\n`s? s = s.replace /\n/g, '\n>' text += ">#{s}\n" text = if !text and post is thread and (!QR.el or QR.el.hidden) # Don't quote the OP unless the QR was already opened once. "" else ">>#{post}\n#{text}" QR.open() ta = $ 'textarea', QR.el if QR.threadSelector and !ta.value and g.BOARD.ID isnt 'f' QR.threadSelector.value = thread.ID caretPos = ta.selectionStart # Replace selection for text. ta.value = ta.value[...caretPos] + text + ta.value[ta.selectionEnd..] # Move the caret to the end of the new quote. range = caretPos + text.length ta.setSelectionRange range, range ta.focus() # Fire the 'input' event ta.dispatchEvent new Event 'input' characterCount: -> counter = QR.charaCounter count = @textLength counter.textContent = count counter.hidden = count < 1000 (if count > 1500 then $.addClass else $.rmClass) counter, 'warning' drag: (e) -> # Let it drag anything from the page. toggle = if e.type is 'dragstart' then $.off else $.on toggle d, 'dragover', QR.dragOver toggle d, 'drop', QR.dropFile dragOver: (e) -> e.preventDefault() e.dataTransfer.dropEffect = 'copy' # cursor feedback dropFile: (e) -> # Let it only handle files from the desktop. return unless e.dataTransfer.files.length e.preventDefault() QR.open() QR.fileInput.call e.dataTransfer $.addClass QR.el, 'dump' fileInput: -> QR.cleanNotifications() # Set or change current reply's file. if @files.length is 1 file = @files[0] if file.size > @max QR.error "File too large (file: #{$.bytesToString file.size}, max: #{$.bytesToString @max})." QR.resetFileInput() else unless file.type in QR.mimeTypes 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 (file: #{$.bytesToString file.size}, max: #{$.bytesToString @max})." else unless file.type in QR.mimeTypes QR.error "#{file.name}: Unsupported file type." unless QR.replies[QR.replies.length - 1].file # set last reply's file QR.replies[QR.replies.length - 1].setFile file else new QR.reply().setFile file $.addClass QR.el, 'dump' QR.resetFileInput() # reset input resetFileInput: -> $('[type=file]', QR.el).value = null replies: [] reply: class constructor: -> # set values, or null, to avoid 'undefined' values in inputs prev = QR.replies[QR.replies.length-1] persona = $.get 'QR.persona', {} @name = if prev then prev.name else persona.name or null @email = if prev and !/^sage$/.test prev.email then prev.email else persona.email or null @sub = if prev and Conf['Remember Subject'] then prev.sub else if Conf['Remember Subject'] then persona.sub else null @spoiler = if prev and Conf['Remember Spoiler'] then prev.spoiler else false @com = null @el = $.el 'a', className: 'qrpreview' draggable: true href: 'javascript:;' innerHTML: '×' $('input', @el).checked = @spoiler $.on @el, 'click', => @select() $.on $('.remove', @el), 'click', (e) => e.stopPropagation() @rm() $.on $('label', @el), 'click', (e) => e.stopPropagation() $.on $('input', @el), 'change', (e) => @spoiler = e.target.checked $.id('spoiler').checked = @spoiler if @el.id is 'selected' $.before $('#addReply', QR.el), @el $.on @el, 'dragstart', @dragStart $.on @el, 'dragenter', @dragEnter $.on @el, 'dragleave', @dragLeave $.on @el, 'dragover', @dragOver $.on @el, 'dragend', @dragEnd $.on @el, 'drop', @drop QR.replies.push @ setFile: (@file) -> @el.title = "#{file.name} (#{$.bytesToString file.size})" $('label', @el).hidden = false if QR.spoiler unless /^image/.test file.type @el.style.backgroundImage = null return # XXX Opera does not support blob URL return unless window.URL URL.revokeObjectURL @url # Create a redimensioned thumbnail. fileURL = URL.createObjectURL file img = $.el 'img' $.on img, 'load', => # Generate thumbnails only if they're really big. # Resized pictures through canvases look like ass, # so we generate thumbnails `s` times bigger then expected # to avoid crappy resized quality. s = 90*3 if img.height < s or img.width < s @url = fileURL @el.style.backgroundImage = "url(#{@url})" return if img.height <= img.width img.width = s / img.height * img.width img.height = s else img.height = s / img.width * img.height img.width = s c = $.el 'canvas' c.height = img.height c.width = img.width c.getContext('2d').drawImage img, 0, 0, img.width, img.height # Support for toBlob fucking when? data = atob c.toDataURL().split(',')[1] # DataUrl to Binary code from Aeosynth's 4chan X repo l = data.length ui8a = new Uint8Array l for i in [0...l] ui8a[i] = data.charCodeAt i @url = URL.createObjectURL new Blob [ui8a], type: 'image/png' @el.style.backgroundImage = "url(#{@url})" URL.revokeObjectURL fileURL img.src = fileURL rmFile: -> QR.resetFileInput() delete @file @el.title = null @el.style.backgroundImage = null $('label', @el).hidden = true if QR.spoiler return unless window.URL URL.revokeObjectURL @url select: -> if QR.selected QR.selected.el.id = null QR.selected.forceSave() 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 name in ['name', 'email', 'sub', 'com'] $("[name=#{name}]", QR.el).value = @[name] QR.characterCount.call $ 'textarea', QR.el $('#spoiler', QR.el).checked = @spoiler forceSave: -> # Do this in case people use extensions # that do not trigger the `input` event. for name in ['name', 'email', 'sub', 'com'] @[name] = $("[name=#{name}]", QR.el).value return dragStart: -> $.addClass @, 'drag' dragEnter: -> $.addClass @, 'over' dragLeave: -> $.rmClass @, 'over' dragOver: (e) -> e.preventDefault() e.dataTransfer.dropEffect = 'move' drop: -> el = $ '.drag', @parentNode index = (el) -> Array::slice.call(el.parentNode.children).indexOf el oldIndex = index el newIndex = index @ if oldIndex < newIndex $.after @, el else $.before @, el reply = QR.replies.splice(oldIndex, 1)[0] QR.replies.splice newIndex, 0, reply dragEnd: -> $.rmClass @, 'drag' if el = $ '.over', @parentNode $.rmClass el, 'over' rm: -> QR.resetFileInput() $.rm @el index = QR.replies.indexOf @ if QR.replies.length is 1 new QR.reply().select() else if @el.id is 'selected' (QR.replies[index-1] or QR.replies[index+1]).select() QR.replies.splice index, 1 return unless window.URL URL.revokeObjectURL @url captcha: init: -> # XXX CoffeeScrit's indexOf doesn't wanna work here ??? # return if 'pass_enabled=1' in d.cookie return if d.cookie.indexOf('pass_enabled=1') >= 0 return unless @isEnabled = !!$.id 'captchaFormPart' if $.id 'recaptcha_challenge_field_holder' @ready() else @onready = => @ready() $.on $.id('recaptcha_widget_div'), 'DOMNodeInserted', @onready ready: -> if @challenge = $.id 'recaptcha_challenge_field_holder' $.off $.id('recaptcha_widget_div'), 'DOMNodeInserted', @onready delete @onready else return $.addClass QR.el, 'captcha' $.after $('.textarea', QR.el), $.el 'div', className: 'captchaimg' title: 'Reload' innerHTML: '' $.after $('.captchaimg', QR.el), $.el 'div', className: 'captchainput' innerHTML: '' @img = $ '.captchaimg > img', QR.el @input = $ '.captchainput > input', QR.el $.on @img.parentNode, 'click', @reload $.on @input, 'keydown', @keydown $.on @challenge, 'DOMNodeInserted', => @load() $.sync 'captchas', (arr) => @count arr.length @count $.get('captchas', []).length # start with an uncached captcha @reload() save: -> return unless response = @input.value captchas = $.get 'captchas', [] # Remove old captchas. while (captcha = captchas[0]) and captcha.time < Date.now() captchas.shift() captchas.push challenge: @challenge.firstChild.value response: response time: @timeout $.set 'captchas', captchas @count captchas.length @reload() load: -> # -1 minute to give upload some time. @timeout = Date.now() + $.unsafeWindow.RecaptchaState.timeout * $.SECOND - $.MINUTE challenge = @challenge.firstChild.value @img.alt = challenge @img.src = "//www.google.com/recaptcha/api/image?c=#{challenge}" @input.value = null count: (count) -> @input.placeholder = switch count when 0 'Verification (Shift + Enter to cache)' when 1 'Verification (1 cached captcha)' else "Verification (#{count} cached captchas)" @input.alt = count # For XTRM RICE. reload: (focus) -> # the 't' argument prevents the input from being focused $.unsafeWindow.Recaptcha.reload 't' # Focus if we meant to. QR.captcha.input.focus() if focus keydown: (e) -> c = QR.captcha if e.keyCode is 8 and not c.input.value c.reload() else if e.keyCode is 13 and e.shiftKey c.save() else return e.preventDefault() dialog: -> QR.el = UI.dialog 'qr', 'top:0;right:0;', """
Quick Reply ×
""" # Allow only this board's supported files. mimeTypes = $('ul.rules').firstElementChild.textContent.trim().match(/: (.+)/)[1].toLowerCase().replace /\w+/g, (type) -> switch type when 'jpg' 'image/jpeg' when 'pdf' 'application/pdf' when 'swf' 'application/x-shockwave-flash' else "image/#{type}" QR.mimeTypes = mimeTypes.split ', ' # Add empty mimeType to avoid errors with URLs selected in Window's file dialog. QR.mimeTypes.push '' fileInput = $ 'input[type=file]', QR.el fileInput.max = $('input[name=MAX_FILE_SIZE]').value fileInput.accept = mimeTypes if $.engine isnt 'presto' # Opera's accept attribute is fucked up QR.spoiler = !!$ 'input[name=spoiler]' spoiler = $ '#spoilerLabel', QR.el spoiler.hidden = !QR.spoiler QR.charaCounter = $ '#charCount', QR.el ta = $ 'textarea', QR.el span = $('.move > span', QR.el) # Make a list of visible threads. if g.BOARD.ID is 'f' if g.VIEW is 'index' QR.threadSelector = $('select[name=filetag]').cloneNode true else QR.threadSelector = $.el 'select', title: 'Create a new thread / Reply to a thread' threads = '' for key, thread of g.BOARD.threads threads += "" QR.threadSelector.innerHTML = threads if g.VIEW is 'thread' QR.threadSelector.value = g.THREAD if QR.threadSelector $.prepend span, QR.threadSelector $.on span, 'mousedown', (e) -> e.stopPropagation() $.on $('#autohide', QR.el), 'change', QR.toggleHide $.on $('.close', QR.el), 'click', QR.close $.on $('#dump', QR.el), 'click', -> QR.el.classList.toggle 'dump' $.on $('#addReply', QR.el), 'click', -> new QR.reply().select() $.on $('form', QR.el), 'submit', QR.submit $.on ta, 'input', -> QR.selected.el.lastChild.textContent = @value $.on ta, 'input', QR.characterCount $.on fileInput, 'change', QR.fileInput $.on fileInput, 'click', (e) -> if e.shiftKey then QR.selected.rmFile() or e.preventDefault() $.on spoiler.firstChild, 'change', -> $('input', QR.selected.el).click() new QR.reply().select() # save selected reply's data for name in ['name', 'email', 'sub', 'com'] # The input event replaces keyup, change and paste events. $.on $("[name=#{name}]", QR.el), 'input', -> QR.selected[@name] = @value # Disable auto-posting if you're typing in the first reply # during the last 5 seconds of the cooldown. if QR.cooldown.auto and QR.selected is QR.replies[0] and 0 < QR.cooldown.seconds <= 5 QR.cooldown.auto = false QR.status.input = $ 'input[type=submit]', QR.el QR.status() QR.cooldown.init() QR.captcha.init() $.add d.body, QR.el # Create a custom event when the QR dialog is first initialized. # Use it to extend the QR's functionalities, or for XTRM RICE. $.event 'QRDialogCreation', null, QR.el submit: (e) -> e?.preventDefault() if QR.cooldown.seconds QR.cooldown.auto = !QR.cooldown.auto QR.status() return if QR.ajax QR.abort() return reply = QR.replies[0] reply.forceSave() if reply is QR.selected if g.BOARD.ID is 'f' and g.VIEW is 'index' filetag = QR.threadSelector.value threadID = 'new' else threadID = QR.threadSelector.value # prevent errors if threadID is 'new' threadID = null if g.BOARD.ID in ['vg', 'q'] and !reply.sub err = 'New threads require a subject.' else unless reply.file or textOnly = !!$ 'input[name=textonly]', $.id 'postForm' err = 'No file selected.' else if g.BOARD.ID is 'f' and filetag is '9999' err = 'Invalid tag specified.' else if g.BOARD.threads[threadID].isSticky err = 'You can\'t reply to this thread anymore.' else unless reply.com or reply.file err = 'No file selected.' if QR.captcha.isEnabled and !err # get oldest valid captcha captchas = $.get 'captchas', [] # remove old captchas while (captcha = captchas[0]) and captcha.time < Date.now() captchas.shift() if captcha = captchas.shift() challenge = captcha.challenge response = captcha.response else challenge = QR.captcha.img.alt if response = QR.captcha.input.value then QR.captcha.reload() $.set 'captchas', captchas QR.captcha.count captchas.length unless response err = 'No valid captcha.' else response = response.trim() # one-word-captcha: # If there's only one word, duplicate it. response = "#{response} #{response}" unless /\s/.test response if err # stop auto-posting QR.cooldown.auto = false QR.status() QR.error err return QR.cleanNotifications() # Enable auto-posting if we have stuff to post, disable it otherwise. QR.cooldown.auto = QR.replies.length > 1 if Conf['Auto Hide QR'] and not QR.cooldown.auto QR.hide() if not QR.cooldown.auto and $.x 'ancestor::div[@id="qr"]', d.activeElement # Unfocus the focused element if it is one within the QR and we're not auto-posting. d.activeElement.blur() # Starting to upload might take some time. # Provide some feedback that we're starting to submit. QR.status progress: '...' post = resto: threadID name: reply.name email: reply.email sub: reply.sub com: reply.com upfile: reply.file filetag: filetag spoiler: reply.spoiler textonly: textOnly mode: 'regist' pwd: if m = d.cookie.match(/4chan_pass=([^;]+)/) then decodeURIComponent m[1] else $.id('postPassword').value recaptcha_challenge_field: challenge recaptcha_response_field: response callbacks = onload: -> QR.response @ onerror: -> delete QR.ajax # Connection error, or # CORS disabled error on www.4chan.org/banned QR.cooldown.auto = false QR.status() QR.error $.el 'a', href: '//www.4chan.org/banned', target: '_blank', textContent: 'Network error.' opts = form: $.formData post upCallbacks: onload: -> # Upload done, waiting for response. QR.status progress: '...' onprogress: (e) -> # Uploading... QR.status progress: "#{Math.round e.loaded / e.total * 100}%" QR.ajax = $.ajax $.id('postForm').parentNode.action, callbacks, opts response: (req) -> delete QR.ajax tmpDoc = d.implementation.createHTMLDocument '' tmpDoc.documentElement.innerHTML = req.response if ban = $ '.banType', tmpDoc # banned/warning board = $('.board', tmpDoc).innerHTML err = $.el 'span', innerHTML: if ban.textContent.toLowerCase() is 'banned' "You are banned on #{board}! ;_;
" + "Click here to see the reason." else "You were issued a warning on #{board} as #{$('.nameBlock', tmpDoc).innerHTML}.
" + "Reason: #{$('.reason', tmpDoc).innerHTML}" else if err = tmpDoc.getElementById 'errmsg' # error! $('a', err)?.target = '_blank' # duplicate image link else if tmpDoc.title isnt 'Post successful!' err = 'Connection error with sys.4chan.org.' else if req.status isnt 200 err = "Error #{req.statusText} (#{req.status})" if err if /captcha|verification/i.test(err.textContent) or err is 'Connection error with sys.4chan.org.' # Remove the obnoxious 4chan Pass ad. if /mistyped/i.test err.textContent err = 'Error: You seem to have mistyped the CAPTCHA.' # Enable auto-post if we have some cached captchas. QR.cooldown.auto = if QR.captcha.isEnabled !!$.get('captchas', []).length else if err is 'Connection error with sys.4chan.org.' true else # Something must've gone terribly wrong if you get captcha errors without captchas. # Don't auto-post indefinitely in that case. false # Too many frequent mistyped captchas will auto-ban you! # On connection error, the post most likely didn't go through. QR.cooldown.set delay: 2 else # stop auto-posting QR.cooldown.auto = false QR.status() QR.error err return h1 = $ 'h1', tmpDoc QR.cleanNotifications() QR.lastNotifications.push new Notification 'success', h1.textContent, 5 reply = QR.replies[0] persona = $.get 'QR.persona', {} persona = name: reply.name email: if /^sage$/.test reply.email then persona.email else reply.email sub: if Conf['Remember Subject'] then reply.sub else null $.set 'QR.persona', persona [_, threadID, postID] = h1.nextSibling.textContent.match /thread:(\d+),no:(\d+)/ postID = +postID threadID = +threadID or postID (QR.yourPosts.threads[threadID] or= []).push postID $.set "yourPosts.#{g.BOARD}", QR.yourPosts # Post/upload confirmed as successful. $.event 'QRPostSuccessful', { board: g.BOARD threadID postID }, QR.el QR.cooldown.set post: reply isReply: !!threadID # Enable auto-posting if we have stuff to post, disable it otherwise. QR.cooldown.auto = QR.replies.length > 1 if threadID is postID # new thread $.open "//boards.4chan.org/#{g.BOARD}/res/#{threadID}" else if g.VIEW is 'index' and !QR.cooldown.auto # posting from the index $.open "//boards.4chan.org/#{g.BOARD}/res/#{threadID}#p#{postID}" if Conf['Persistent QR'] or QR.cooldown.auto reply.rm() else QR.close() QR.status() QR.resetFileInput() abort: -> if QR.ajax QR.ajax.abort() delete QR.ajax QR.error 'QR upload aborted.' QR.status()