diff --git a/Gruntfile.coffee b/Gruntfile.coffee index 940a57d49..c97e8e155 100644 --- a/Gruntfile.coffee +++ b/Gruntfile.coffee @@ -29,6 +29,7 @@ module.exports = (grunt) -> # Features --> 'src/Filtering/**/*' 'src/Quotelinks/**/*' + 'src/Posting/QR.coffee' 'src/Posting/**/*' 'src/Images/**/*' 'src/Linkification/**/*' diff --git a/src/Posting/QR.captcha.coffee b/src/Posting/QR.captcha.coffee new file mode 100644 index 000000000..c19e319ed --- /dev/null +++ b/src/Posting/QR.captcha.coffee @@ -0,0 +1,111 @@ +QR.captcha = + init: -> + return if d.cookie.indexOf('pass_enabled=1') >= 0 + return unless @isEnabled = !!$.id 'captchaFormPart' + $.asap (-> $.id 'recaptcha_challenge_field_holder'), @ready.bind @ + ready: -> + setLifetime = (e) => @lifetime = e.detail + $.on window, 'captcha:timeout', setLifetime + $.globalEval 'window.dispatchEvent(new CustomEvent("captcha:timeout", {detail: RecaptchaState.timeout}))' + $.off window, 'captcha:timeout', setLifetime + + imgContainer = $.el 'div', + className: 'captcha-img' + title: 'Reload reCAPTCHA' + innerHTML: '' + input = $.el 'input', + className: 'captcha-input field' + title: 'Verification' + autocomplete: 'off' + spellcheck: false + @nodes = + challenge: $.id 'recaptcha_challenge_field_holder' + img: imgContainer.firstChild + input: input + + new MutationObserver(@load.bind @).observe @nodes.challenge, + childList: true + + $.on imgContainer, 'click', @reload.bind @ + $.on input, 'keydown', @keydown.bind @ + $.get 'captchas', [], ({captchas}) => + @sync captchas + $.sync 'captchas', @sync + # start with an uncached captcha + @reload() + + <% if (type === 'userscript') { %> + # XXX Firefox lacks focusin/focusout support. + $.on input, 'blur', QR.focusout + $.on input, 'focus', QR.focusin + <% } %> + + $.addClass QR.nodes.el, 'has-captcha' + $.after QR.nodes.com.parentNode, [imgContainer, input] + sync: (captchas) -> + QR.captcha.captchas = captchas + QR.captcha.count() + getOne: -> + @clear() + if captcha = @captchas.shift() + {challenge, response} = captcha + @count() + $.set 'captchas', @captchas + else + challenge = @nodes.img.alt + if response = @nodes.input.value then @reload() + if response + response = response.trim() + # one-word-captcha: + # If there's only one word, duplicate it. + response = "#{response} #{response}" unless /\s/.test response + {challenge, response} + save: -> + return unless response = @nodes.input.value.trim() + @captchas.push + challenge: @nodes.img.alt + response: response + timeout: @timeout + @count() + @reload() + $.set 'captchas', @captchas + clear: -> + now = Date.now() + for captcha, i in @captchas + break if captcha.timeout > now + return unless i + @captchas = @captchas[i..] + @count() + $.set 'captchas', @captchas + load: -> + return unless @nodes.challenge.firstChild + # -1 minute to give upload some time. + @timeout = Date.now() + @lifetime * $.SECOND - $.MINUTE + challenge = @nodes.challenge.firstChild.value + @nodes.img.alt = challenge + @nodes.img.src = "//www.google.com/recaptcha/api/image?c=#{challenge}" + @nodes.input.value = null + @clear() + count: -> + count = @captchas.length + @nodes.input.placeholder = switch count + when 0 + 'Verification (Shift + Enter to cache)' + when 1 + 'Verification (1 cached captcha)' + else + "Verification (#{count} cached captchas)" + @nodes.input.alt = count # For XTRM RICE. + reload: (focus) -> + # the 't' argument prevents the input from being focused + $.globalEval 'Recaptcha.reload("t")' + # Focus if we meant to. + @nodes.input.focus() if focus + keydown: (e) -> + if e.keyCode is 8 and not @nodes.input.value + @reload() + else if e.keyCode is 13 and e.shiftKey + @save() + else + return + e.preventDefault() diff --git a/src/Posting/QR.coffee b/src/Posting/QR.coffee index 698d1bf8a..24209cf08 100644 --- a/src/Posting/QR.coffee +++ b/src/Posting/QR.coffee @@ -3,6 +3,7 @@ QR = return if !Conf['Quick Reply'] @db = new DataBoard 'yourPosts' + @posts = [] if Conf['Hide Original Post Form'] $.addClass doc, 'hide-original-post-form' @@ -160,177 +161,6 @@ QR = value status.disabled = disabled or false - persona: - pwd: '' - always: {} - init: -> - QR.persona.getPassword() - $.get 'QR.personas', Conf['QR.personas'], ({'QR.personas': personas}) -> - types = - name: [] - email: [] - sub: [] - for item in personas.split '\n' - QR.persona.parseItem item.trim(), types - for type, arr of types - QR.persona.loadPersonas type, arr - return - parseItem: (item, types) -> - return if item[0] is '#' - return unless match = item.match /(name|email|subject|password):"(.*)"/i - [match, type, val] = match - - # Don't mix up item settings with val. - item = item.replace match, '' - - boards = item.match(/boards:([^;]+)/i)?[1].toLowerCase() or 'global' - if boards isnt 'global' and not (g.BOARD.ID in boards.split ',') - return - - if type is 'password' - QR.persona.pwd = val - return - - type = 'sub' if type is 'subject' - - if /always/i.test item - QR.persona.always[type] = val - - unless val in types[type] - types[type].push val - loadPersonas: (type, arr) -> - list = $ "#list-#{type}", QR.nodes.el - for val in arr when val - $.add list, $.el 'option', - textContent: val - return - getPassword: -> - unless QR.persona.pwd - QR.persona.pwd = if m = d.cookie.match /4chan_pass=([^;]+)/ - decodeURIComponent m[1] - else if input = $.id 'postPassword' - input.value - else - # If we're in a closed thread, #postPassword isn't available. - # And since #delPassword.value is only filled on window.onload - # we'd rather use #postPassword when we can. - $.id('delPassword').value - return QR.persona.pwd - get: (cb) -> - $.get 'QR.persona', {}, ({'QR.persona': persona}) -> - cb persona - set: (post) -> - $.get 'QR.persona', {}, ({'QR.persona': persona}) -> - persona = - name: post.name - email: if /^sage$/.test post.email then persona.email else post.email - sub: if Conf['Remember Subject'] then post.sub else undefined - flag: post.flag - $.set 'QR.persona', persona - - cooldown: - init: -> - return unless Conf['Cooldown'] - setTimers = (e) => QR.cooldown.types = e.detail - $.on window, 'cooldown:timers', setTimers - $.globalEval 'window.dispatchEvent(new CustomEvent("cooldown:timers", {detail: cooldowns}))' - $.off window, 'cooldown:timers', setTimers - for type of QR.cooldown.types - QR.cooldown.types[type] = +QR.cooldown.types[type] - QR.cooldown.upSpd = 0 - QR.cooldown.upSpdAccuracy = .5 - key = "cooldown.#{g.BOARD}" - $.get key, {}, (item) -> - QR.cooldown.cooldowns = item[key] - QR.cooldown.start() - $.sync key, QR.cooldown.sync - start: -> - return unless Conf['Cooldown'] - 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) -> - return unless Conf['Cooldown'] - {req, post, isReply, threadID, delay} = data - start = if req then req.uploadEndTime else Date.now() - if delay - cooldown = {delay} - else - if post.file - upSpd = post.file.size / ((start - req.uploadStartTime) / $.SECOND) - QR.cooldown.upSpdAccuracy = ((upSpd > QR.cooldown.upSpd * .9) + QR.cooldown.upSpdAccuracy) / 2 - QR.cooldown.upSpd = upSpd - cooldown = {isReply, threadID} - QR.cooldown.cooldowns[start] = cooldown - $.set "cooldown.#{g.BOARD}", QR.cooldown.cooldowns - QR.cooldown.start() - unset: (id) -> - delete QR.cooldown.cooldowns[id] - if Object.keys(QR.cooldown.cooldowns).length - $.set "cooldown.#{g.BOARD}", QR.cooldown.cooldowns - else - $.delete "cooldown.#{g.BOARD}" - count: -> - unless Object.keys(QR.cooldown.cooldowns).length - $.delete "#{g.BOARD}.cooldown" - delete QR.cooldown.isCounting - delete QR.cooldown.seconds - QR.status() - return - - clearTimeout QR.cooldown.timeout - QR.cooldown.timeout = setTimeout QR.cooldown.count, $.SECOND - - now = Date.now() - post = QR.posts[0] - isReply = post.thread isnt 'new' - hasFile = !!post.file - seconds = null - {types, cooldowns, upSpd, upSpdAccuracy} = 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 variable: - # reply cooldown with a reply, thread cooldown with a thread - elapsed = Math.floor (now - start) / $.SECOND - continue if elapsed < 0 # clock changed since then? - type = unless isReply - 'thread' - else if hasFile - 'image' - else - 'reply' - maxTimer = Math.max types[type] or 0, types[type + '_intra'] or 0 - unless start <= now <= start + maxTimer * $.SECOND - QR.cooldown.unset start - type += '_intra' if isReply and +post.thread is cooldown.threadID - seconds = Math.max seconds, types[type] - elapsed - - if seconds and Conf['Cooldown Prediction'] and hasFile and upSpd - seconds -= Math.floor post.file.size / upSpd * upSpdAccuracy - seconds = Math.max seconds, 0 - # 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 and !QR.req - quote: (e) -> e?.preventDefault() return unless QR.postingIsEnabled @@ -427,369 +257,6 @@ QR = openFileInput: -> QR.nodes.fileInput.click() - posts: [] - post: class - constructor: (select) -> - el = $.el 'a', - className: 'qr-preview' - draggable: true - href: 'javascript:;' - innerHTML: '' - - @nodes = - el: el - rm: el.firstChild - label: $ 'label', el - spoiler: $ 'input', el - span: el.lastChild - - <% if (type === 'userscript') { %> - # XXX Firefox lacks focusin/focusout support. - for elm in $$ '*', el - $.on elm, 'blur', QR.focusout - $.on elm, 'focus', QR.focusin - <% } %> - $.on el, 'click', @select - $.on @nodes.rm, 'click', (e) => e.stopPropagation(); @rm() - $.on @nodes.label, 'click', (e) => e.stopPropagation() - $.on @nodes.spoiler, 'change', (e) => - @spoiler = e.target.checked - QR.nodes.spoiler.checked = @spoiler if @ is QR.selected - $.add QR.nodes.dumpList, el - - for event in ['dragStart', 'dragEnter', 'dragLeave', 'dragOver', 'dragEnd', 'drop'] - $.on el, event.toLowerCase(), @[event] - - @thread = if g.VIEW is 'thread' - g.THREADID - else - 'new' - - prev = QR.posts[QR.posts.length - 1] - QR.posts.push @ - @nodes.spoiler.checked = @spoiler = if prev and Conf['Remember Spoiler'] - prev.spoiler - else - false - QR.persona.get (persona) => - @name = if 'name' of QR.persona.always - QR.persona.always.name - else if prev - prev.name - else - persona.name - - @email = if 'email' of QR.persona.always - QR.persona.always.email - else if prev and !/^sage$/.test prev.email - prev.email - else - persona.email - - @sub = if 'sub' of QR.persona.always - QR.persona.always.sub - else if Conf['Remember Subject'] - if prev then prev.sub else persona.sub - else - '' - - if QR.nodes.flag - @flag = if prev - prev.flag - else - persona.flag - @load() if QR.selected is @ # load persona - @select() if select - @unlock() - rm: -> - @delete() - index = QR.posts.indexOf @ - if QR.posts.length is 1 - new QR.post true - else if @ is QR.selected - (QR.posts[index-1] or QR.posts[index+1]).select() - QR.posts.splice index, 1 - QR.status() - delete: -> - $.rm @nodes.el - URL.revokeObjectURL @URL - lock: (lock=true) -> - @isLocked = lock - return unless @ is QR.selected - for name in ['thread', 'name', 'email', 'sub', 'com', 'fileButton', 'filename', 'spoiler', 'flag'] - continue unless node = QR.nodes[name] - node.disabled = lock - @nodes.rm.style.visibility = if lock then 'hidden' else '' - (if lock then $.off else $.on) QR.nodes.filename.previousElementSibling, 'click', QR.openFileInput - @nodes.spoiler.disabled = lock - @nodes.el.draggable = !lock - unlock: -> - @lock false - select: => - if QR.selected - QR.selected.nodes.el.id = null - QR.selected.forceSave() - QR.selected = @ - @lock @isLocked - @nodes.el.id = 'selected' - # Scroll the list to center the focused post. - rectEl = @nodes.el.getBoundingClientRect() - rectList = @nodes.el.parentNode.getBoundingClientRect() - @nodes.el.parentNode.scrollLeft += rectEl.left + rectEl.width/2 - rectList.left - rectList.width/2 - @load() - $.event 'QRPostSelection', @ - load: -> - # Load this post's values. - for name in ['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag'] - continue unless node = QR.nodes[name] - node.value = @[name] or node.dataset.default or null - @showFileData() - QR.characterCount() - save: (input) -> - if input.type is 'checkbox' - @spoiler = input.checked - return - {name} = input.dataset - @[name] = input.value or input.dataset.default or null - switch name - when 'thread' - QR.status() - when 'com' - @nodes.span.textContent = @com - QR.characterCount() - # Disable auto-posting if you're typing in the first post - # during the last 5 seconds of the cooldown. - if QR.cooldown.auto and @ is QR.posts[0] and 0 < QR.cooldown.seconds <= 5 - QR.cooldown.auto = false - when 'filename' - return unless @file - @file.newName = @filename.replace /[/\\]/g, '-' - unless /\.(jpe?g|png|gif|pdf|swf)$/i.test @filename - # 4chan will truncate the filename if it has no extension, - # but it will always replace the extension by the correct one, - # so we suffix it with '.jpg' when needed. - @file.newName += '.jpg' - @updateFilename() - forceSave: -> - return unless @ is QR.selected - # Do this in case people use extensions - # that do not trigger the `input` event. - for name in ['thread', 'name', 'email', 'sub', 'com', 'filename', 'spoiler', 'flag'] - continue unless node = QR.nodes[name] - @save node - return - setFile: (@file) -> - @filename = file.name - @filesize = $.bytesToString file.size - @nodes.label.hidden = false if QR.spoiler - URL.revokeObjectURL @URL - @showFileData() if @ is QR.selected - unless /^image/.test file.type - @nodes.el.style.backgroundImage = null - return - @setThumbnail() - setThumbnail: -> - # Create a redimensioned thumbnail. - img = $.el 'img' - - img.onload = => - # 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 * 2 * window.devicePixelRatio - s *= 3 if @file.type is 'image/gif' # let them animate - {height, width} = img - if height < s or width < s - @URL = fileURL - @nodes.el.style.backgroundImage = "url(#{@URL})" - return - if height <= width - width = s / height * width - height = s - else - height = s / width * height - width = s - cv = $.el 'canvas' - cv.height = img.height = height - cv.width = img.width = width - cv.getContext('2d').drawImage img, 0, 0, width, height - URL.revokeObjectURL fileURL - cv.toBlob (blob) => - @URL = URL.createObjectURL blob - @nodes.el.style.backgroundImage = "url(#{@URL})" - - fileURL = URL.createObjectURL @file - img.src = fileURL - rmFile: -> - return if @isLocked - delete @file - delete @filename - delete @filesize - @nodes.el.title = null - @nodes.el.style.backgroundImage = null - @nodes.label.hidden = true if QR.spoiler - @showFileData() - URL.revokeObjectURL @URL - updateFilename: -> - long = "#{@filename} (#{@filesize})" - @nodes.el.title = long - return unless @ is QR.selected - QR.nodes.filename.title = long - showFileData: -> - if @file - @updateFilename() - QR.nodes.filename.value = @filename - QR.nodes.filesize.textContent = @filesize - QR.nodes.spoiler.checked = @spoiler - $.addClass QR.nodes.fileSubmit, 'has-file' - else - $.rmClass QR.nodes.fileSubmit, 'has-file' - pasteText: (file) -> - reader = new FileReader() - reader.onload = (e) => - text = e.target.result - if @com - @com += "\n#{text}" - else - @com = text - if QR.selected is @ - QR.nodes.com.value = @com - @nodes.span.textContent = @com - reader.readAsText file - dragStart: (e) -> - e.dataTransfer.setDragImage @, e.layerX, e.layerY - $.addClass @, 'drag' - dragEnd: -> $.rmClass @, 'drag' - dragEnter: -> $.addClass @, 'over' - dragLeave: -> $.rmClass @, 'over' - dragOver: (e) -> - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - drop: -> - $.rmClass @, 'over' - return unless @draggable - el = $ '.drag', @parentNode - index = (el) -> [el.parentNode.children...].indexOf el - oldIndex = index el - newIndex = index @ - (if oldIndex < newIndex then $.after else $.before) @, el - post = QR.posts.splice(oldIndex, 1)[0] - QR.posts.splice newIndex, 0, post - QR.status() - - captcha: - init: -> - return if d.cookie.indexOf('pass_enabled=1') >= 0 - return unless @isEnabled = !!$.id 'captchaFormPart' - $.asap (-> $.id 'recaptcha_challenge_field_holder'), @ready.bind @ - ready: -> - setLifetime = (e) => @lifetime = e.detail - $.on window, 'captcha:timeout', setLifetime - $.globalEval 'window.dispatchEvent(new CustomEvent("captcha:timeout", {detail: RecaptchaState.timeout}))' - $.off window, 'captcha:timeout', setLifetime - - imgContainer = $.el 'div', - className: 'captcha-img' - title: 'Reload reCAPTCHA' - innerHTML: '' - input = $.el 'input', - className: 'captcha-input field' - title: 'Verification' - autocomplete: 'off' - spellcheck: false - @nodes = - challenge: $.id 'recaptcha_challenge_field_holder' - img: imgContainer.firstChild - input: input - - new MutationObserver(@load.bind @).observe @nodes.challenge, - childList: true - - $.on imgContainer, 'click', @reload.bind @ - $.on input, 'keydown', @keydown.bind @ - $.get 'captchas', [], ({captchas}) => - @sync captchas - $.sync 'captchas', @sync - # start with an uncached captcha - @reload() - - <% if (type === 'userscript') { %> - # XXX Firefox lacks focusin/focusout support. - $.on input, 'blur', QR.focusout - $.on input, 'focus', QR.focusin - <% } %> - - $.addClass QR.nodes.el, 'has-captcha' - $.after QR.nodes.com.parentNode, [imgContainer, input] - sync: (captchas) -> - QR.captcha.captchas = captchas - QR.captcha.count() - getOne: -> - @clear() - if captcha = @captchas.shift() - {challenge, response} = captcha - @count() - $.set 'captchas', @captchas - else - challenge = @nodes.img.alt - if response = @nodes.input.value then @reload() - if response - response = response.trim() - # one-word-captcha: - # If there's only one word, duplicate it. - response = "#{response} #{response}" unless /\s/.test response - {challenge, response} - save: -> - return unless response = @nodes.input.value.trim() - @captchas.push - challenge: @nodes.img.alt - response: response - timeout: @timeout - @count() - @reload() - $.set 'captchas', @captchas - clear: -> - now = Date.now() - for captcha, i in @captchas - break if captcha.timeout > now - return unless i - @captchas = @captchas[i..] - @count() - $.set 'captchas', @captchas - load: -> - return unless @nodes.challenge.firstChild - # -1 minute to give upload some time. - @timeout = Date.now() + @lifetime * $.SECOND - $.MINUTE - challenge = @nodes.challenge.firstChild.value - @nodes.img.alt = challenge - @nodes.img.src = "//www.google.com/recaptcha/api/image?c=#{challenge}" - @nodes.input.value = null - @clear() - count: -> - count = @captchas.length - @nodes.input.placeholder = switch count - when 0 - 'Verification (Shift + Enter to cache)' - when 1 - 'Verification (1 cached captcha)' - else - "Verification (#{count} cached captchas)" - @nodes.input.alt = count # For XTRM RICE. - reload: (focus) -> - # the 't' argument prevents the input from being focused - $.globalEval 'Recaptcha.reload("t")' - # Focus if we meant to. - @nodes.input.focus() if focus - keydown: (e) -> - if e.keyCode is 8 and not @nodes.input.value - @reload() - else if e.keyCode is 13 and e.shiftKey - @save() - else - return - e.preventDefault() - generatePostableThreadsList: -> return unless QR.nodes list = QR.nodes.thread diff --git a/src/Posting/QR.cooldown.coffee b/src/Posting/QR.cooldown.coffee new file mode 100644 index 000000000..ce0fa7960 --- /dev/null +++ b/src/Posting/QR.cooldown.coffee @@ -0,0 +1,102 @@ +QR.cooldown = + init: -> + return unless Conf['Cooldown'] + setTimers = (e) => QR.cooldown.types = e.detail + $.on window, 'cooldown:timers', setTimers + $.globalEval 'window.dispatchEvent(new CustomEvent("cooldown:timers", {detail: cooldowns}))' + $.off window, 'cooldown:timers', setTimers + for type of QR.cooldown.types + QR.cooldown.types[type] = +QR.cooldown.types[type] + QR.cooldown.upSpd = 0 + QR.cooldown.upSpdAccuracy = .5 + key = "cooldown.#{g.BOARD}" + $.get key, {}, (item) -> + QR.cooldown.cooldowns = item[key] + QR.cooldown.start() + $.sync key, QR.cooldown.sync + start: -> + return unless Conf['Cooldown'] + 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) -> + return unless Conf['Cooldown'] + {req, post, isReply, threadID, delay} = data + start = if req then req.uploadEndTime else Date.now() + if delay + cooldown = {delay} + else + if post.file + upSpd = post.file.size / ((start - req.uploadStartTime) / $.SECOND) + QR.cooldown.upSpdAccuracy = ((upSpd > QR.cooldown.upSpd * .9) + QR.cooldown.upSpdAccuracy) / 2 + QR.cooldown.upSpd = upSpd + cooldown = {isReply, threadID} + QR.cooldown.cooldowns[start] = cooldown + $.set "cooldown.#{g.BOARD}", QR.cooldown.cooldowns + QR.cooldown.start() + unset: (id) -> + delete QR.cooldown.cooldowns[id] + if Object.keys(QR.cooldown.cooldowns).length + $.set "cooldown.#{g.BOARD}", QR.cooldown.cooldowns + else + $.delete "cooldown.#{g.BOARD}" + count: -> + unless Object.keys(QR.cooldown.cooldowns).length + $.delete "#{g.BOARD}.cooldown" + delete QR.cooldown.isCounting + delete QR.cooldown.seconds + QR.status() + return + + clearTimeout QR.cooldown.timeout + QR.cooldown.timeout = setTimeout QR.cooldown.count, $.SECOND + + now = Date.now() + post = QR.posts[0] + isReply = post.thread isnt 'new' + hasFile = !!post.file + seconds = null + {types, cooldowns, upSpd, upSpdAccuracy} = 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 variable: + # reply cooldown with a reply, thread cooldown with a thread + elapsed = Math.floor (now - start) / $.SECOND + continue if elapsed < 0 # clock changed since then? + type = unless isReply + 'thread' + else if hasFile + 'image' + else + 'reply' + maxTimer = Math.max types[type] or 0, types[type + '_intra'] or 0 + unless start <= now <= start + maxTimer * $.SECOND + QR.cooldown.unset start + type += '_intra' if isReply and +post.thread is cooldown.threadID + seconds = Math.max seconds, types[type] - elapsed + + if seconds and Conf['Cooldown Prediction'] and hasFile and upSpd + seconds -= Math.floor post.file.size / upSpd * upSpdAccuracy + seconds = Math.max seconds, 0 + # 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 and !QR.req diff --git a/src/Posting/QR.persona.coffee b/src/Posting/QR.persona.coffee new file mode 100644 index 000000000..5a47e3490 --- /dev/null +++ b/src/Posting/QR.persona.coffee @@ -0,0 +1,67 @@ +QR.persona = + pwd: '' + always: {} + init: -> + QR.persona.getPassword() + $.get 'QR.personas', Conf['QR.personas'], ({'QR.personas': personas}) -> + types = + name: [] + email: [] + sub: [] + for item in personas.split '\n' + QR.persona.parseItem item.trim(), types + for type, arr of types + QR.persona.loadPersonas type, arr + return + parseItem: (item, types) -> + return if item[0] is '#' + return unless match = item.match /(name|email|subject|password):"(.*)"/i + [match, type, val] = match + + # Don't mix up item settings with val. + item = item.replace match, '' + + boards = item.match(/boards:([^;]+)/i)?[1].toLowerCase() or 'global' + if boards isnt 'global' and not (g.BOARD.ID in boards.split ',') + return + + if type is 'password' + QR.persona.pwd = val + return + + type = 'sub' if type is 'subject' + + if /always/i.test item + QR.persona.always[type] = val + + unless val in types[type] + types[type].push val + loadPersonas: (type, arr) -> + list = $ "#list-#{type}", QR.nodes.el + for val in arr when val + $.add list, $.el 'option', + textContent: val + return + getPassword: -> + unless QR.persona.pwd + QR.persona.pwd = if m = d.cookie.match /4chan_pass=([^;]+)/ + decodeURIComponent m[1] + else if input = $.id 'postPassword' + input.value + else + # If we're in a closed thread, #postPassword isn't available. + # And since #delPassword.value is only filled on window.onload + # we'd rather use #postPassword when we can. + $.id('delPassword').value + return QR.persona.pwd + get: (cb) -> + $.get 'QR.persona', {}, ({'QR.persona': persona}) -> + cb persona + set: (post) -> + $.get 'QR.persona', {}, ({'QR.persona': persona}) -> + persona = + name: post.name + email: if /^sage$/.test post.email then persona.email else post.email + sub: if Conf['Remember Subject'] then post.sub else undefined + flag: post.flag + $.set 'QR.persona', persona diff --git a/src/Posting/QR.post.coffee b/src/Posting/QR.post.coffee new file mode 100644 index 000000000..06194bbe3 --- /dev/null +++ b/src/Posting/QR.post.coffee @@ -0,0 +1,249 @@ +QR.post = class + constructor: (select) -> + el = $.el 'a', + className: 'qr-preview' + draggable: true + href: 'javascript:;' + innerHTML: '' + + @nodes = + el: el + rm: el.firstChild + label: $ 'label', el + spoiler: $ 'input', el + span: el.lastChild + + <% if (type === 'userscript') { %> + # XXX Firefox lacks focusin/focusout support. + for elm in $$ '*', el + $.on elm, 'blur', QR.focusout + $.on elm, 'focus', QR.focusin + <% } %> + $.on el, 'click', @select + $.on @nodes.rm, 'click', (e) => e.stopPropagation(); @rm() + $.on @nodes.label, 'click', (e) => e.stopPropagation() + $.on @nodes.spoiler, 'change', (e) => + @spoiler = e.target.checked + QR.nodes.spoiler.checked = @spoiler if @ is QR.selected + $.add QR.nodes.dumpList, el + + for event in ['dragStart', 'dragEnter', 'dragLeave', 'dragOver', 'dragEnd', 'drop'] + $.on el, event.toLowerCase(), @[event] + + @thread = if g.VIEW is 'thread' + g.THREADID + else + 'new' + + prev = QR.posts[QR.posts.length - 1] + QR.posts.push @ + @nodes.spoiler.checked = @spoiler = if prev and Conf['Remember Spoiler'] + prev.spoiler + else + false + QR.persona.get (persona) => + @name = if 'name' of QR.persona.always + QR.persona.always.name + else if prev + prev.name + else + persona.name + + @email = if 'email' of QR.persona.always + QR.persona.always.email + else if prev and !/^sage$/.test prev.email + prev.email + else + persona.email + + @sub = if 'sub' of QR.persona.always + QR.persona.always.sub + else if Conf['Remember Subject'] + if prev then prev.sub else persona.sub + else + '' + + if QR.nodes.flag + @flag = if prev + prev.flag + else + persona.flag + @load() if QR.selected is @ # load persona + @select() if select + @unlock() + rm: -> + @delete() + index = QR.posts.indexOf @ + if QR.posts.length is 1 + new QR.post true + else if @ is QR.selected + (QR.posts[index-1] or QR.posts[index+1]).select() + QR.posts.splice index, 1 + QR.status() + delete: -> + $.rm @nodes.el + URL.revokeObjectURL @URL + lock: (lock=true) -> + @isLocked = lock + return unless @ is QR.selected + for name in ['thread', 'name', 'email', 'sub', 'com', 'fileButton', 'filename', 'spoiler', 'flag'] + continue unless node = QR.nodes[name] + node.disabled = lock + @nodes.rm.style.visibility = if lock then 'hidden' else '' + (if lock then $.off else $.on) QR.nodes.filename.previousElementSibling, 'click', QR.openFileInput + @nodes.spoiler.disabled = lock + @nodes.el.draggable = !lock + unlock: -> + @lock false + select: => + if QR.selected + QR.selected.nodes.el.id = null + QR.selected.forceSave() + QR.selected = @ + @lock @isLocked + @nodes.el.id = 'selected' + # Scroll the list to center the focused post. + rectEl = @nodes.el.getBoundingClientRect() + rectList = @nodes.el.parentNode.getBoundingClientRect() + @nodes.el.parentNode.scrollLeft += rectEl.left + rectEl.width/2 - rectList.left - rectList.width/2 + @load() + $.event 'QRPostSelection', @ + load: -> + # Load this post's values. + for name in ['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag'] + continue unless node = QR.nodes[name] + node.value = @[name] or node.dataset.default or null + @showFileData() + QR.characterCount() + save: (input) -> + if input.type is 'checkbox' + @spoiler = input.checked + return + {name} = input.dataset + @[name] = input.value or input.dataset.default or null + switch name + when 'thread' + QR.status() + when 'com' + @nodes.span.textContent = @com + QR.characterCount() + # Disable auto-posting if you're typing in the first post + # during the last 5 seconds of the cooldown. + if QR.cooldown.auto and @ is QR.posts[0] and 0 < QR.cooldown.seconds <= 5 + QR.cooldown.auto = false + when 'filename' + return unless @file + @file.newName = @filename.replace /[/\\]/g, '-' + unless /\.(jpe?g|png|gif|pdf|swf)$/i.test @filename + # 4chan will truncate the filename if it has no extension, + # but it will always replace the extension by the correct one, + # so we suffix it with '.jpg' when needed. + @file.newName += '.jpg' + @updateFilename() + forceSave: -> + return unless @ is QR.selected + # Do this in case people use extensions + # that do not trigger the `input` event. + for name in ['thread', 'name', 'email', 'sub', 'com', 'filename', 'spoiler', 'flag'] + continue unless node = QR.nodes[name] + @save node + return + setFile: (@file) -> + @filename = file.name + @filesize = $.bytesToString file.size + @nodes.label.hidden = false if QR.spoiler + URL.revokeObjectURL @URL + @showFileData() if @ is QR.selected + unless /^image/.test file.type + @nodes.el.style.backgroundImage = null + return + @setThumbnail() + setThumbnail: -> + # Create a redimensioned thumbnail. + img = $.el 'img' + + img.onload = => + # 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 * 2 * window.devicePixelRatio + s *= 3 if @file.type is 'image/gif' # let them animate + {height, width} = img + if height < s or width < s + @URL = fileURL + @nodes.el.style.backgroundImage = "url(#{@URL})" + return + if height <= width + width = s / height * width + height = s + else + height = s / width * height + width = s + cv = $.el 'canvas' + cv.height = img.height = height + cv.width = img.width = width + cv.getContext('2d').drawImage img, 0, 0, width, height + URL.revokeObjectURL fileURL + cv.toBlob (blob) => + @URL = URL.createObjectURL blob + @nodes.el.style.backgroundImage = "url(#{@URL})" + + fileURL = URL.createObjectURL @file + img.src = fileURL + rmFile: -> + return if @isLocked + delete @file + delete @filename + delete @filesize + @nodes.el.title = null + @nodes.el.style.backgroundImage = null + @nodes.label.hidden = true if QR.spoiler + @showFileData() + URL.revokeObjectURL @URL + updateFilename: -> + long = "#{@filename} (#{@filesize})" + @nodes.el.title = long + return unless @ is QR.selected + QR.nodes.filename.title = long + showFileData: -> + if @file + @updateFilename() + QR.nodes.filename.value = @filename + QR.nodes.filesize.textContent = @filesize + QR.nodes.spoiler.checked = @spoiler + $.addClass QR.nodes.fileSubmit, 'has-file' + else + $.rmClass QR.nodes.fileSubmit, 'has-file' + pasteText: (file) -> + reader = new FileReader() + reader.onload = (e) => + text = e.target.result + if @com + @com += "\n#{text}" + else + @com = text + if QR.selected is @ + QR.nodes.com.value = @com + @nodes.span.textContent = @com + reader.readAsText file + dragStart: (e) -> + e.dataTransfer.setDragImage @, e.layerX, e.layerY + $.addClass @, 'drag' + dragEnd: -> $.rmClass @, 'drag' + dragEnter: -> $.addClass @, 'over' + dragLeave: -> $.rmClass @, 'over' + dragOver: (e) -> + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + drop: -> + $.rmClass @, 'over' + return unless @draggable + el = $ '.drag', @parentNode + index = (el) -> [el.parentNode.children...].indexOf el + oldIndex = index el + newIndex = index @ + (if oldIndex < newIndex then $.after else $.before) @, el + post = QR.posts.splice(oldIndex, 1)[0] + QR.posts.splice newIndex, 0, post + QR.status()