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'
$.on d, '4chanXInitFinished', @initReady
Post::callbacks.push
name: 'Quick Reply'
cb: @node
initReady: ->
QR.postingIsEnable = !!$.id 'postForm'
return unless QR.postingIsEnable
link = $.el 'a',
className: 'qr-shortcut'
textContent: 'Quick Reply'
href: 'javascript:;'
$.on link, 'click', ->
$.event 'CloseMenu'
QR.open()
QR.resetThreadSelector()
QR.nodes.com.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, 'ThreadUpdate', ->
QR.abort() if g.DEAD
QR.persist() if Conf['Persistent QR']
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.nodes
QR.nodes.el.hidden = false
QR.unhide()
return
try
QR.dialog()
catch err
delete QR.nodes
Main.handleErrors
message: 'Quick Reply dialog creation crashed.'
error: err
close: ->
QR.nodes.el.hidden = true
QR.abort()
d.activeElement.blur()
$.rmClass QR.nodes.el, 'dump'
for i in QR.replies
QR.replies[0].rm()
QR.cooldown.auto = false
QR.status()
QR.resetFileInput()
if !Conf['Remember Spoiler'] and QR.nodes.spoiler.checked
QR.nodes.spoiler.click()
QR.cleanNotifications()
hide: ->
d.activeElement.blur()
$.addClass QR.nodes.el, 'autohide'
QR.nodes.autohide.checked = true
unhide: ->
$.rmClass QR.nodes.el, 'autohide'
QR.nodes.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.
QR.captcha.nodes.input.focus()
alert el.textContent if d.hidden
QR.notifications.push new Notification 'warning', el
notifications: []
cleanNotifications: ->
for notification in QR.notifications
notification.close()
QR.notifications = []
status: (data={}) ->
return unless QR.nodes
if g.DEAD
value = 404
disabled = true
QR.cooldown.auto = false
value = data.progress or QR.cooldown.seconds or value
{status} = QR.nodes
status.value =
if QR.cooldown.auto
if value then "Auto #{value}" else 'Auto'
else
value or 'Submit'
status.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 "cooldown.#{board}", {}
QR.cooldown.start()
$.sync "cooldown.#{board}", 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 "cooldown.#{g.BOARD}", QR.cooldown.cooldowns
QR.cooldown.start()
unset: (id) ->
delete QR.cooldown.cooldowns[id]
$.set "cooldown.#{g.BOARD}", 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'
g.VIEW is 'thread'
else
QR.nodes.thread.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()
return unless QR.postingIsEnable
text = ""
sel = d.getSelection()
selectionRoot = $.x 'ancestor::div[contains(@class,"postContainer")][1]', sel.anchorNode
post = Get.postFromNode @
{OP} = 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 OP and (!QR.nodes or QR.nodes.el.hidden)
# Don't quote the OP unless the QR was already opened once.
""
else
">>#{post}\n#{text}"
QR.open()
ta = QR.nodes.com
if QR.threadSelector and !ta.value and g.BOARD.ID isnt 'f'
QR.threadSelector.value = OP.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
$.event 'input', null, ta
characterCount: ->
counter = QR.nodes.charCount
count = QR.nodes.com.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 e.dataTransfer.files
$.addClass QR.nodes.el, 'dump'
fileInput: (files) ->
unless files instanceof FileList
files = @files
{length} = files
return unless length
max = QR.nodes.fileInput.max
QR.cleanNotifications()
# Set or change current reply's file.
if 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.name}: File 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.nodes.el, 'dump'
QR.resetFileInput() # reset input
resetFileInput: ->
QR.nodes.fileInput.value = null
resetThreadSelector: ->
if g.BOARD.ID is 'f'
if g.VIEW is 'index'
QR.nodes.flashTag.value = '9999'
else if g.VIEW is 'thread'
QR.nodes.thread.value = g.THREAD
else
QR.nodes.thread.value = 'new'
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: '×'
@nodes =
el: el
rm: el.firstChild
label: $ 'label', el
spoiler: $ 'input', el
span: el.lastChild
@nodes.spoiler.checked = @spoiler
$.on el, 'click', @select.bind @
$.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
$.before QR.nodes.addReply, el
for event in ['dragStart', 'dragEnter', 'dragLeave', 'dragOver', 'dragEnd', 'drop']
$.on el, event.toLowerCase(), @[event]
QR.replies.push @
setFile: (@file) ->
@nodes.el.title = "#{file.name} (#{$.bytesToString file.size})"
@nodes.label.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
@nodes.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
applyBlob = (blob) =>
@url = URL.createObjectURL blob
@nodes.el.style.backgroundImage = "url(#{@url})"
URL.revokeObjectURL fileURL
if c.toBlob
c.toBlob applyBlob
return
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
applyBlob new Blob [ui8a], type: 'image/png'
img.src = fileURL
rmFile: ->
QR.resetFileInput()
delete @file
@nodes.el.title = null
@nodes.el.style.backgroundImage = null
@nodes.label.hidden = true if QR.spoiler
return unless window.URL
URL.revokeObjectURL @url
select: ->
if QR.selected
QR.selected.nodes.el.id = null
QR.selected.forceSave()
QR.selected = @
@nodes.el.id = 'selected'
# Scroll the list to center the focused reply.
rectEl = @nodes.el.getBoundingClientRect()
rectList = @nodes.el.parentNode.getBoundingClientRect()
@nodes.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']
QR.nodes[name].value = @[name]
QR.characterCount()
QR.nodes.spoiler.checked = @spoiler
save: (input) ->
{value} = input
@[input.name] = value
return if input.nodeName isnt 'TEXTAREA'
@nodes.span.textContent = value
QR.characterCount()
# Disable auto-posting if you're typing in the first reply
# during the last 5 seconds of the cooldown.
if QR.cooldown.auto and @ is QR.replies[0] and 0 < QR.cooldown.seconds <= 5
QR.cooldown.auto = false
forceSave: ->
# Do this in case people use extensions
# that do not trigger the `input` event.
for name in ['name', 'email', 'sub', 'com']
@save QR.nodes[name]
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 @nodes.el
index = QR.replies.indexOf @
if QR.replies.length is 1
new QR.reply().select()
else if @ is QR.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: ->
return if d.cookie.indexOf('pass_enabled=1') >= 0
return unless @isEnabled = !!$.id 'captchaFormPart'
$.asap (-> $.id 'recaptcha_challenge_field_holder'), @ready.bind @
ready: ->
imgContainer = $.el 'div',
className: 'captchaimg'
title: 'Reload'
innerHTML: ''
input = $.el 'input',
className: 'captcha-input field'
title: 'Verification'
autocomplete: 'off'
@nodes =
challenge: $.id 'recaptcha_challenge_field_holder'
img: imgContainer.firstChild
input: input
if MutationObserver = window.MutationObserver or window.WebKitMutationObserver or window.OMutationObserver
observer = new MutationObserver @load.bind @
observer.observe @nodes.challenge,
childList: true
else
$.on @nodes.challenge, 'DOMNodeInserted', @load.bind @
$.on imgContainer, 'click', @reload.bind @
$.on input, 'keydown', @keydown.bind @
$.sync 'captchas', @sync.bind @
@sync $.get 'captchas', []
# start with an uncached captcha
@reload()
$.addClass QR.nodes.el, 'has-captcha'
$.after QR.nodes.com.parentNode, [imgContainer, input]
sync: (@captchas) ->
@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: ->
# -1 minute to give upload some time.
@timeout = Date.now() + $.unsafeWindow.RecaptchaState.timeout * $.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
$.unsafeWindow.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()
dialog: ->
dialog = UI.dialog 'qr', 'top:0;right:0;', """