From 22d40d2315c0c50d51c89ac1d2913824123feaad Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Tue, 12 Feb 2013 23:59:29 +0100 Subject: [PATCH] The QR code is just too damn long, move it into its own file. --- 4chan_x.user.js | 1866 +++++++++++++++++++++---------------------- grunt.js | 1 + src/features.coffee | 797 ------------------ src/qr.coffee | 796 ++++++++++++++++++ 4 files changed, 1730 insertions(+), 1730 deletions(-) create mode 100644 src/qr.coffee diff --git a/4chan_x.user.js b/4chan_x.user.js index be96638a6..9dd93a271 100644 --- a/4chan_x.user.js +++ b/4chan_x.user.js @@ -1821,939 +1821,6 @@ } }; - QR = { - init: function() { - var link; - if (g.VIEW === 'catalog' || !Conf['Quick Reply']) { - return; - } - 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', function() { - Header.menu.close(); - QR.open(); - if (g.BOARD.ID === 'f') { - if (g.VIEW === 'index') { - QR.threadSelector.value = '9999'; - } - } else if (g.VIEW === 'thread') { - QR.threadSelector.value = g.THREAD; - } else { - QR.threadSelector.value = 'new'; - } - return $('textarea', QR.el).focus(); - }); - $.event('AddMenuEntry', { - type: 'header', - el: link - }); - $.on(d, 'dragover', QR.dragOver); - $.on(d, 'drop', QR.dropFile); - $.on(d, 'dragstart dragend', QR.drag); - $.on(d, '4chanXInitFinished', function() { - if (!Conf['Persistent QR']) { - return; - } - QR.open(); - if (Conf['Auto Hide QR']) { - return QR.hide(); - } - }); - return Post.prototype.callbacks.push({ - name: 'Quick Reply', - cb: this.node - }); - }, - node: function() { - return $.on($('a[title="Quote this post"]', this.nodes.info), 'click', QR.quote); - }, - open: function() { - if (QR.el) { - QR.el.hidden = false; - QR.unhide(); - return; - } - try { - return QR.dialog(); - } catch (err) { - delete QR.el; - return Main.handleErrors({ - message: 'Quick Reply dialog creation crashed.', - error: err - }); - } - }, - close: function() { - var i, spoiler, _i, _len, _ref; - QR.el.hidden = true; - QR.abort(); - d.activeElement.blur(); - $.rmClass(QR.el, 'dump'); - _ref = QR.replies; - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - i = _ref[_i]; - QR.replies[0].rm(); - } - QR.cooldown.auto = false; - QR.status(); - QR.resetFileInput(); - if (!Conf['Remember Spoiler'] && (spoiler = $.id('spoiler')).checked) { - spoiler.click(); - } - return QR.cleanNotification(); - }, - hide: function() { - d.activeElement.blur(); - $.addClass(QR.el, 'autohide'); - return $.id('autohide').checked = true; - }, - unhide: function() { - $.rmClass(QR.el, 'autohide'); - return $.id('autohide').checked = false; - }, - toggleHide: function() { - if (this.checked) { - return QR.hide(); - } else { - return QR.unhide(); - } - }, - error: function(err) { - var el; - QR.open(); - if (typeof err === 'string') { - el = $.tn(err); - } else { - el = err; - el.removeAttribute('style'); - } - if (QR.captcha.isEnabled && /captcha|verification/i.test(el.textContent)) { - $('[autocomplete]', QR.el).focus(); - } - if (d.hidden) { - alert(el.textContent); - } - return QR.lastNotification = new Notification('warning', el); - }, - cleanNotification: function() { - var _ref; - if ((_ref = QR.lastNotification) != null) { - _ref.close(); - } - return delete QR.lastNotification; - }, - status: function(data) { - var disabled, input, value; - if (data == null) { - data = {}; - } - if (!QR.el) { - return; - } - if (g.dead) { - value = 404; - disabled = true; - QR.cooldown.auto = false; - } - value = data.progress || QR.cooldown.seconds || value; - input = QR.status.input; - input.value = QR.cooldown.auto ? value ? "Auto " + value : 'Auto' : value || 'Submit'; - return input.disabled = disabled || false; - }, - cooldown: { - init: function() { - QR.cooldown.types = { - thread: (function() { - switch (g.BOARD) { - case 'q': - return 86400; - case 'b': - case 'soc': - case 'r9k': - return 600; - default: - return 300; - } - })(), - sage: g.BOARD === 'q' ? 600 : 60, - file: g.BOARD === 'q' ? 300 : 30, - post: g.BOARD === 'q' ? 60 : 30 - }; - QR.cooldown.cooldowns = $.get("" + g.BOARD + ".cooldown", {}); - QR.cooldown.start(); - return $.sync("" + g.BOARD + ".cooldown", QR.cooldown.sync); - }, - start: function() { - if (QR.cooldown.isCounting) { - return; - } - QR.cooldown.isCounting = true; - return QR.cooldown.count(); - }, - sync: function(cooldowns) { - var id; - for (id in cooldowns) { - QR.cooldown.cooldowns[id] = cooldowns[id]; - } - return QR.cooldown.start(); - }, - set: function(data) { - var cooldown, hasFile, isReply, isSage, start, type; - 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 = !isReply ? 'thread' : isSage ? 'sage' : hasFile ? 'file' : '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); - return QR.cooldown.start(); - }, - unset: function(id) { - delete QR.cooldown.cooldowns[id]; - return $.set("" + g.BOARD + ".cooldown", QR.cooldown.cooldowns); - }, - count: function() { - var cooldown, cooldowns, elapsed, hasFile, isReply, isSage, now, post, seconds, start, type, types, update, _ref; - 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 = g.BOARD.ID === 'f' && g.VIEW === 'thread' ? true : QR.threadSelector.value !== 'new'; - if (isReply) { - post = QR.replies[0]; - isSage = /sage/i.test(post.email); - hasFile = !!post.file; - } - now = Date.now(); - seconds = null; - _ref = QR.cooldown, types = _ref.types, cooldowns = _ref.cooldowns; - for (start in cooldowns) { - cooldown = cooldowns[start]; - if ('delay' in cooldown) { - if (cooldown.delay) { - seconds = Math.max(seconds, cooldown.delay--); - } else { - seconds = Math.max(seconds, 0); - QR.cooldown.unset(start); - } - continue; - } - if (isReply === cooldown.isReply) { - type = !isReply ? 'thread' : isSage && cooldown.isSage ? 'sage' : hasFile && cooldown.hasFile ? 'file' : 'post'; - elapsed = Math.floor((now - start) / 1000); - if (elapsed >= 0) { - seconds = Math.max(seconds, types[type] - elapsed); - } - } - if (!((start <= now && now <= cooldown.timeout))) { - QR.cooldown.unset(start); - } - } - update = seconds !== null || !!QR.cooldown.seconds; - QR.cooldown.seconds = seconds; - if (update) { - QR.status(); - } - if (seconds === 0 && QR.cooldown.auto) { - return QR.submit(); - } - } - }, - quote: function(e) { - var caretPos, post, range, s, sel, selectionRoot, ta, text; - if (e != null) { - e.preventDefault(); - } - QR.open(); - ta = $('textarea', QR.el); - if (QR.threadSelector && !ta.value && g.BOARD.ID !== 'f') { - QR.threadSelector.value = $.x('ancestor::div[parent::div[@class="board"]]', this).id.slice(1); - } - post = Get.postFromRoot($.x('ancestor-or-self::div[contains(@class,"postContainer")][1]', this)); - text = ">>" + post + "\n"; - sel = d.getSelection(); - selectionRoot = $.x('ancestor-or-self::div[contains(@class,"postContainer")][1]', sel.anchorNode); - if ((s = sel.toString().trim()) && post.nodes.root === selectionRoot) { - s = s.replace(/\n/g, '\n>'); - text += ">" + s + "\n"; - } - caretPos = ta.selectionStart; - ta.value = ta.value.slice(0, caretPos) + text + ta.value.slice(ta.selectionEnd); - range = caretPos + text.length; - ta.setSelectionRange(range, range); - ta.focus(); - return ta.dispatchEvent(new Event('input')); - }, - characterCount: function() { - var count, counter; - counter = QR.charaCounter; - count = this.textLength; - counter.textContent = count; - counter.hidden = count < 1000; - return (count > 1500 ? $.addClass : $.rmClass)(counter, 'warning'); - }, - drag: function(e) { - var toggle; - toggle = e.type === 'dragstart' ? $.off : $.on; - toggle(d, 'dragover', QR.dragOver); - return toggle(d, 'drop', QR.dropFile); - }, - dragOver: function(e) { - e.preventDefault(); - return e.dataTransfer.dropEffect = 'copy'; - }, - dropFile: function(e) { - if (!e.dataTransfer.files.length) { - return; - } - e.preventDefault(); - QR.open(); - QR.fileInput.call(e.dataTransfer); - return $.addClass(QR.el, 'dump'); - }, - fileInput: function() { - var file, _i, _len, _ref; - QR.cleanNotification(); - if (this.files.length === 1) { - file = this.files[0]; - if (file.size > this.max) { - QR.error('File too large.'); - QR.resetFileInput(); - } else if (-1 === QR.mimeTypes.indexOf(file.type)) { - QR.error('Unsupported file type.'); - QR.resetFileInput(); - } else { - QR.selected.setFile(file); - } - return; - } - _ref = this.files; - for (_i = 0, _len = _ref.length; _i < _len; _i++) { - file = _ref[_i]; - if (file.size > this.max) { - QR.error("File " + file.name + " is too large."); - break; - } else if (-1 === QR.mimeTypes.indexOf(file.type)) { - QR.error("" + file.name + ": Unsupported file type."); - break; - } - if (!QR.replies[QR.replies.length - 1].file) { - QR.replies[QR.replies.length - 1].setFile(file); - } else { - new QR.reply().setFile(file); - } - } - $.addClass(QR.el, 'dump'); - return QR.resetFileInput(); - }, - resetFileInput: function() { - return $('[type=file]', QR.el).value = null; - }, - replies: [], - reply: (function() { - - function _Class() { - var persona, prev, - _this = this; - prev = QR.replies[QR.replies.length - 1]; - persona = $.get('QR.persona', {}); - this.name = prev ? prev.name : persona.name || null; - this.email = prev && !/^sage$/.test(prev.email) ? prev.email : persona.email || null; - this.sub = prev && Conf['Remember Subject'] ? prev.sub : Conf['Remember Subject'] ? persona.sub : null; - this.spoiler = prev && Conf['Remember Spoiler'] ? prev.spoiler : false; - this.com = null; - this.el = $.el('a', { - className: 'qrpreview', - draggable: true, - href: 'javascript:;', - innerHTML: '×' - }); - $('input', this.el).checked = this.spoiler; - $.on(this.el, 'click', function() { - return _this.select(); - }); - $.on($('.remove', this.el), 'click', function(e) { - e.stopPropagation(); - return _this.rm(); - }); - $.on($('label', this.el), 'click', function(e) { - return e.stopPropagation(); - }); - $.on($('input', this.el), 'change', function(e) { - _this.spoiler = e.target.checked; - if (_this.el.id === 'selected') { - return $.id('spoiler').checked = _this.spoiler; - } - }); - $.before($('#addReply', QR.el), this.el); - $.on(this.el, 'dragstart', this.dragStart); - $.on(this.el, 'dragenter', this.dragEnter); - $.on(this.el, 'dragleave', this.dragLeave); - $.on(this.el, 'dragover', this.dragOver); - $.on(this.el, 'dragend', this.dragEnd); - $.on(this.el, 'drop', this.drop); - QR.replies.push(this); - } - - _Class.prototype.setFile = function(file) { - var fileUrl, img, url, - _this = this; - this.file = file; - this.el.title = "" + file.name + " (" + ($.bytesToString(file.size)) + ")"; - if (QR.spoiler) { - $('label', this.el).hidden = false; - } - if (!/^image/.test(file.type)) { - this.el.style.backgroundImage = null; - return; - } - if (!(url = window.URL || window.webkitURL)) { - return; - } - url.revokeObjectURL(this.url); - fileUrl = url.createObjectURL(file); - img = $.el('img'); - $.on(img, 'load', function() { - var c, data, i, l, s, ui8a, _i; - s = 90 * 3; - if (img.height < s || img.width < s) { - _this.url = fileUrl; - _this.el.style.backgroundImage = "url(" + _this.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); - data = atob(c.toDataURL().split(',')[1]); - l = data.length; - ui8a = new Uint8Array(l); - for (i = _i = 0; 0 <= l ? _i < l : _i > l; i = 0 <= l ? ++_i : --_i) { - ui8a[i] = data.charCodeAt(i); - } - _this.url = url.createObjectURL(new Blob([ui8a], { - type: 'image/png' - })); - _this.el.style.backgroundImage = "url(" + _this.url + ")"; - return typeof url.revokeObjectURL === "function" ? url.revokeObjectURL(fileUrl) : void 0; - }); - return img.src = fileUrl; - }; - - _Class.prototype.rmFile = function() { - var _base; - QR.resetFileInput(); - delete this.file; - this.el.title = null; - this.el.style.backgroundImage = null; - if (QR.spoiler) { - $('label', this.el).hidden = true; - } - return typeof (_base = window.URL || window.webkitURL).revokeObjectURL === "function" ? _base.revokeObjectURL(this.url) : void 0; - }; - - _Class.prototype.select = function() { - var data, rectEl, rectList, _i, _len, _ref, _ref1; - if ((_ref = QR.selected) != null) { - _ref.el.id = null; - } - QR.selected = this; - this.el.id = 'selected'; - rectEl = this.el.getBoundingClientRect(); - rectList = this.el.parentNode.getBoundingClientRect(); - this.el.parentNode.scrollLeft += rectEl.left + rectEl.width / 2 - rectList.left - rectList.width / 2; - _ref1 = ['name', 'email', 'sub', 'com']; - for (_i = 0, _len = _ref1.length; _i < _len; _i++) { - data = _ref1[_i]; - $("[name=" + data + "]", QR.el).value = this[data]; - } - QR.characterCount.call($('textarea', QR.el)); - return $('#spoiler', QR.el).checked = this.spoiler; - }; - - _Class.prototype.dragStart = function() { - return $.addClass(this, 'drag'); - }; - - _Class.prototype.dragEnter = function() { - return $.addClass(this, 'over'); - }; - - _Class.prototype.dragLeave = function() { - return $.rmClass(this, 'over'); - }; - - _Class.prototype.dragOver = function(e) { - e.preventDefault(); - return e.dataTransfer.dropEffect = 'move'; - }; - - _Class.prototype.drop = function() { - var el, index, newIndex, oldIndex, reply; - el = $('.drag', this.parentNode); - index = function(el) { - return Array.prototype.slice.call(el.parentNode.children).indexOf(el); - }; - oldIndex = index(el); - newIndex = index(this); - if (oldIndex < newIndex) { - $.after(this, el); - } else { - $.before(this, el); - } - reply = QR.replies.splice(oldIndex, 1)[0]; - return QR.replies.splice(newIndex, 0, reply); - }; - - _Class.prototype.dragEnd = function() { - var el; - $.rmClass(this, 'drag'); - if (el = $('.over', this.parentNode)) { - return $.rmClass(el, 'over'); - } - }; - - _Class.prototype.rm = function() { - var index, _ref; - QR.resetFileInput(); - $.rm(this.el); - index = QR.replies.indexOf(this); - if (QR.replies.length === 1) { - new QR.reply().select(); - } else if (this.el.id === 'selected') { - (QR.replies[index - 1] || QR.replies[index + 1]).select(); - } - QR.replies.splice(index, 1); - return (_ref = window.URL || window.webkitURL) != null ? _ref.revokeObjectURL(this.url) : void 0; - }; - - return _Class; - - })(), - captcha: { - init: function() { - var _this = this; - if (-1 !== d.cookie.indexOf('pass_enabled=')) { - return; - } - if (!(this.isEnabled = !!$.id('captchaFormPart'))) { - return; - } - if ($.id('recaptcha_challenge_field_holder')) { - return this.ready(); - } else { - this.onready = function() { - return _this.ready(); - }; - return $.on($.id('recaptcha_widget_div'), 'DOMNodeInserted', this.onready); - } - }, - ready: function() { - var _this = this; - if (this.challenge = $.id('recaptcha_challenge_field_holder')) { - $.off($.id('recaptcha_widget_div'), 'DOMNodeInserted', this.onready); - delete this.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: '' - })); - this.img = $('.captchaimg > img', QR.el); - this.input = $('.captchainput > input', QR.el); - $.on(this.img.parentNode, 'click', this.reload); - $.on(this.input, 'keydown', this.keydown); - $.on(this.challenge, 'DOMNodeInserted', function() { - return _this.load(); - }); - $.sync('captchas', function(arr) { - return _this.count(arr.length); - }); - this.count($.get('captchas', []).length); - return this.reload(); - }, - save: function() { - var captcha, captchas, response; - if (!(response = this.input.value)) { - return; - } - captchas = $.get('captchas', []); - while ((captcha = captchas[0]) && captcha.time < Date.now()) { - captchas.shift(); - } - captchas.push({ - challenge: this.challenge.firstChild.value, - response: response, - time: this.timeout - }); - $.set('captchas', captchas); - this.count(captchas.length); - return this.reload(); - }, - load: function() { - var challenge; - this.timeout = Date.now() + $.unsafeWindow.RecaptchaState.timeout * $.SECOND - $.MINUTE; - challenge = this.challenge.firstChild.value; - this.img.alt = challenge; - this.img.src = "//www.google.com/recaptcha/api/image?c=" + challenge; - return this.input.value = null; - }, - count: function(count) { - this.input.placeholder = (function() { - switch (count) { - case 0: - return 'Verification (Shift + Enter to cache)'; - case 1: - return 'Verification (1 cached captcha)'; - default: - return "Verification (" + count + " cached captchas)"; - } - })(); - return this.input.alt = count; - }, - reload: function(focus) { - $.unsafeWindow.Recaptcha.reload('t'); - if (focus) { - return QR.captcha.input.focus(); - } - }, - keydown: function(e) { - var c; - c = QR.captcha; - if (e.keyCode === 8 && !c.input.value) { - c.reload(); - } else if (e.keyCode === 13 && e.shiftKey) { - c.save(); - } else { - return; - } - return e.preventDefault(); - } - }, - dialog: function() { - var fileInput, key, mimeTypes, name, span, spoiler, ta, thread, threads, _i, _len, _ref, _ref1; - QR.el = UI.dialog('qr', 'top:0;right:0;', "
Quick Reply ×
\n
\n
\n \n
\n
\n \n
"); - mimeTypes = $('ul.rules').firstElementChild.textContent.trim().match(/: (.+)/)[1].toLowerCase().replace(/\w+/g, function(type) { - switch (type) { - case 'jpg': - return 'image/jpeg'; - case 'pdf': - return 'application/pdf'; - case 'swf': - return 'application/x-shockwave-flash'; - default: - return "image/" + type; - } - }); - QR.mimeTypes = mimeTypes.split(', '); - QR.mimeTypes.push(''); - fileInput = $('input[type=file]', QR.el); - fileInput.max = $('input[name=MAX_FILE_SIZE]').value; - if ($.engine !== 'presto') { - fileInput.accept = mimeTypes; - } - 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); - if (g.BOARD.ID === 'f') { - if (g.VIEW === 'index') { - QR.threadSelector = $('select[name=filetag]').cloneNode(true); - } - } else { - QR.threadSelector = $.el('select', { - title: 'Create a new thread / Reply to a thread' - }); - threads = ''; - _ref = g.BOARD.threads; - for (key in _ref) { - thread = _ref[key]; - threads += ""; - } - QR.threadSelector.innerHTML = threads; - if (g.VIEW === 'thread') { - QR.threadSelector.value = g.THREAD; - } - } - if (QR.threadSelector) { - $.prepend(span, QR.threadSelector); - } - $.on(span, 'mousedown', function(e) { - return e.stopPropagation(); - }); - $.on($('#autohide', QR.el), 'change', QR.toggleHide); - $.on($('.close', QR.el), 'click', QR.close); - $.on($('#dump', QR.el), 'click', function() { - return QR.el.classList.toggle('dump'); - }); - $.on($('#addReply', QR.el), 'click', function() { - return new QR.reply().select(); - }); - $.on($('form', QR.el), 'submit', QR.submit); - $.on(ta, 'input', function() { - return QR.selected.el.lastChild.textContent = this.value; - }); - $.on(ta, 'input', QR.characterCount); - $.on(fileInput, 'change', QR.fileInput); - $.on(fileInput, 'click', function(e) { - if (e.shiftKey) { - return QR.selected.rmFile() || e.preventDefault(); - } - }); - $.on(spoiler.firstChild, 'change', function() { - return $('input', QR.selected.el).click(); - }); - new QR.reply().select(); - _ref1 = ['name', 'email', 'sub', 'com']; - for (_i = 0, _len = _ref1.length; _i < _len; _i++) { - name = _ref1[_i]; - $.on($("[name=" + name + "]", QR.el), 'input', function() { - var _ref2; - QR.selected[this.name] = this.value; - if (QR.cooldown.auto && QR.selected === QR.replies[0] && (0 < (_ref2 = QR.cooldown.seconds) && _ref2 <= 5)) { - return 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); - return $.event(new CustomEvent('QRDialogCreation', null, QR.el)); - }, - submit: function(e) { - var callbacks, captcha, captchas, challenge, err, filetag, m, opts, post, reply, response, textOnly, threadID, _ref; - if (e != null) { - e.preventDefault(); - } - if (QR.cooldown.seconds) { - QR.cooldown.auto = !QR.cooldown.auto; - QR.status(); - return; - } - QR.abort(); - reply = QR.replies[0]; - if (g.BOARD.ID === 'f' && g.VIEW === 'index') { - filetag = QR.threadSelector.value; - threadID = 'new'; - } else { - threadID = QR.threadSelector.value; - } - if (threadID === 'new') { - threadID = null; - if (((_ref = g.BOARD.ID) === 'vg' || _ref === 'q') && !reply.sub) { - err = 'New threads require a subject.'; - } else if (!(reply.file || (textOnly = !!$('input[name=textonly]', $.id('postForm'))))) { - err = 'No file selected.'; - } else if (g.BOARD.ID === 'f' && filetag === '9999') { - err = 'Invalid tag specified.'; - } - } else if (!(reply.com || reply.file)) { - err = 'No file selected.'; - } - if (QR.captcha.isEnabled && !err) { - captchas = $.get('captchas', []); - while ((captcha = captchas[0]) && 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) { - QR.captcha.reload(); - } - } - $.set('captchas', captchas); - QR.captcha.count(captchas.length); - if (!response) { - err = 'No valid captcha.'; - } else { - response = response.trim(); - if (!/\s/.test(response)) { - response = "" + response + " " + response; - } - } - } - if (err) { - QR.cooldown.auto = false; - QR.status(); - QR.error(err); - return; - } - QR.cleanNotification(); - QR.cooldown.auto = QR.replies.length > 1; - if (Conf['Auto Hide QR'] && !QR.cooldown.auto) { - QR.hide(); - } - if (!QR.cooldown.auto && $.x('ancestor::div[@id="qr"]', d.activeElement)) { - d.activeElement.blur(); - } - 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: (m = d.cookie.match(/4chan_pass=([^;]+)/)) ? decodeURIComponent(m[1]) : $('input[name=pwd]').value, - recaptcha_challenge_field: challenge, - recaptcha_response_field: response - }; - callbacks = { - onload: function() { - return QR.response(this.response); - }, - onerror: function() { - QR.cooldown.auto = false; - QR.status(); - return QR.error($.el('a', { - href: '//www.4chan.org/banned', - target: '_blank', - textContent: 'Connection error, or you are banned.' - })); - } - }; - opts = { - form: $.formData(post), - upCallbacks: { - onload: function() { - return QR.status({ - progress: '...' - }); - }, - onprogress: function(e) { - return QR.status({ - progress: "" + (Math.round(e.loaded / e.total * 100)) + "%" - }); - } - } - }; - return QR.ajax = $.ajax($.id('postForm').parentNode.action, callbacks, opts); - }, - response: function(html) { - var ban, board, err, h1, persona, postID, reply, threadID, tmpDoc, _, _ref, _ref1; - tmpDoc = d.implementation.createHTMLDocument(''); - tmpDoc.documentElement.innerHTML = html; - if (ban = $('.banType', tmpDoc)) { - board = $('.board', tmpDoc).innerHTML; - err = $.el('span', { - innerHTML: ban.textContent.toLowerCase() === 'banned' ? ("You are banned on " + board + "! ;_;
") + "Click here to see the reason." : ("You were issued a warning on " + board + " as " + ($('.nameBlock', tmpDoc).innerHTML) + ".
") + ("Reason: " + ($('.reason', tmpDoc).innerHTML)) - }); - } else if (err = tmpDoc.getElementById('errmsg')) { - if ((_ref = $('a', err)) != null) { - _ref.target = '_blank'; - } - } else if (tmpDoc.title !== 'Post successful!') { - err = 'Connection error with sys.4chan.org.'; - } - if (err) { - if (/captcha|verification/i.test(err.textContent) || err === 'Connection error with sys.4chan.org.') { - if (/mistyped/i.test(err.textContent)) { - err = 'Error: You seem to have mistyped the CAPTCHA.'; - } - QR.cooldown.auto = QR.captcha.isEnabled ? !!$.get('captchas', []).length : err === 'Connection error with sys.4chan.org.' ? true : false; - QR.cooldown.set({ - delay: 2 - }); - } else { - QR.cooldown.auto = false; - } - QR.status(); - QR.error(err); - return; - } - h1 = $('h1', tmpDoc); - QR.lastNotification = new Notification('success', h1.textContent, 5); - reply = QR.replies[0]; - persona = $.get('QR.persona', {}); - persona = { - name: reply.name, - email: /^sage$/.test(reply.email) ? persona.email : reply.email, - sub: Conf['Remember Subject'] ? reply.sub : null - }; - $.set('QR.persona', persona); - _ref1 = h1.nextSibling.textContent.match(/thread:(\d+),no:(\d+)/), _ = _ref1[0], threadID = _ref1[1], postID = _ref1[2]; - $.event(new CustomEvent('QRPostSuccessful', { - threadID: threadID, - postID: postID - }, QR.el)); - QR.cooldown.set({ - post: reply, - isReply: threadID !== '0' - }); - QR.cooldown.auto = QR.replies.length > 1; - if (threadID === '0') { - $.open("/" + g.BOARD + "/res/" + postID); - } else if (g.VIEW === 'reply' && !QR.cooldown.auto) { - $.open("//boards.4chan.org/" + g.BOARD + "/res/" + threadID + "#p" + postID); - } - if (Conf['Persistent QR'] || QR.cooldown.auto) { - reply.rm(); - } else { - QR.close(); - } - QR.status(); - return QR.resetFileInput(); - }, - abort: function() { - var _ref; - if ((_ref = QR.ajax) != null) { - _ref.abort(); - } - delete QR.ajax; - return QR.status(); - } - }; - Menu = { init: function() { if (g.VIEW === 'catalog' || !Conf['Menu']) { @@ -4716,6 +3783,939 @@ })() }; + QR = { + init: function() { + var link; + if (g.VIEW === 'catalog' || !Conf['Quick Reply']) { + return; + } + 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', function() { + Header.menu.close(); + QR.open(); + if (g.BOARD.ID === 'f') { + if (g.VIEW === 'index') { + QR.threadSelector.value = '9999'; + } + } else if (g.VIEW === 'thread') { + QR.threadSelector.value = g.THREAD; + } else { + QR.threadSelector.value = 'new'; + } + return $('textarea', QR.el).focus(); + }); + $.event('AddMenuEntry', { + type: 'header', + el: link + }); + $.on(d, 'dragover', QR.dragOver); + $.on(d, 'drop', QR.dropFile); + $.on(d, 'dragstart dragend', QR.drag); + $.on(d, '4chanXInitFinished', function() { + if (!Conf['Persistent QR']) { + return; + } + QR.open(); + if (Conf['Auto Hide QR']) { + return QR.hide(); + } + }); + return Post.prototype.callbacks.push({ + name: 'Quick Reply', + cb: this.node + }); + }, + node: function() { + return $.on($('a[title="Quote this post"]', this.nodes.info), 'click', QR.quote); + }, + open: function() { + if (QR.el) { + QR.el.hidden = false; + QR.unhide(); + return; + } + try { + return QR.dialog(); + } catch (err) { + delete QR.el; + return Main.handleErrors({ + message: 'Quick Reply dialog creation crashed.', + error: err + }); + } + }, + close: function() { + var i, spoiler, _i, _len, _ref; + QR.el.hidden = true; + QR.abort(); + d.activeElement.blur(); + $.rmClass(QR.el, 'dump'); + _ref = QR.replies; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + i = _ref[_i]; + QR.replies[0].rm(); + } + QR.cooldown.auto = false; + QR.status(); + QR.resetFileInput(); + if (!Conf['Remember Spoiler'] && (spoiler = $.id('spoiler')).checked) { + spoiler.click(); + } + return QR.cleanNotification(); + }, + hide: function() { + d.activeElement.blur(); + $.addClass(QR.el, 'autohide'); + return $.id('autohide').checked = true; + }, + unhide: function() { + $.rmClass(QR.el, 'autohide'); + return $.id('autohide').checked = false; + }, + toggleHide: function() { + if (this.checked) { + return QR.hide(); + } else { + return QR.unhide(); + } + }, + error: function(err) { + var el; + QR.open(); + if (typeof err === 'string') { + el = $.tn(err); + } else { + el = err; + el.removeAttribute('style'); + } + if (QR.captcha.isEnabled && /captcha|verification/i.test(el.textContent)) { + $('[autocomplete]', QR.el).focus(); + } + if (d.hidden) { + alert(el.textContent); + } + return QR.lastNotification = new Notification('warning', el); + }, + cleanNotification: function() { + var _ref; + if ((_ref = QR.lastNotification) != null) { + _ref.close(); + } + return delete QR.lastNotification; + }, + status: function(data) { + var disabled, input, value; + if (data == null) { + data = {}; + } + if (!QR.el) { + return; + } + if (g.dead) { + value = 404; + disabled = true; + QR.cooldown.auto = false; + } + value = data.progress || QR.cooldown.seconds || value; + input = QR.status.input; + input.value = QR.cooldown.auto ? value ? "Auto " + value : 'Auto' : value || 'Submit'; + return input.disabled = disabled || false; + }, + cooldown: { + init: function() { + QR.cooldown.types = { + thread: (function() { + switch (g.BOARD) { + case 'q': + return 86400; + case 'b': + case 'soc': + case 'r9k': + return 600; + default: + return 300; + } + })(), + sage: g.BOARD === 'q' ? 600 : 60, + file: g.BOARD === 'q' ? 300 : 30, + post: g.BOARD === 'q' ? 60 : 30 + }; + QR.cooldown.cooldowns = $.get("" + g.BOARD + ".cooldown", {}); + QR.cooldown.start(); + return $.sync("" + g.BOARD + ".cooldown", QR.cooldown.sync); + }, + start: function() { + if (QR.cooldown.isCounting) { + return; + } + QR.cooldown.isCounting = true; + return QR.cooldown.count(); + }, + sync: function(cooldowns) { + var id; + for (id in cooldowns) { + QR.cooldown.cooldowns[id] = cooldowns[id]; + } + return QR.cooldown.start(); + }, + set: function(data) { + var cooldown, hasFile, isReply, isSage, start, type; + 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 = !isReply ? 'thread' : isSage ? 'sage' : hasFile ? 'file' : '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); + return QR.cooldown.start(); + }, + unset: function(id) { + delete QR.cooldown.cooldowns[id]; + return $.set("" + g.BOARD + ".cooldown", QR.cooldown.cooldowns); + }, + count: function() { + var cooldown, cooldowns, elapsed, hasFile, isReply, isSage, now, post, seconds, start, type, types, update, _ref; + 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 = g.BOARD.ID === 'f' && g.VIEW === 'thread' ? true : QR.threadSelector.value !== 'new'; + if (isReply) { + post = QR.replies[0]; + isSage = /sage/i.test(post.email); + hasFile = !!post.file; + } + now = Date.now(); + seconds = null; + _ref = QR.cooldown, types = _ref.types, cooldowns = _ref.cooldowns; + for (start in cooldowns) { + cooldown = cooldowns[start]; + if ('delay' in cooldown) { + if (cooldown.delay) { + seconds = Math.max(seconds, cooldown.delay--); + } else { + seconds = Math.max(seconds, 0); + QR.cooldown.unset(start); + } + continue; + } + if (isReply === cooldown.isReply) { + type = !isReply ? 'thread' : isSage && cooldown.isSage ? 'sage' : hasFile && cooldown.hasFile ? 'file' : 'post'; + elapsed = Math.floor((now - start) / 1000); + if (elapsed >= 0) { + seconds = Math.max(seconds, types[type] - elapsed); + } + } + if (!((start <= now && now <= cooldown.timeout))) { + QR.cooldown.unset(start); + } + } + update = seconds !== null || !!QR.cooldown.seconds; + QR.cooldown.seconds = seconds; + if (update) { + QR.status(); + } + if (seconds === 0 && QR.cooldown.auto) { + return QR.submit(); + } + } + }, + quote: function(e) { + var caretPos, post, range, s, sel, selectionRoot, ta, text; + if (e != null) { + e.preventDefault(); + } + QR.open(); + ta = $('textarea', QR.el); + if (QR.threadSelector && !ta.value && g.BOARD.ID !== 'f') { + QR.threadSelector.value = $.x('ancestor::div[parent::div[@class="board"]]', this).id.slice(1); + } + post = Get.postFromRoot($.x('ancestor-or-self::div[contains(@class,"postContainer")][1]', this)); + text = ">>" + post + "\n"; + sel = d.getSelection(); + selectionRoot = $.x('ancestor-or-self::div[contains(@class,"postContainer")][1]', sel.anchorNode); + if ((s = sel.toString().trim()) && post.nodes.root === selectionRoot) { + s = s.replace(/\n/g, '\n>'); + text += ">" + s + "\n"; + } + caretPos = ta.selectionStart; + ta.value = ta.value.slice(0, caretPos) + text + ta.value.slice(ta.selectionEnd); + range = caretPos + text.length; + ta.setSelectionRange(range, range); + ta.focus(); + return ta.dispatchEvent(new Event('input')); + }, + characterCount: function() { + var count, counter; + counter = QR.charaCounter; + count = this.textLength; + counter.textContent = count; + counter.hidden = count < 1000; + return (count > 1500 ? $.addClass : $.rmClass)(counter, 'warning'); + }, + drag: function(e) { + var toggle; + toggle = e.type === 'dragstart' ? $.off : $.on; + toggle(d, 'dragover', QR.dragOver); + return toggle(d, 'drop', QR.dropFile); + }, + dragOver: function(e) { + e.preventDefault(); + return e.dataTransfer.dropEffect = 'copy'; + }, + dropFile: function(e) { + if (!e.dataTransfer.files.length) { + return; + } + e.preventDefault(); + QR.open(); + QR.fileInput.call(e.dataTransfer); + return $.addClass(QR.el, 'dump'); + }, + fileInput: function() { + var file, _i, _len, _ref; + QR.cleanNotification(); + if (this.files.length === 1) { + file = this.files[0]; + if (file.size > this.max) { + QR.error('File too large.'); + QR.resetFileInput(); + } else if (-1 === QR.mimeTypes.indexOf(file.type)) { + QR.error('Unsupported file type.'); + QR.resetFileInput(); + } else { + QR.selected.setFile(file); + } + return; + } + _ref = this.files; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + file = _ref[_i]; + if (file.size > this.max) { + QR.error("File " + file.name + " is too large."); + break; + } else if (-1 === QR.mimeTypes.indexOf(file.type)) { + QR.error("" + file.name + ": Unsupported file type."); + break; + } + if (!QR.replies[QR.replies.length - 1].file) { + QR.replies[QR.replies.length - 1].setFile(file); + } else { + new QR.reply().setFile(file); + } + } + $.addClass(QR.el, 'dump'); + return QR.resetFileInput(); + }, + resetFileInput: function() { + return $('[type=file]', QR.el).value = null; + }, + replies: [], + reply: (function() { + + function _Class() { + var persona, prev, + _this = this; + prev = QR.replies[QR.replies.length - 1]; + persona = $.get('QR.persona', {}); + this.name = prev ? prev.name : persona.name || null; + this.email = prev && !/^sage$/.test(prev.email) ? prev.email : persona.email || null; + this.sub = prev && Conf['Remember Subject'] ? prev.sub : Conf['Remember Subject'] ? persona.sub : null; + this.spoiler = prev && Conf['Remember Spoiler'] ? prev.spoiler : false; + this.com = null; + this.el = $.el('a', { + className: 'qrpreview', + draggable: true, + href: 'javascript:;', + innerHTML: '×' + }); + $('input', this.el).checked = this.spoiler; + $.on(this.el, 'click', function() { + return _this.select(); + }); + $.on($('.remove', this.el), 'click', function(e) { + e.stopPropagation(); + return _this.rm(); + }); + $.on($('label', this.el), 'click', function(e) { + return e.stopPropagation(); + }); + $.on($('input', this.el), 'change', function(e) { + _this.spoiler = e.target.checked; + if (_this.el.id === 'selected') { + return $.id('spoiler').checked = _this.spoiler; + } + }); + $.before($('#addReply', QR.el), this.el); + $.on(this.el, 'dragstart', this.dragStart); + $.on(this.el, 'dragenter', this.dragEnter); + $.on(this.el, 'dragleave', this.dragLeave); + $.on(this.el, 'dragover', this.dragOver); + $.on(this.el, 'dragend', this.dragEnd); + $.on(this.el, 'drop', this.drop); + QR.replies.push(this); + } + + _Class.prototype.setFile = function(file) { + var fileUrl, img, url, + _this = this; + this.file = file; + this.el.title = "" + file.name + " (" + ($.bytesToString(file.size)) + ")"; + if (QR.spoiler) { + $('label', this.el).hidden = false; + } + if (!/^image/.test(file.type)) { + this.el.style.backgroundImage = null; + return; + } + if (!(url = window.URL || window.webkitURL)) { + return; + } + url.revokeObjectURL(this.url); + fileUrl = url.createObjectURL(file); + img = $.el('img'); + $.on(img, 'load', function() { + var c, data, i, l, s, ui8a, _i; + s = 90 * 3; + if (img.height < s || img.width < s) { + _this.url = fileUrl; + _this.el.style.backgroundImage = "url(" + _this.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); + data = atob(c.toDataURL().split(',')[1]); + l = data.length; + ui8a = new Uint8Array(l); + for (i = _i = 0; 0 <= l ? _i < l : _i > l; i = 0 <= l ? ++_i : --_i) { + ui8a[i] = data.charCodeAt(i); + } + _this.url = url.createObjectURL(new Blob([ui8a], { + type: 'image/png' + })); + _this.el.style.backgroundImage = "url(" + _this.url + ")"; + return typeof url.revokeObjectURL === "function" ? url.revokeObjectURL(fileUrl) : void 0; + }); + return img.src = fileUrl; + }; + + _Class.prototype.rmFile = function() { + var _base; + QR.resetFileInput(); + delete this.file; + this.el.title = null; + this.el.style.backgroundImage = null; + if (QR.spoiler) { + $('label', this.el).hidden = true; + } + return typeof (_base = window.URL || window.webkitURL).revokeObjectURL === "function" ? _base.revokeObjectURL(this.url) : void 0; + }; + + _Class.prototype.select = function() { + var data, rectEl, rectList, _i, _len, _ref, _ref1; + if ((_ref = QR.selected) != null) { + _ref.el.id = null; + } + QR.selected = this; + this.el.id = 'selected'; + rectEl = this.el.getBoundingClientRect(); + rectList = this.el.parentNode.getBoundingClientRect(); + this.el.parentNode.scrollLeft += rectEl.left + rectEl.width / 2 - rectList.left - rectList.width / 2; + _ref1 = ['name', 'email', 'sub', 'com']; + for (_i = 0, _len = _ref1.length; _i < _len; _i++) { + data = _ref1[_i]; + $("[name=" + data + "]", QR.el).value = this[data]; + } + QR.characterCount.call($('textarea', QR.el)); + return $('#spoiler', QR.el).checked = this.spoiler; + }; + + _Class.prototype.dragStart = function() { + return $.addClass(this, 'drag'); + }; + + _Class.prototype.dragEnter = function() { + return $.addClass(this, 'over'); + }; + + _Class.prototype.dragLeave = function() { + return $.rmClass(this, 'over'); + }; + + _Class.prototype.dragOver = function(e) { + e.preventDefault(); + return e.dataTransfer.dropEffect = 'move'; + }; + + _Class.prototype.drop = function() { + var el, index, newIndex, oldIndex, reply; + el = $('.drag', this.parentNode); + index = function(el) { + return Array.prototype.slice.call(el.parentNode.children).indexOf(el); + }; + oldIndex = index(el); + newIndex = index(this); + if (oldIndex < newIndex) { + $.after(this, el); + } else { + $.before(this, el); + } + reply = QR.replies.splice(oldIndex, 1)[0]; + return QR.replies.splice(newIndex, 0, reply); + }; + + _Class.prototype.dragEnd = function() { + var el; + $.rmClass(this, 'drag'); + if (el = $('.over', this.parentNode)) { + return $.rmClass(el, 'over'); + } + }; + + _Class.prototype.rm = function() { + var index, _ref; + QR.resetFileInput(); + $.rm(this.el); + index = QR.replies.indexOf(this); + if (QR.replies.length === 1) { + new QR.reply().select(); + } else if (this.el.id === 'selected') { + (QR.replies[index - 1] || QR.replies[index + 1]).select(); + } + QR.replies.splice(index, 1); + return (_ref = window.URL || window.webkitURL) != null ? _ref.revokeObjectURL(this.url) : void 0; + }; + + return _Class; + + })(), + captcha: { + init: function() { + var _this = this; + if (-1 !== d.cookie.indexOf('pass_enabled=')) { + return; + } + if (!(this.isEnabled = !!$.id('captchaFormPart'))) { + return; + } + if ($.id('recaptcha_challenge_field_holder')) { + return this.ready(); + } else { + this.onready = function() { + return _this.ready(); + }; + return $.on($.id('recaptcha_widget_div'), 'DOMNodeInserted', this.onready); + } + }, + ready: function() { + var _this = this; + if (this.challenge = $.id('recaptcha_challenge_field_holder')) { + $.off($.id('recaptcha_widget_div'), 'DOMNodeInserted', this.onready); + delete this.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: '' + })); + this.img = $('.captchaimg > img', QR.el); + this.input = $('.captchainput > input', QR.el); + $.on(this.img.parentNode, 'click', this.reload); + $.on(this.input, 'keydown', this.keydown); + $.on(this.challenge, 'DOMNodeInserted', function() { + return _this.load(); + }); + $.sync('captchas', function(arr) { + return _this.count(arr.length); + }); + this.count($.get('captchas', []).length); + return this.reload(); + }, + save: function() { + var captcha, captchas, response; + if (!(response = this.input.value)) { + return; + } + captchas = $.get('captchas', []); + while ((captcha = captchas[0]) && captcha.time < Date.now()) { + captchas.shift(); + } + captchas.push({ + challenge: this.challenge.firstChild.value, + response: response, + time: this.timeout + }); + $.set('captchas', captchas); + this.count(captchas.length); + return this.reload(); + }, + load: function() { + var challenge; + this.timeout = Date.now() + $.unsafeWindow.RecaptchaState.timeout * $.SECOND - $.MINUTE; + challenge = this.challenge.firstChild.value; + this.img.alt = challenge; + this.img.src = "//www.google.com/recaptcha/api/image?c=" + challenge; + return this.input.value = null; + }, + count: function(count) { + this.input.placeholder = (function() { + switch (count) { + case 0: + return 'Verification (Shift + Enter to cache)'; + case 1: + return 'Verification (1 cached captcha)'; + default: + return "Verification (" + count + " cached captchas)"; + } + })(); + return this.input.alt = count; + }, + reload: function(focus) { + $.unsafeWindow.Recaptcha.reload('t'); + if (focus) { + return QR.captcha.input.focus(); + } + }, + keydown: function(e) { + var c; + c = QR.captcha; + if (e.keyCode === 8 && !c.input.value) { + c.reload(); + } else if (e.keyCode === 13 && e.shiftKey) { + c.save(); + } else { + return; + } + return e.preventDefault(); + } + }, + dialog: function() { + var fileInput, key, mimeTypes, name, span, spoiler, ta, thread, threads, _i, _len, _ref, _ref1; + QR.el = UI.dialog('qr', 'top:0;right:0;', "
Quick Reply ×
\n
\n
\n \n
\n
\n \n
"); + mimeTypes = $('ul.rules').firstElementChild.textContent.trim().match(/: (.+)/)[1].toLowerCase().replace(/\w+/g, function(type) { + switch (type) { + case 'jpg': + return 'image/jpeg'; + case 'pdf': + return 'application/pdf'; + case 'swf': + return 'application/x-shockwave-flash'; + default: + return "image/" + type; + } + }); + QR.mimeTypes = mimeTypes.split(', '); + QR.mimeTypes.push(''); + fileInput = $('input[type=file]', QR.el); + fileInput.max = $('input[name=MAX_FILE_SIZE]').value; + if ($.engine !== 'presto') { + fileInput.accept = mimeTypes; + } + 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); + if (g.BOARD.ID === 'f') { + if (g.VIEW === 'index') { + QR.threadSelector = $('select[name=filetag]').cloneNode(true); + } + } else { + QR.threadSelector = $.el('select', { + title: 'Create a new thread / Reply to a thread' + }); + threads = ''; + _ref = g.BOARD.threads; + for (key in _ref) { + thread = _ref[key]; + threads += ""; + } + QR.threadSelector.innerHTML = threads; + if (g.VIEW === 'thread') { + QR.threadSelector.value = g.THREAD; + } + } + if (QR.threadSelector) { + $.prepend(span, QR.threadSelector); + } + $.on(span, 'mousedown', function(e) { + return e.stopPropagation(); + }); + $.on($('#autohide', QR.el), 'change', QR.toggleHide); + $.on($('.close', QR.el), 'click', QR.close); + $.on($('#dump', QR.el), 'click', function() { + return QR.el.classList.toggle('dump'); + }); + $.on($('#addReply', QR.el), 'click', function() { + return new QR.reply().select(); + }); + $.on($('form', QR.el), 'submit', QR.submit); + $.on(ta, 'input', function() { + return QR.selected.el.lastChild.textContent = this.value; + }); + $.on(ta, 'input', QR.characterCount); + $.on(fileInput, 'change', QR.fileInput); + $.on(fileInput, 'click', function(e) { + if (e.shiftKey) { + return QR.selected.rmFile() || e.preventDefault(); + } + }); + $.on(spoiler.firstChild, 'change', function() { + return $('input', QR.selected.el).click(); + }); + new QR.reply().select(); + _ref1 = ['name', 'email', 'sub', 'com']; + for (_i = 0, _len = _ref1.length; _i < _len; _i++) { + name = _ref1[_i]; + $.on($("[name=" + name + "]", QR.el), 'input', function() { + var _ref2; + QR.selected[this.name] = this.value; + if (QR.cooldown.auto && QR.selected === QR.replies[0] && (0 < (_ref2 = QR.cooldown.seconds) && _ref2 <= 5)) { + return 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); + return $.event(new CustomEvent('QRDialogCreation', null, QR.el)); + }, + submit: function(e) { + var callbacks, captcha, captchas, challenge, err, filetag, m, opts, post, reply, response, textOnly, threadID, _ref; + if (e != null) { + e.preventDefault(); + } + if (QR.cooldown.seconds) { + QR.cooldown.auto = !QR.cooldown.auto; + QR.status(); + return; + } + QR.abort(); + reply = QR.replies[0]; + if (g.BOARD.ID === 'f' && g.VIEW === 'index') { + filetag = QR.threadSelector.value; + threadID = 'new'; + } else { + threadID = QR.threadSelector.value; + } + if (threadID === 'new') { + threadID = null; + if (((_ref = g.BOARD.ID) === 'vg' || _ref === 'q') && !reply.sub) { + err = 'New threads require a subject.'; + } else if (!(reply.file || (textOnly = !!$('input[name=textonly]', $.id('postForm'))))) { + err = 'No file selected.'; + } else if (g.BOARD.ID === 'f' && filetag === '9999') { + err = 'Invalid tag specified.'; + } + } else if (!(reply.com || reply.file)) { + err = 'No file selected.'; + } + if (QR.captcha.isEnabled && !err) { + captchas = $.get('captchas', []); + while ((captcha = captchas[0]) && 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) { + QR.captcha.reload(); + } + } + $.set('captchas', captchas); + QR.captcha.count(captchas.length); + if (!response) { + err = 'No valid captcha.'; + } else { + response = response.trim(); + if (!/\s/.test(response)) { + response = "" + response + " " + response; + } + } + } + if (err) { + QR.cooldown.auto = false; + QR.status(); + QR.error(err); + return; + } + QR.cleanNotification(); + QR.cooldown.auto = QR.replies.length > 1; + if (Conf['Auto Hide QR'] && !QR.cooldown.auto) { + QR.hide(); + } + if (!QR.cooldown.auto && $.x('ancestor::div[@id="qr"]', d.activeElement)) { + d.activeElement.blur(); + } + 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: (m = d.cookie.match(/4chan_pass=([^;]+)/)) ? decodeURIComponent(m[1]) : $('input[name=pwd]').value, + recaptcha_challenge_field: challenge, + recaptcha_response_field: response + }; + callbacks = { + onload: function() { + return QR.response(this.response); + }, + onerror: function() { + QR.cooldown.auto = false; + QR.status(); + return QR.error($.el('a', { + href: '//www.4chan.org/banned', + target: '_blank', + textContent: 'Connection error, or you are banned.' + })); + } + }; + opts = { + form: $.formData(post), + upCallbacks: { + onload: function() { + return QR.status({ + progress: '...' + }); + }, + onprogress: function(e) { + return QR.status({ + progress: "" + (Math.round(e.loaded / e.total * 100)) + "%" + }); + } + } + }; + return QR.ajax = $.ajax($.id('postForm').parentNode.action, callbacks, opts); + }, + response: function(html) { + var ban, board, err, h1, persona, postID, reply, threadID, tmpDoc, _, _ref, _ref1; + tmpDoc = d.implementation.createHTMLDocument(''); + tmpDoc.documentElement.innerHTML = html; + if (ban = $('.banType', tmpDoc)) { + board = $('.board', tmpDoc).innerHTML; + err = $.el('span', { + innerHTML: ban.textContent.toLowerCase() === 'banned' ? ("You are banned on " + board + "! ;_;
") + "Click here to see the reason." : ("You were issued a warning on " + board + " as " + ($('.nameBlock', tmpDoc).innerHTML) + ".
") + ("Reason: " + ($('.reason', tmpDoc).innerHTML)) + }); + } else if (err = tmpDoc.getElementById('errmsg')) { + if ((_ref = $('a', err)) != null) { + _ref.target = '_blank'; + } + } else if (tmpDoc.title !== 'Post successful!') { + err = 'Connection error with sys.4chan.org.'; + } + if (err) { + if (/captcha|verification/i.test(err.textContent) || err === 'Connection error with sys.4chan.org.') { + if (/mistyped/i.test(err.textContent)) { + err = 'Error: You seem to have mistyped the CAPTCHA.'; + } + QR.cooldown.auto = QR.captcha.isEnabled ? !!$.get('captchas', []).length : err === 'Connection error with sys.4chan.org.' ? true : false; + QR.cooldown.set({ + delay: 2 + }); + } else { + QR.cooldown.auto = false; + } + QR.status(); + QR.error(err); + return; + } + h1 = $('h1', tmpDoc); + QR.lastNotification = new Notification('success', h1.textContent, 5); + reply = QR.replies[0]; + persona = $.get('QR.persona', {}); + persona = { + name: reply.name, + email: /^sage$/.test(reply.email) ? persona.email : reply.email, + sub: Conf['Remember Subject'] ? reply.sub : null + }; + $.set('QR.persona', persona); + _ref1 = h1.nextSibling.textContent.match(/thread:(\d+),no:(\d+)/), _ = _ref1[0], threadID = _ref1[1], postID = _ref1[2]; + $.event(new CustomEvent('QRPostSuccessful', { + threadID: threadID, + postID: postID + }, QR.el)); + QR.cooldown.set({ + post: reply, + isReply: threadID !== '0' + }); + QR.cooldown.auto = QR.replies.length > 1; + if (threadID === '0') { + $.open("/" + g.BOARD + "/res/" + postID); + } else if (g.VIEW === 'reply' && !QR.cooldown.auto) { + $.open("//boards.4chan.org/" + g.BOARD + "/res/" + threadID + "#p" + postID); + } + if (Conf['Persistent QR'] || QR.cooldown.auto) { + reply.rm(); + } else { + QR.close(); + } + QR.status(); + return QR.resetFileInput(); + }, + abort: function() { + var _ref; + if ((_ref = QR.ajax) != null) { + _ref.abort(); + } + delete QR.ajax; + return QR.status(); + } + }; + Board = (function() { Board.prototype.toString = function() { diff --git a/grunt.js b/grunt.js index c5c392104..03635bef3 100644 --- a/grunt.js +++ b/grunt.js @@ -20,6 +20,7 @@ module.exports = function(grunt) { '', '', '', + '', '' ], dest: 'tmp/script.coffee' diff --git a/src/features.coffee b/src/features.coffee index 2ea742d45..c217d3a02 100644 --- a/src/features.coffee +++ b/src/features.coffee @@ -760,803 +760,6 @@ Recursive = break return -QR = - init: -> - return if g.VIEW is 'catalog' or !Conf['Quick Reply'] - - 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', -> - Header.menu.close() - 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 - - $.on d, 'dragover', QR.dragOver - $.on d, 'drop', QR.dropFile - $.on d, 'dragstart dragend', QR.drag - $.on d, '4chanXInitFinished', -> - return unless Conf['Persistent QR'] - QR.open() - QR.hide() if Conf['Auto Hide QR'] - - Post::callbacks.push - name: 'Quick Reply' - cb: @node - - node: -> - $.on $('a[title="Quote this post"]', @nodes.info), 'click', QR.quote - - 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.cleanNotification() - 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() - - 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.lastNotification = new Notification 'warning', el - cleanNotification: -> - QR.lastNotification?.close() - delete QR.lastNotification - - status: (data={}) -> - return unless QR.el - if g.dead # XXX - 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: -> - QR.cooldown.types = - thread: switch g.BOARD - when 'q' then 86400 - when 'b', 'soc', 'r9k' then 600 - else 300 - sage: if g.BOARD is 'q' then 600 else 60 - file: if g.BOARD is 'q' then 300 else 30 - post: if g.BOARD is 'q' then 60 else 30 - QR.cooldown.cooldowns = $.get "#{g.BOARD}.cooldown", {} - QR.cooldown.start() - $.sync "#{g.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() - QR.open() - ta = $ 'textarea', QR.el - if QR.threadSelector and !ta.value and g.BOARD.ID isnt 'f' - QR.threadSelector.value = $.x('ancestor::div[parent::div[@class="board"]]', @).id[1..] - # Make sure we get the correct number, even with XXX censors - post = Get.postFromRoot $.x 'ancestor-or-self::div[contains(@class,"postContainer")][1]', @ - text = ">>#{post}\n" - - sel = d.getSelection() - selectionRoot = $.x 'ancestor-or-self::div[contains(@class,"postContainer")][1]', sel.anchorNode - 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" - - 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.cleanNotification() - # Set or change current reply's file. - if @files.length is 1 - file = @files[0] - if file.size > @max - QR.error 'File too large.' - QR.resetFileInput() - else if -1 is QR.mimeTypes.indexOf file.type - 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." - break - else if -1 is QR.mimeTypes.indexOf file.type - QR.error "#{file.name}: Unsupported file type." - break - 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 window.URL - return unless url = window.URL or window.webkitURL - 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 - (window.URL or window.webkitURL).revokeObjectURL? @url - select: -> - QR.selected?.el.id = null - 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 data in ['name', 'email', 'sub', 'com'] - $("[name=#{data}]", QR.el).value = @[data] - QR.characterCount.call $ 'textarea', QR.el - $('#spoiler', QR.el).checked = @spoiler - 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 - (window.URL or window.webkitURL)?.revokeObjectURL @url - - captcha: - init: -> - return if -1 isnt d.cookie.indexOf 'pass_enabled=' - 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 new CustomEvent 'QRDialogCreation', null, QR.el - - submit: (e) -> - e?.preventDefault() - if QR.cooldown.seconds - QR.cooldown.auto = !QR.cooldown.auto - QR.status() - return - QR.abort() - - reply = QR.replies[0] - 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 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.cleanNotification() - - # 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 $('input[name=pwd]').value - recaptcha_challenge_field: challenge - recaptcha_response_field: response - - callbacks = - onload: -> - QR.response @response - onerror: -> - # 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: 'Connection error, or you are banned.' - 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: (html) -> - tmpDoc = d.implementation.createHTMLDocument '' - tmpDoc.documentElement.innerHTML = html - 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.' - - 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.lastNotification = 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+)/ - - # Post/upload confirmed as successful. - $.event new CustomEvent 'QRPostSuccessful', { - threadID - postID - }, QR.el - - QR.cooldown.set - post: reply - isReply: threadID isnt '0' - - # Enable auto-posting if we have stuff to post, disable it otherwise. - QR.cooldown.auto = QR.replies.length > 1 - - if threadID is '0' # new thread - $.open "/#{g.BOARD}/res/#{postID}" - else if g.VIEW is 'reply' 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: -> - QR.ajax?.abort() - delete QR.ajax - QR.status() - Menu = init: -> return if g.VIEW is 'catalog' or !Conf['Menu'] diff --git a/src/qr.coffee b/src/qr.coffee new file mode 100644 index 000000000..1d04510b8 --- /dev/null +++ b/src/qr.coffee @@ -0,0 +1,796 @@ +QR = + init: -> + return if g.VIEW is 'catalog' or !Conf['Quick Reply'] + + 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', -> + Header.menu.close() + 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 + + $.on d, 'dragover', QR.dragOver + $.on d, 'drop', QR.dropFile + $.on d, 'dragstart dragend', QR.drag + $.on d, '4chanXInitFinished', -> + return unless Conf['Persistent QR'] + QR.open() + QR.hide() if Conf['Auto Hide QR'] + + Post::callbacks.push + name: 'Quick Reply' + cb: @node + + node: -> + $.on $('a[title="Quote this post"]', @nodes.info), 'click', QR.quote + + 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.cleanNotification() + 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() + + 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.lastNotification = new Notification 'warning', el + cleanNotification: -> + QR.lastNotification?.close() + delete QR.lastNotification + + status: (data={}) -> + return unless QR.el + if g.dead # XXX + 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: -> + QR.cooldown.types = + thread: switch g.BOARD + when 'q' then 86400 + when 'b', 'soc', 'r9k' then 600 + else 300 + sage: if g.BOARD is 'q' then 600 else 60 + file: if g.BOARD is 'q' then 300 else 30 + post: if g.BOARD is 'q' then 60 else 30 + QR.cooldown.cooldowns = $.get "#{g.BOARD}.cooldown", {} + QR.cooldown.start() + $.sync "#{g.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() + QR.open() + ta = $ 'textarea', QR.el + if QR.threadSelector and !ta.value and g.BOARD.ID isnt 'f' + QR.threadSelector.value = $.x('ancestor::div[parent::div[@class="board"]]', @).id[1..] + # Make sure we get the correct number, even with XXX censors + post = Get.postFromRoot $.x 'ancestor-or-self::div[contains(@class,"postContainer")][1]', @ + text = ">>#{post}\n" + + sel = d.getSelection() + selectionRoot = $.x 'ancestor-or-self::div[contains(@class,"postContainer")][1]', sel.anchorNode + 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" + + 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.cleanNotification() + # Set or change current reply's file. + if @files.length is 1 + file = @files[0] + if file.size > @max + QR.error 'File too large.' + QR.resetFileInput() + else if -1 is QR.mimeTypes.indexOf file.type + 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." + break + else if -1 is QR.mimeTypes.indexOf file.type + QR.error "#{file.name}: Unsupported file type." + break + 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 window.URL + return unless url = window.URL or window.webkitURL + 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 + (window.URL or window.webkitURL).revokeObjectURL? @url + select: -> + QR.selected?.el.id = null + 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 data in ['name', 'email', 'sub', 'com'] + $("[name=#{data}]", QR.el).value = @[data] + QR.characterCount.call $ 'textarea', QR.el + $('#spoiler', QR.el).checked = @spoiler + 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 + (window.URL or window.webkitURL)?.revokeObjectURL @url + + captcha: + init: -> + return if -1 isnt d.cookie.indexOf 'pass_enabled=' + 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 new CustomEvent 'QRDialogCreation', null, QR.el + + submit: (e) -> + e?.preventDefault() + if QR.cooldown.seconds + QR.cooldown.auto = !QR.cooldown.auto + QR.status() + return + QR.abort() + + reply = QR.replies[0] + 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 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.cleanNotification() + + # 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 $('input[name=pwd]').value + recaptcha_challenge_field: challenge + recaptcha_response_field: response + + callbacks = + onload: -> + QR.response @response + onerror: -> + # 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: 'Connection error, or you are banned.' + 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: (html) -> + tmpDoc = d.implementation.createHTMLDocument '' + tmpDoc.documentElement.innerHTML = html + 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.' + + 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.lastNotification = 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+)/ + + # Post/upload confirmed as successful. + $.event new CustomEvent 'QRPostSuccessful', { + threadID + postID + }, QR.el + + QR.cooldown.set + post: reply + isReply: threadID isnt '0' + + # Enable auto-posting if we have stuff to post, disable it otherwise. + QR.cooldown.auto = QR.replies.length > 1 + + if threadID is '0' # new thread + $.open "/#{g.BOARD}/res/#{postID}" + else if g.VIEW is 'reply' 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: -> + QR.ajax?.abort() + delete QR.ajax + QR.status()