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()