import meta from '../../package.json' import Callbacks from '../classes/Callbacks' import Notice from '../classes/Notice' import Thread from '../classes/Thread' import BoardConfig from '../General/BoardConfig' import Get from '../General/Get' import Header from '../General/Header' import UI from '../General/UI' import { Conf, d, doc, E, g } from '../globals/globals' import Main from '../main/Main' import Menu from '../Menu/Menu' import Favicon from '../Monitoring/Favicon' import $ from '../platform/$' import $$ from '../platform/$$' import CrossOrigin from '../platform/CrossOrigin' import { DAY, dict, SECOND } from '../platform/helpers' import Captcha from './Captcha' import QuickReplyPage from './QR/QuickReply.html' const QR = { selected: null, mimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/vnd.adobe.flash.movie', 'application/x-shockwave-flash', 'video/webm'], validExtension: /\.(jpe?g|png|gif|pdf|swf|webm)$/i, typeFromExtension: { 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif', 'pdf': 'application/pdf', 'swf': 'application/vnd.adobe.flash.movie', 'webm': 'video/webm' }, extensionFromType: { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'application/pdf': 'pdf', 'application/vnd.adobe.flash.movie': 'swf', 'application/x-shockwave-flash': 'swf', 'video/webm': 'webm' }, init() { let sc if (!Conf['Quick Reply']) { return } this.posts = [] $.on(d, '4chanXInitFinished', () => BoardConfig.ready(QR.initReady)) Callbacks.Post.push({ name: 'Quick Reply', cb: this.node }) this.shortcut = (sc = $.el('a', { className: 'fa fa-comment-o disabled', textContent: 'QR', title: 'Quick Reply', href: 'javascript:;' } )) $.on(sc, 'click', function () { if (!QR.postingIsEnabled) { return } if (Conf['Persistent QR'] || !QR.nodes || QR.nodes.el.hidden) { QR.open() return QR.nodes.com.focus() } else { return QR.close() } }) return Header.addShortcut('qr', sc, 540) }, captcha: null, postingIsEnabled: false, nodes: null, posts: null, shortcut: null, link: '', min_width: 0, min_height: 0, max_width: 0, max_height: 0, max_size: 0, max_size_video: null, max_comment: null, max_width_video: null, max_height_video: null, max_duration_video: null, forcedAnon: null, spoiler: null, hasFocus: false, req: null, currentCaptcha: null, errorCount: 0, initReady() { let origToggle const captchaVersion = $('#g-recaptcha, #captcha-forced-noscript') ? 'v2' : 't' QR.captcha = Captcha[captchaVersion] QR.postingIsEnabled = true const { config } = g.BOARD const prop = (key, def) => +(config[key] ?? def) QR.min_width = prop('min_image_width', 1) QR.min_height = prop('min_image_height', 1) QR.max_width = (QR.max_height = 10000) QR.max_size = prop('max_filesize', 4194304) QR.max_size_video = prop('max_webm_filesize', QR.max_size) QR.max_comment = prop('max_comment_chars', 2000) QR.max_width_video = (QR.max_height_video = 2048) QR.max_duration_video = prop('max_webm_duration', 120) QR.forcedAnon = !!config.forced_anon QR.spoiler = !!config.spoilers if (origToggle = $.id('togglePostFormLink')) { const link = $.el('h1', { className: "qr-link-container" }) $.extend(link, { innerHTML: `${g.VIEW === "thread" ? "Reply to Thread" : "Start a Thread"}` }) QR.link = link.firstElementChild $.on(link.firstChild, 'click', function () { QR.open() return QR.nodes.com.focus() }) $.before(origToggle, link) origToggle.firstElementChild.textContent = 'Original Form' } if (g.VIEW === 'thread') { let navLinksBot const linkBot = $.el('div', { className: "brackets-wrap qr-link-container-bottom" }) $.extend(linkBot, { innerHTML: 'Reply to Thread' }) $.on(linkBot.firstElementChild, 'click', function () { QR.open() return QR.nodes.com.focus() }) if (navLinksBot = $('.navLinksBot')) { $.prepend(navLinksBot, linkBot) } } $.on(d, 'QRGetFile', QR.getFile) $.on(d, 'QRDrawFile', QR.drawFile) $.on(d, 'QRSetFile', QR.setFile) $.on(d, 'paste', QR.paste) $.on(d, 'dragover', QR.dragOver) $.on(d, 'drop', QR.dropFile) $.on(d, 'dragstart dragend', QR.drag) $.on(d, 'IndexRefreshInternal', QR.generatePostableThreadsList) $.on(d, 'ThreadUpdate', QR.statusCheck) if (!Conf['Persistent QR']) { return } QR.open() if (Conf['Auto Hide QR']) { return QR.hide() } }, statusCheck() { if (!QR.nodes) { return } const { thread } = QR.posts[0] if ((thread !== 'new') && g.threads.get(`${g.BOARD}.${thread}`).isDead) { return QR.abort() } else { return QR.status() } }, node() { $.on(this.nodes.quote, 'click', QR.quote) if (this.isFetchedQuote) { return QR.generatePostableThreadsList() } }, open() { if (QR.nodes) { if (QR.nodes.el.hidden) { QR.captcha.setup() } QR.nodes.el.hidden = false QR.unhide() } else { try { QR.dialog() } catch (err) { delete QR.nodes Main.handleErrors({ message: 'Quick Reply dialog creation crashed.', error: err }) return } } return $.rmClass(QR.shortcut, 'disabled') }, close() { if (QR.req) { QR.abort() return } QR.nodes.el.hidden = true QR.cleanNotifications() QR.blur() $.rmClass(QR.nodes.el, 'dump') $.addClass(QR.shortcut, 'disabled') new QR.post(true) for (const post of QR.posts.splice(0, QR.posts.length - 1)) { post.delete() } QR.cooldown.auto = false QR.status() return QR.captcha.destroy() }, focus() { return $.queueTask(function () { if (!QR.inBubble()) { QR.hasFocus = d.activeElement && QR.nodes.el.contains(d.activeElement) return QR.nodes.el.classList.toggle('focus', QR.hasFocus) } }) }, inBubble() { const bubbles = $$('iframe[src^="https://www.google.com/recaptcha/api2/frame"]') return bubbles.includes(d.activeElement) || bubbles.some(el => (getComputedStyle(el).visibility !== 'hidden') && (el.getBoundingClientRect().bottom > 0)) }, hide() { QR.blur() $.addClass(QR.nodes.el, 'autohide') return QR.nodes.autohide.checked = true }, unhide() { $.rmClass(QR.nodes.el, 'autohide') return QR.nodes.autohide.checked = false }, toggleHide() { if (this.checked) { return QR.hide() } else { return QR.unhide() } }, blur() { if (QR.nodes.el.contains(d.activeElement) && d.activeElement instanceof HTMLElement) { d.activeElement.blur() } }, toggleSJIS(e) { e.preventDefault() Conf['sjisPreview'] = !Conf['sjisPreview'] $.set('sjisPreview', Conf['sjisPreview']) return QR.nodes.el.classList.toggle('sjis-preview', Conf['sjisPreview']) }, texPreviewShow() { if ($.hasClass(QR.nodes.el, 'tex-preview')) { return QR.texPreviewHide() } $.addClass(QR.nodes.el, 'tex-preview') QR.nodes.texPreview.textContent = QR.nodes.com.value return $.event('mathjax', null, QR.nodes.texPreview) }, texPreviewHide() { return $.rmClass(QR.nodes.el, 'tex-preview') }, addPost() { const wasOpen = (QR.nodes && !QR.nodes.el.hidden) QR.open() if (wasOpen) { $.addClass(QR.nodes.el, 'dump') new QR.post(true) } return QR.nodes.com.focus() }, setCustomCooldown(enabled) { Conf['customCooldownEnabled'] = enabled QR.cooldown.customCooldown = enabled return QR.nodes.customCooldown.classList.toggle('disabled', !enabled) }, toggleCustomCooldown() { const enabled = $.hasClass(QR.nodes.customCooldown, 'disabled') QR.setCustomCooldown(enabled) return $.set('customCooldownEnabled', enabled) }, error(err, focusOverride) { let el QR.open() if (typeof err === 'string') { el = $.tn(err) } else { el = err el.removeAttribute('style') } const notice = new Notice('warning', el) QR.notifications.push(notice) if (!Header.areNotificationsEnabled) { if (d.hidden && !QR.cooldown.auto) { return alert(el.textContent) } } else if (d.hidden || !(focusOverride || d.hasFocus())) { const notif = new Notification(el.textContent, { body: el.textContent, icon: Favicon.logo } ) notif.onclick = () => window.focus() if ($.engine !== 'gecko') { // Firefox automatically closes notifications // so we can't control the onclose properly. notif.onclose = () => notice.close() return notif.onshow = () => setTimeout(function () { notif.onclose = null return notif.close() } , 7 * SECOND) } } }, connectionError() { return $.el('span', { innerHTML: 'Connection error while posting. ' + '[More info]' } ) }, notifications: [], cleanNotifications() { for (const notification of QR.notifications) { notification.close() } return QR.notifications = [] }, status() { let disabled, value if (!QR.nodes) { return } const { thread } = QR.posts[0] if ((thread !== 'new') && g.threads.get(`${g.BOARD}.${thread}`).isDead) { value = 'Dead' disabled = true QR.cooldown.auto = false } value = QR.req ? QR.req.progress : QR.cooldown.seconds || value const { status } = QR.nodes status.value = !value ? 'Submit' : QR.cooldown.auto ? `Auto ${value}` : value return status.disabled = disabled || false }, openPost() { QR.open() if (QR.selected.isLocked) { const index = QR.posts.indexOf(QR.selected); (QR.posts[index + 1] || new QR.post(QR.selected)).select() $.addClass(QR.nodes.el, 'dump') return QR.cooldown.auto = true } }, quote(e) { let range e?.preventDefault() if (!QR.postingIsEnabled) { return } const sel = d.getSelection() const post = Get.postFromNode(this) const { root } = post.nodes const postRange = new Range() postRange.selectNode(root) let text = post.board.ID === g.BOARD.ID ? `>>${post}\n` : `>>>/${post.board}/${post}\n` for (let i = 0, end = sel.rangeCount, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) { try { let insideCode, node range = sel.getRangeAt(i) // Trim range to be fully inside post if (range.compareBoundaryPoints(Range.START_TO_START, postRange) < 0) { range.setStartBefore(root) } if (range.compareBoundaryPoints(Range.END_TO_END, postRange) > 0) { range.setEndAfter(root) } if (!range.toString().trim()) { continue } const frag = range.cloneContents() const ancestor = range.commonAncestorContainer // Quoting the insides of a spoiler/code tag. if ($.x('ancestor-or-self::*[self::s or contains(@class,"removed-spoiler")]', ancestor)) { $.prepend(frag, $.tn('[spoiler]')) $.add(frag, $.tn('[/spoiler]')) } if (insideCode = $.x('ancestor-or-self::pre[contains(@class,"prettyprint")]', ancestor)) { $.prepend(frag, $.tn('[code]')) $.add(frag, $.tn('[/code]')) } for (node of $$((insideCode ? 'br' : '.prettyprint br'), frag)) { $.replace(node, $.tn('\n')) } for (node of $$('br', frag)) { if (node !== frag.lastChild) { $.replace(node, $.tn('\n>')) } } g.SITE.insertTags?.() for (node of $$('.linkify[data-original]', frag)) { $.replace(node, $.tn(node.dataset.original)) } for (node of $$('.embedder', frag)) { if (node.previousSibling?.nodeValue === ' ') { $.rm(node.previousSibling) } $.rm(node) } text += `>${frag.textContent.trim()}\n` } catch (error) { /* empty */ } } QR.openPost() const { com, thread } = QR.nodes if (!com.value) { thread.value = Get.threadFromNode(this) } const wasOnlyQuotes = QR.selected.isOnlyQuotes() const caretPos = com.selectionStart // Replace selection for text. com.value = com.value.slice(0, caretPos) + text + com.value.slice(com.selectionEnd) // Move the caret to the end of the new quote. range = caretPos + text.length com.setSelectionRange(range, range) com.focus() // This allows us to determine if any text other than quotes has been typed. if (wasOnlyQuotes) { QR.selected.quotedText = com.value } QR.selected.save(com) return QR.selected.save(thread) }, characterCount() { const counter = QR.nodes.charCount const count = QR.nodes.com.value.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, '_').length counter.textContent = count counter.hidden = count < (QR.max_comment / 2) return (count > QR.max_comment ? $.addClass : $.rmClass)(counter, 'warning') }, getFile() { return $.event('QRFile', QR.selected?.file) }, drawFile(e) { const file = QR.selected?.file if (!file || !/^(image|video)\//.test(file.type)) { return } const isVideo = /^video\//.test(file) const el = $.el((isVideo ? 'video' : 'img'), { src: '' }) $.on(el, 'error', () => QR.openError()) $.on(el, (isVideo ? 'loadeddata' : 'load'), function () { e.target.getContext('2d').drawImage(el, 0, 0) URL.revokeObjectURL(el.src) return $.event('QRImageDrawn', null, e.target) }) return el.src = URL.createObjectURL(file) }, openError() { const div = $.el('div', { className: 'error' }) $.extend(div, { innerHTML: 'Could not open file. [More info]' }) return QR.error(div, 5000) }, setFile(e) { const { file, name, source } = e.detail if (name != null) { file.name = name } if (source != null) { file.source = source } QR.open() return QR.handleFiles([file]) }, drag(e) { // Let it drag anything from the page. const toggle = e.type === 'dragstart' ? $.off : $.on toggle(d, 'dragover', QR.dragOver) return toggle(d, 'drop', QR.dropFile) }, dragOver(e) { e.preventDefault() return e.dataTransfer.dropEffect = 'copy' }, // cursor feedback dropFile(e) { // Let it only handle files from the desktop. if (!e.dataTransfer.files.length) { return } e.preventDefault() QR.open() return QR.handleFiles(e.dataTransfer.files) }, paste(e) { if (!e.clipboardData.items) { return } let file = null let score = -1 for (const item of e.clipboardData.items) { let file2 if ((item.kind === 'file') && (file2 = item.getAsFile())) { const score2 = (file2.size * 100) / (d.body.clientWidth * d.body.clientHeight) if (score2 > score) { file = file2 score = score2 } } } if (file) { const { type } = file const blob = new Blob([file], { type }) file.name = blob.name QR.open() QR.handleFiles([blob]) $.addClass(QR.nodes.el, 'dump') } }, pasteFF() { const { pasteArea } = QR.nodes if (!pasteArea.childNodes.length) { return } const images = $$('img', pasteArea) $.rmAll(pasteArea) for (const img of images) { let m const { src } = img if (m = src.match(/data:(image\/(\w+));base64,(.+)/)) { const bstr = atob(m[3]) const arr = new Uint8Array(bstr.length) for (let i = 0, end = bstr.length, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) { arr[i] = bstr.charCodeAt(i) } const blob = new Blob([arr], { type: m[1] }) blob.name = `${Conf['pastedname']}.${m[2]}` QR.handleFiles([blob]) } else if (/^https?:\/\//.test(src)) { QR.handleUrl(src) } } }, handleUrl(urlDefault) { QR.open() QR.selected.preventAutoPost() return CrossOrigin.permission(function () { const url = prompt('Enter a URL:', urlDefault) if (url === null) { return } QR.nodes.fileButton.focus() return CrossOrigin.file(url, function (blob) { if (blob && !/^text\//.test(blob.type)) { return QR.handleFiles([blob]) } else { return QR.error("Can't load file.", 5000) } }) }, urlDefault, true) }, handleFiles(files) { if (this !== QR) { // file input files = [...Array.from(this.files)] this.value = null } if (!files.length) { return } QR.cleanNotifications() for (const file of files) { QR.handleFile(file, files.length) } if (files.length !== 1) { $.addClass(QR.nodes.el, 'dump') } if ((d.activeElement === QR.nodes.fileButton) && $.hasClass(QR.nodes.fileSubmit, 'has-file')) { return QR.nodes.filename.focus() } }, handleFile(file, nfiles) { let post const isText = /^text\//.test(file.type) if (nfiles === 1) { post = QR.selected } else { post = QR.posts[QR.posts.length - 1] if (isText ? post.com || post.pasting : post.file) { post = new QR.post(QR.selected) } } return post[isText ? 'pasteText' : 'setFile'](file) }, openFileInput() { if (QR.nodes.fileButton.disabled) { return } QR.nodes.fileInput.click() return QR.nodes.fileButton.focus() }, generatePostableThreadsList() { if (!QR.nodes) { return } const list = QR.nodes.thread const options = [list.firstElementChild] for (const thread of g.BOARD.threads.keys) { options.push($.el('option', { value: thread, textContent: `Thread ${thread}` } ) ) } const val = list.value $.rmAll(list) $.add(list, options) list.value = val if (list.value === val) { return } // Fix the value if the option disappeared. list.value = g.VIEW === 'thread' ? g.THREADID : 'new' return (g.VIEW === 'thread' ? $.addClass : $.rmClass)(QR.nodes.el, 'reply-to-thread') }, dialog() { let dialog, event, nodes let name QR.nodes = (nodes = { el: (dialog = UI.dialog('qr', { innerHTML: QuickReplyPage })) }) const setNode = (name, query) => nodes[name] = $(query, dialog) setNode('move', '.move') setNode('autohide', '#autohide') setNode('close', '.close') setNode('thread', 'select') setNode('form', 'form') setNode('sjisToggle', '#sjis-toggle') setNode('texButton', '#tex-preview-button') setNode('name', '[data-name=name]') setNode('email', '[data-name=email]') setNode('sub', '[data-name=sub]') setNode('com', '[data-name=com]') setNode('charCount', '#char-count') setNode('texPreview', '#tex-preview') setNode('dumpList', '#dump-list') setNode('addPost', '#add-post') setNode('oekaki', '.oekaki') setNode('drawButton', '#qr-draw-button') setNode('fileSubmit', '#file-n-submit') setNode('fileButton', '#qr-file-button') setNode('noFile', '#qr-no-file') setNode('filename', '#qr-filename') setNode('spoiler', '#qr-file-spoiler') setNode('oekakiButton', '#qr-oekaki-button') setNode('fileRM', '#qr-filerm') setNode('urlButton', '#url-button') setNode('pasteArea', '#paste-area') setNode('customCooldown', '#custom-cooldown-button') setNode('dumpButton', '#dump-button') setNode('status', '[type=submit]') setNode('flashTag', '[name=filetag]') setNode('fileInput', '[type=file]') const { config } = g.BOARD const { classList } = QR.nodes.el classList.toggle('forced-anon', QR.forcedAnon) classList.toggle('has-spoiler', QR.spoiler) classList.toggle('has-sjis', !!config.sjis_tags) classList.toggle('has-math', !!config.math_tags) classList.toggle('sjis-preview', !!config.sjis_tags && Conf['sjisPreview']) classList.toggle('show-new-thread-option', Conf['Show New Thread Option in Threads']) if (parseInt(Conf['customCooldown'], 10) > 0) { $.addClass(QR.nodes.fileSubmit, 'custom-cooldown') $.get('customCooldownEnabled', Conf['customCooldownEnabled'], function ({ customCooldownEnabled }) { QR.setCustomCooldown(customCooldownEnabled) return $.sync('customCooldownEnabled', QR.setCustomCooldown) }) } QR.flagsInput() $.on(nodes.autohide, 'change', QR.toggleHide) $.on(nodes.close, 'click', QR.close) $.on(nodes.status, 'click', QR.submit) $.on(nodes.form, 'submit', QR.submit) $.on(nodes.sjisToggle, 'click', QR.toggleSJIS) $.on(nodes.texButton, 'mousedown', QR.texPreviewShow) $.on(nodes.texButton, 'mouseup', QR.texPreviewHide) $.on(nodes.addPost, 'click', () => new QR.post(true)) $.on(nodes.drawButton, 'click', QR.oekaki.draw) $.on(nodes.fileButton, 'click', QR.openFileInput) $.on(nodes.noFile, 'click', QR.openFileInput) $.on(nodes.filename, 'focus', function () { return $.addClass(this.parentNode, 'focus') }) $.on(nodes.filename, 'blur', function () { return $.rmClass(this.parentNode, 'focus') }) $.on(nodes.spoiler, 'change', () => QR.selected.nodes.spoiler.click()) $.on(nodes.oekakiButton, 'click', QR.oekaki.button) $.on(nodes.fileRM, 'click', () => QR.selected.rmFile()) $.on(nodes.urlButton, 'click', () => QR.handleUrl('')) $.on(nodes.customCooldown, 'click', QR.toggleCustomCooldown) $.on(nodes.dumpButton, 'click', () => nodes.el.classList.toggle('dump')) $.on(nodes.fileInput, 'change', QR.handleFiles) window.addEventListener('focus', QR.focus, true) window.addEventListener('blur', QR.focus, true) // We don't receive blur events from captcha iframe. $.on(d, 'click', QR.focus) // XXX Workaround for image pasting in Firefox, obsolete as of v50. // https://bugzilla.mozilla.org/show_bug.cgi?id=906420 if (($.engine === 'gecko') && !window.DataTransferItemList) { nodes.pasteArea.hidden = false } new MutationObserver(QR.pasteFF).observe(nodes.pasteArea, { childList: true }) // save selected post's data const items = ['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag'] let i = 0 const save = function () { return QR.selected.save(this) } while ((name = items[i++])) { let node if (!(node = nodes[name])) { continue } event = node.nodeName === 'SELECT' ? 'change' : 'input' $.on(nodes[name], event, save) } // XXX Blink and WebKit treat width and height of