mirror of
https://github.com/LalleSX/4chan-XZ.git
synced 2025-10-07 07:22:37 +02:00
2216 lines
68 KiB
TypeScript
2216 lines
68 KiB
TypeScript
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:
|
|
`<a href="javascript:;" class="qr-link">${g.VIEW === "thread" ? "Reply to Thread" : "Start a Thread"}</a>`
|
|
})
|
|
|
|
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: '<a href="javascript:;" class="qr-link-bottom">Reply to Thread</a>' })
|
|
|
|
$.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. ' +
|
|
'[<a href="' + meta.faq + '#connection-errors" target="_blank">More info</a>]'
|
|
}
|
|
)
|
|
},
|
|
|
|
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. [<a href="' + E(meta.faq) + '#error-reading-metadata" target="_blank">More info</a>]'
|
|
})
|
|
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 <textarea>s as min-width and min-height
|
|
if (($.engine === 'gecko') && Conf['Remember QR Size']) {
|
|
$.get('QR Size', '', item => nodes.com.style.cssText = item['QR Size'])
|
|
$.on(nodes.com, 'mouseup', function (e) {
|
|
if (e.button !== 0) { return }
|
|
return $.set('QR Size', this.style.cssText)
|
|
})
|
|
}
|
|
|
|
QR.generatePostableThreadsList()
|
|
QR.persona.load()
|
|
new QR.post(true)
|
|
QR.status()
|
|
QR.cooldown.setup()
|
|
QR.captcha.init()
|
|
|
|
$.add(d.body, dialog)
|
|
QR.captcha.setup()
|
|
QR.oekaki.setup()
|
|
|
|
// Create a custom event when the QR dialog is first initialized.
|
|
// Use it to extend the QR's functionalities, or for XTRM RICE.
|
|
return $.event('QRDialogCreation', null, dialog)
|
|
},
|
|
|
|
flags() {
|
|
const select = $.el('select', {
|
|
name: 'flag',
|
|
className: 'flagSelector'
|
|
}
|
|
)
|
|
|
|
const addFlag = (value, textContent) => $.add(select, $.el('option', { value, textContent }))
|
|
|
|
addFlag('0', (g.BOARD.config.country_flags ? 'Geographic Location' : 'None'))
|
|
for (const value in g.BOARD.config.board_flags) {
|
|
const textContent = g.BOARD.config.board_flags[value]
|
|
addFlag(value, textContent)
|
|
}
|
|
|
|
return select
|
|
},
|
|
|
|
flagsInput() {
|
|
const { nodes } = QR
|
|
if (!nodes) { return }
|
|
if (nodes.flag) {
|
|
$.rm(nodes.flag)
|
|
delete nodes.flag
|
|
}
|
|
|
|
if (g.BOARD.config.board_flags) {
|
|
const flag = QR.flags()
|
|
flag.dataset.name = 'flag'
|
|
flag.dataset.default = '0'
|
|
nodes.flag = flag
|
|
return $.add(nodes.form, flag)
|
|
}
|
|
},
|
|
|
|
submit(e?: KeyboardEvent) {
|
|
let captcha, err, filetag
|
|
e?.preventDefault()
|
|
const force = e?.shiftKey
|
|
|
|
if (QR.req) {
|
|
QR.abort()
|
|
return
|
|
}
|
|
|
|
$.forceSync('cooldowns', QR.cooldown.sync)
|
|
if (QR.cooldown.seconds) {
|
|
if (force) {
|
|
QR.cooldown.clear()
|
|
} else {
|
|
QR.cooldown.auto = !QR.cooldown.auto
|
|
QR.status()
|
|
return
|
|
}
|
|
}
|
|
|
|
const post = QR.posts[0]
|
|
delete post.quotedText
|
|
post.forceSave()
|
|
let threadID = post.thread
|
|
const thread = g.BOARD.threads.get(threadID)
|
|
if ((g.BOARD.ID === 'f') && (threadID === 'new')) {
|
|
filetag = QR.nodes.flashTag.value
|
|
}
|
|
|
|
// prevent errors
|
|
if (threadID === 'new') {
|
|
threadID = null
|
|
if (!!g.BOARD.config.require_subject && !post.sub) {
|
|
err = 'New threads require a subject.'
|
|
} else if (!g.BOARD.config.text_only && !post.file) {
|
|
err = 'No file selected.'
|
|
}
|
|
} else if (g.BOARD.threads.get(threadID).isClosed) {
|
|
err = 'You can\'t reply to this thread anymore.'
|
|
} else if (!post.com && !post.file) {
|
|
err = 'No comment or file.'
|
|
} else if (post.file && thread.fileLimit) {
|
|
err = 'Max limit of image replies has been reached.'
|
|
}
|
|
|
|
if ((g.BOARD.ID === 'r9k') && !post.com?.match(/[a-z-]/i)) {
|
|
if (!err) { err = 'Original comment required.' }
|
|
}
|
|
|
|
if (QR.captcha.isEnabled && !((QR.captcha === Captcha.v2) && /\b_ct=/.test(d.cookie) && threadID) && !(err && !force)) {
|
|
captcha = QR.captcha.getOne(!!threadID)
|
|
if (QR.captcha === Captcha.v2) {
|
|
if (!captcha) { captcha = Captcha.cache.request(!!threadID) }
|
|
}
|
|
if (!captcha) {
|
|
err = 'No valid captcha.'
|
|
QR.captcha.setup(!QR.cooldown.auto || (d.activeElement === QR.nodes.status))
|
|
}
|
|
}
|
|
|
|
QR.cleanNotifications()
|
|
if (err && !force) {
|
|
// stop auto-posting
|
|
QR.cooldown.auto = false
|
|
QR.status()
|
|
QR.error(err, 1)
|
|
return
|
|
}
|
|
|
|
// Enable auto-posting if we have stuff to post, disable it otherwise.
|
|
QR.cooldown.auto = QR.posts.length > 1
|
|
|
|
post.lock()
|
|
|
|
const formData = {
|
|
MAX_FILE_SIZE: QR.max_size,
|
|
mode: 'regist',
|
|
pwd: QR.persona.getPassword(),
|
|
resto: threadID,
|
|
name: (!QR.forcedAnon ? post.name : undefined),
|
|
email: post.email,
|
|
sub: (!QR.forcedAnon && !threadID ? post.sub : undefined),
|
|
com: post.com,
|
|
upfile: post.file,
|
|
filetag,
|
|
spoiler: post.spoiler,
|
|
flag: post.flag,
|
|
}
|
|
|
|
const options = {
|
|
responseType: 'document',
|
|
withCredentials: true,
|
|
onloadend: QR.response,
|
|
form: $.formData(formData)
|
|
}
|
|
if (Conf['Show Upload Progress']) {
|
|
options.onprogress = function (e) {
|
|
if (this !== QR.req?.upload) { return } // aborted
|
|
if (e.loaded < e.total) {
|
|
// Uploading...
|
|
QR.req.progress = `${Math.round((e.loaded / e.total) * 100)}%`
|
|
} else {
|
|
// Upload done, waiting for server response.
|
|
QR.req.isUploadFinished = true
|
|
QR.req.progress = '...'
|
|
}
|
|
return QR.status()
|
|
}
|
|
}
|
|
|
|
let cb = function (response) {
|
|
const cb = null
|
|
if (response != null) {
|
|
QR.currentCaptcha = response
|
|
if (QR.captcha === Captcha.v2) {
|
|
if (response.challenge != null) {
|
|
options.form.append('recaptcha_challenge_field', response.challenge)
|
|
options.form.append('recaptcha_response_field', response.response)
|
|
} else {
|
|
options.form.append('g-recaptcha-response', response.response)
|
|
}
|
|
} else {
|
|
for (const key in response) {
|
|
const val = response[key]
|
|
options.form.append(key, val)
|
|
}
|
|
}
|
|
}
|
|
QR.req = $.ajax(`https://sys.${location.hostname.split('.')[1]}.org/${g.BOARD}/post`, options, cb)
|
|
return QR.req.progress = '...'
|
|
}
|
|
|
|
if (typeof captcha === 'function') {
|
|
// Wait for captcha to be verified before submitting post.
|
|
QR.req = {
|
|
progress: '...',
|
|
abort() {
|
|
if (QR.captcha === Captcha.v2) {
|
|
Captcha.cache.abort()
|
|
}
|
|
return cb = null
|
|
}
|
|
}
|
|
captcha(function (response) {
|
|
if ((QR.captcha === Captcha.v2) && Captcha.cache.haveCookie()) {
|
|
cb(response)
|
|
if (response) { return Captcha.cache.save(response) }
|
|
} else if (response) {
|
|
return cb?.(response)
|
|
} else {
|
|
delete QR.req
|
|
post.unlock()
|
|
QR.cooldown.auto = !!Captcha.cache.getCount()
|
|
return QR.status()
|
|
}
|
|
})
|
|
} else {
|
|
cb(captcha)
|
|
}
|
|
|
|
// Starting to upload might take some time.
|
|
// Provide some feedback that we're starting to submit.
|
|
return QR.status()
|
|
},
|
|
|
|
response() {
|
|
let connErr, err
|
|
if (this !== QR.req) { return } // aborted
|
|
delete QR.req
|
|
|
|
const post = QR.posts[0]
|
|
post.unlock()
|
|
|
|
if (err = this.response?.getElementById('errmsg')) { // error!
|
|
const el = $('a', err)
|
|
if (el) el.target = '_blank' // duplicate image link
|
|
} else if (connErr = (!this.response || (this.response.title !== 'Post successful!'))) {
|
|
err = QR.connectionError()
|
|
if ((QR.captcha === Captcha.v2) && QR.currentCaptcha) { Captcha.cache.save(QR.currentCaptcha) }
|
|
} else if (this.status !== 200) {
|
|
err = `Error ${this.statusText} (${this.status})`
|
|
}
|
|
|
|
if (!connErr) { QR.captcha.setUsed?.() }
|
|
delete QR.currentCaptcha
|
|
|
|
if (err) {
|
|
let m
|
|
QR.errorCount = (QR.errorCount || 0) + 1
|
|
if (/captcha|verification/i.test(err.textContent) || connErr) {
|
|
// Remove the obnoxious 4chan Pass ad.
|
|
if (/mistyped/i.test(err.textContent)) {
|
|
err = 'You mistyped the CAPTCHA, or the CAPTCHA malfunctioned.'
|
|
} else if (/expired/i.test(err.textContent)) {
|
|
err = 'This CAPTCHA is no longer valid because it has expired.'
|
|
}
|
|
if (QR.errorCount >= 5) {
|
|
// Too many posting errors can ban you. Stop autoposting after 5 errors.
|
|
QR.cooldown.auto = false
|
|
} else {
|
|
// Something must've gone terribly wrong if you get captcha errors without captchas.
|
|
// Don't auto-post indefinitely in that case.
|
|
QR.cooldown.auto = QR.captcha.isEnabled || connErr
|
|
// Too many frequent mistyped captchas will auto-ban you!
|
|
// On connection error, the post most likely didn't go through.
|
|
// If the post did go through, it should be stopped by the duplicate reply cooldown.
|
|
QR.cooldown.addDelay(post, 2)
|
|
}
|
|
} else if (err.textContent && (m = err.textContent.match(/\d+\s+(?:minute|second)/gi)) && !/duplicate|hour/i.test(err.textContent)) {
|
|
QR.cooldown.auto = !/have\s+been\s+muted/i.test(err.textContent)
|
|
let seconds = 0
|
|
for (const mi of m) {
|
|
seconds += (/minute/i.test(mi) ? 60 : 1) * (+mi.match(/\d+/)[0])
|
|
}
|
|
if (/muted/i.test(err.textContent)) {
|
|
QR.cooldown.addMute(seconds)
|
|
} else {
|
|
QR.cooldown.addDelay(post, seconds)
|
|
}
|
|
} else { // stop auto-posting
|
|
QR.cooldown.auto = false
|
|
}
|
|
QR.captcha.setup(QR.cooldown.auto && [QR.nodes.status, d.body].includes(d.activeElement))
|
|
QR.status()
|
|
QR.error(err, post)
|
|
return
|
|
}
|
|
|
|
delete QR.errorCount
|
|
|
|
const h1 = $('h1', this.response)
|
|
|
|
let [threadID, postID] = Array.from(h1.nextSibling.textContent.match(/thread:(\d+),no:(\d+)/))
|
|
postID = +postID
|
|
threadID = +threadID || postID
|
|
const isReply = threadID !== postID
|
|
|
|
// Post/upload confirmed as successful.
|
|
$.event('QRPostSuccessful', {
|
|
boardID: g.BOARD.ID,
|
|
threadID,
|
|
postID
|
|
})
|
|
// XXX deprecated
|
|
$.event('QRPostSuccessful_', { boardID: g.BOARD.ID, threadID, postID })
|
|
|
|
// Enable auto-posting if we have stuff left to post, disable it otherwise.
|
|
const postsCount = QR.posts.length - 1
|
|
QR.cooldown.auto = postsCount && isReply
|
|
|
|
const lastPostToThread = !((function () { for (const p of QR.posts.slice(1)) { if (p.thread === post.thread) { return true } } })())
|
|
|
|
if (postsCount) {
|
|
post.rm()
|
|
QR.captcha.setup(d.activeElement === QR.nodes.status)
|
|
} else if (Conf['Persistent QR']) {
|
|
post.rm()
|
|
if (Conf['Auto Hide QR']) {
|
|
QR.hide()
|
|
} else {
|
|
QR.blur()
|
|
}
|
|
} else {
|
|
QR.close()
|
|
}
|
|
|
|
QR.cleanNotifications()
|
|
if (Conf['Posting Success Notifications']) {
|
|
QR.notifications.push(new Notice('success', h1.textContent, 5))
|
|
}
|
|
|
|
QR.cooldown.add(threadID, postID)
|
|
|
|
const URL = threadID === postID ? ( // new thread
|
|
`${window.location.origin}/${g.BOARD}/thread/${threadID}`
|
|
) : (threadID !== g.THREADID) && lastPostToThread && Conf['Open Post in New Tab'] ? ( // replying from the index or a different thread
|
|
`${window.location.origin}/${g.BOARD}/thread/${threadID}#p${postID}`
|
|
) : undefined
|
|
|
|
if (URL) {
|
|
const open = Conf['Open Post in New Tab'] || postsCount ?
|
|
() => $.open(URL)
|
|
:
|
|
() => location.href = URL
|
|
|
|
if (threadID === postID) {
|
|
// XXX 4chan sometimes responds before the thread exists.
|
|
QR.waitForThread(URL, open)
|
|
} else {
|
|
open()
|
|
}
|
|
}
|
|
|
|
return QR.status()
|
|
},
|
|
|
|
waitForThread(url, cb) {
|
|
let attempts = 0
|
|
const check = function () {
|
|
return $.ajax(url, {
|
|
onloadend() {
|
|
attempts++
|
|
if ((attempts >= 6) || (this.status === 200)) {
|
|
return cb()
|
|
} else {
|
|
return setTimeout(check, attempts * SECOND)
|
|
}
|
|
},
|
|
responseType: 'text',
|
|
type: 'HEAD'
|
|
}, cb)
|
|
}
|
|
return check()
|
|
},
|
|
|
|
abort() {
|
|
let oldReq
|
|
if ((oldReq = QR.req) && !QR.req.isUploadFinished) {
|
|
delete QR.req
|
|
oldReq.abort()
|
|
if ((QR.captcha === Captcha.v2) && QR.currentCaptcha) { Captcha.cache.save(QR.currentCaptcha) }
|
|
delete QR.currentCaptcha
|
|
QR.posts[0].unlock()
|
|
QR.cooldown.auto = false
|
|
QR.notifications.push(new Notice('info', 'QR upload aborted.', 5))
|
|
}
|
|
return QR.status()
|
|
},
|
|
|
|
cooldown: {
|
|
timeout: null,
|
|
isCounting: true || false,
|
|
seconds: 0,
|
|
isSetup: false,
|
|
auto: false,
|
|
maxDelay: 0,
|
|
data: null,
|
|
changes: null,
|
|
customCooldown: null,
|
|
|
|
delays: {
|
|
deletion: 60
|
|
}, // cooldown for deleting posts/files
|
|
|
|
// Called from Main
|
|
init() {
|
|
if (!Conf['Quick Reply']) { return }
|
|
this.data = Conf['cooldowns']
|
|
this.changes = dict()
|
|
return $.sync('cooldowns', this.sync)
|
|
},
|
|
|
|
// Called from QR
|
|
setup() {
|
|
// Read cooldown times
|
|
$.extend(QR.cooldown.delays, g.BOARD.cooldowns())
|
|
|
|
// The longest reply cooldown, for use in pruning old reply data
|
|
QR.cooldown.maxDelay = 0
|
|
for (const type in QR.cooldown.delays) {
|
|
const delay = QR.cooldown.delays[type]
|
|
if (!['thread', 'thread_global'].includes(type)) {
|
|
QR.cooldown.maxDelay = Math.max(QR.cooldown.maxDelay, delay)
|
|
}
|
|
}
|
|
|
|
QR.cooldown.isSetup = true
|
|
return QR.cooldown.start()
|
|
},
|
|
|
|
start() {
|
|
const { data } = QR.cooldown
|
|
if (
|
|
!Conf['Cooldown'] ||
|
|
!QR.cooldown.isSetup ||
|
|
!!QR.cooldown.isCounting ||
|
|
((Object.keys(data[g.BOARD.ID] || {}).length + Object.keys(data.global || {}).length) <= 0)
|
|
) { return }
|
|
QR.cooldown.isCounting = true
|
|
return QR.cooldown.count()
|
|
},
|
|
|
|
sync(data) {
|
|
QR.cooldown.data = data || dict()
|
|
return QR.cooldown.start()
|
|
},
|
|
|
|
add(threadID, postID) {
|
|
if (!Conf['Cooldown']) { return }
|
|
const start = Date.now()
|
|
const boardID = g.BOARD.ID
|
|
QR.cooldown.set(boardID, start, { threadID, postID })
|
|
if (threadID === postID) { QR.cooldown.set('global', start, { boardID, threadID, postID }) }
|
|
QR.cooldown.save()
|
|
return QR.cooldown.start()
|
|
},
|
|
|
|
addDelay(post, delay) {
|
|
if (!Conf['Cooldown']) { return }
|
|
const cooldown = QR.cooldown.categorize(post)
|
|
cooldown.delay = delay
|
|
QR.cooldown.set(g.BOARD.ID, Date.now(), cooldown)
|
|
QR.cooldown.save()
|
|
return QR.cooldown.start()
|
|
},
|
|
|
|
addMute(delay) {
|
|
if (!Conf['Cooldown']) { return }
|
|
QR.cooldown.set(g.BOARD.ID, Date.now(), { type: 'mute', delay })
|
|
QR.cooldown.save()
|
|
return QR.cooldown.start()
|
|
},
|
|
|
|
delete(post) {
|
|
let cooldown
|
|
if (!QR.cooldown.data) { return }
|
|
const cooldowns = (QR.cooldown.data[post.board.ID] || (QR.cooldown.data[post.board.ID] = dict()))
|
|
for (const id in cooldowns) {
|
|
cooldown = cooldowns[id]
|
|
if ((cooldown.delay == null) && (cooldown.threadID === post.thread.ID) && (cooldown.postID === post.ID)) {
|
|
QR.cooldown.set(post.board.ID, id, null)
|
|
}
|
|
}
|
|
return QR.cooldown.save()
|
|
},
|
|
|
|
secondsDeletion(post) {
|
|
if (!QR.cooldown.data || !Conf['Cooldown']) { return 0 }
|
|
const cooldowns = QR.cooldown.data[post.board.ID] || dict()
|
|
for (const start in cooldowns) {
|
|
const cooldown = cooldowns[start]
|
|
if ((cooldown.delay == null) && (cooldown.threadID === post.thread.ID) && (cooldown.postID === post.ID)) {
|
|
const seconds = QR.cooldown.delays.deletion - Math.floor((Date.now() - start) / SECOND)
|
|
return Math.max(seconds, 0)
|
|
}
|
|
}
|
|
return 0
|
|
},
|
|
|
|
categorize(post) {
|
|
if (post.thread === 'new') {
|
|
return { type: 'thread' }
|
|
} else {
|
|
return {
|
|
type: post.file ? 'image' : 'reply',
|
|
threadID: +post.thread
|
|
}
|
|
}
|
|
},
|
|
|
|
mergeChange(data, scope, id, value) {
|
|
if (value) {
|
|
return (data[scope] || (data[scope] = dict()))[id] = value
|
|
} else if (scope in data) {
|
|
delete data[scope][id]
|
|
if (Object.keys(data[scope]).length === 0) { return delete data[scope] }
|
|
}
|
|
},
|
|
|
|
set(scope, id, value) {
|
|
QR.cooldown.mergeChange(QR.cooldown.data, scope, id, value)
|
|
return (QR.cooldown.changes[scope] || (QR.cooldown.changes[scope] = dict()))[id] = value
|
|
},
|
|
|
|
save() {
|
|
const { changes } = QR.cooldown
|
|
if (!Object.keys(changes).length) { return }
|
|
return $.get('cooldowns', dict(), function ({ cooldowns }) {
|
|
for (const scope in QR.cooldown.changes) {
|
|
for (const id in QR.cooldown.changes[scope]) {
|
|
const value = QR.cooldown.changes[scope][id]
|
|
QR.cooldown.mergeChange(cooldowns, scope, id, value)
|
|
}
|
|
QR.cooldown.data = cooldowns
|
|
}
|
|
return $.set('cooldowns', cooldowns, () => QR.cooldown.changes = dict())
|
|
})
|
|
},
|
|
|
|
clear() {
|
|
QR.cooldown.data = dict()
|
|
QR.cooldown.changes = dict()
|
|
QR.cooldown.auto = false
|
|
QR.cooldown.update()
|
|
return $.queueTask($.delete, 'cooldowns')
|
|
},
|
|
|
|
update() {
|
|
let cooldown
|
|
if (!QR.cooldown.isCounting) { return }
|
|
|
|
let save = false
|
|
let nCooldowns = 0
|
|
const now = Date.now()
|
|
const { type, threadID } = QR.cooldown.categorize(QR.posts[0])
|
|
let seconds = 0
|
|
|
|
if (Conf['Cooldown']) {
|
|
for (const scope of [g.BOARD.ID, 'global']) {
|
|
const cooldowns = (QR.cooldown.data[scope] || (QR.cooldown.data[scope] = dict()))
|
|
|
|
for (let start in cooldowns) {
|
|
cooldown = cooldowns[start]
|
|
start = +start
|
|
const elapsed = Math.floor((now - start) / SECOND)
|
|
if (elapsed < 0) { // clock changed since then?
|
|
QR.cooldown.set(scope, start, null)
|
|
save = true
|
|
continue
|
|
}
|
|
|
|
// Explicit delays from error messages
|
|
if (cooldown.delay != null) {
|
|
if (cooldown.delay <= elapsed) {
|
|
QR.cooldown.set(scope, start, null)
|
|
save = true
|
|
} else if (((cooldown.type === type) && (cooldown.threadID === threadID)) || (cooldown.type === 'mute')) {
|
|
// Delays only apply to the given post type and thread.
|
|
seconds = Math.max(seconds, cooldown.delay - elapsed)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Clean up expired cooldowns
|
|
let maxDelay = cooldown.threadID !== cooldown.postID ?
|
|
QR.cooldown.maxDelay
|
|
:
|
|
QR.cooldown.delays[scope === 'global' ? 'thread_global' : 'thread']
|
|
if (QR.cooldown.customCooldown) {
|
|
maxDelay = Math.max(maxDelay, parseInt(Conf['customCooldown'], 10))
|
|
}
|
|
if (maxDelay <= elapsed) {
|
|
QR.cooldown.set(scope, start, null)
|
|
save = true
|
|
continue
|
|
}
|
|
|
|
if (((type === 'thread') === (cooldown.threadID === cooldown.postID)) && (cooldown.boardID !== g.BOARD.ID)) {
|
|
// Only cooldowns relevant to this post can set the seconds variable:
|
|
// reply cooldown with a reply, thread cooldown with a thread.
|
|
// Inter-board thread cooldowns only apply on boards other than the one they were posted on.
|
|
const suffix = scope === 'global' ?
|
|
'_global'
|
|
:
|
|
''
|
|
seconds = Math.max(seconds, QR.cooldown.delays[type + suffix] - elapsed)
|
|
|
|
// If additional cooldown is enabled, add the configured seconds to the count.
|
|
if (QR.cooldown.customCooldown) {
|
|
seconds = Math.max(seconds, parseInt(Conf['customCooldown'], 10) - elapsed)
|
|
}
|
|
}
|
|
}
|
|
|
|
nCooldowns += Object.keys(cooldowns).length
|
|
}
|
|
}
|
|
|
|
if (save) { QR.cooldown.save }
|
|
|
|
if (nCooldowns) {
|
|
clearTimeout(QR.cooldown.timeout)
|
|
QR.cooldown.timeout = setTimeout(QR.cooldown.count, SECOND)
|
|
} else {
|
|
delete QR.cooldown.isCounting
|
|
}
|
|
|
|
// Update the status when we change posting type.
|
|
// Don't get stuck at some random number.
|
|
// Don't interfere with progress status updates.
|
|
const update = seconds !== QR.cooldown.seconds
|
|
QR.cooldown.seconds = seconds
|
|
if (update) { return QR.status() }
|
|
},
|
|
|
|
count() {
|
|
QR.cooldown.update()
|
|
if ((QR.cooldown.seconds === 0) && QR.cooldown.auto && !QR.req) { return QR.submit() }
|
|
}
|
|
},
|
|
|
|
oekaki: {
|
|
menu: {
|
|
post: null,
|
|
init() {
|
|
if (!['index', 'thread'].includes(g.VIEW) || !Conf['Menu'] || !Conf['Edit Link'] || !Conf['Quick Reply']) { return }
|
|
|
|
const a = $.el('a', {
|
|
className: 'edit-link',
|
|
href: 'javascript:;',
|
|
textContent: 'Edit image'
|
|
}
|
|
)
|
|
$.on(a, 'click', this.editFile)
|
|
|
|
return Menu.menu.addEntry({
|
|
el: a,
|
|
order: 90,
|
|
open(post) {
|
|
QR.oekaki.menu.post = post
|
|
const { file } = post
|
|
return QR.postingIsEnabled && !!file && (file.isImage || file.isVideo)
|
|
}
|
|
})
|
|
},
|
|
|
|
editFile() {
|
|
const { post } = QR.oekaki.menu
|
|
QR.quote.call(post.nodes.post)
|
|
const { isVideo } = post.file
|
|
const currentTime = post.file.fullImage?.currentTime || 0
|
|
return CrossOrigin.file(post.file.url, function (blob) {
|
|
if (!blob) {
|
|
return QR.error("Can't load file.", 'oekaki')
|
|
} else if (isVideo) {
|
|
const video = $.el('video', {
|
|
src: URL.createObjectURL(blob),
|
|
autoplay: true,
|
|
loop: true,
|
|
muted: true
|
|
})
|
|
$.on(video, 'loadedmetadata', function () {
|
|
$.on(video, 'seeked', function () {
|
|
const canvas = $.el('canvas', {
|
|
width: video.videoWidth,
|
|
height: video.videoHeight
|
|
}
|
|
)
|
|
canvas.getContext('2d').drawImage(video, 0, 0)
|
|
return canvas.toBlob(function (snapshot) {
|
|
snapshot.name = post.file.name.replace(/\.\w+$/, '') + '.png'
|
|
QR.handleFiles([snapshot])
|
|
return QR.oekaki.edit()
|
|
})
|
|
})
|
|
return video.currentTime = currentTime
|
|
})
|
|
$.on(video, 'error', () => QR.openError())
|
|
return video.src = URL.createObjectURL(blob)
|
|
} else {
|
|
blob.name = post.file.name
|
|
QR.handleFiles([blob])
|
|
return QR.oekaki.edit()
|
|
}
|
|
})
|
|
}
|
|
},
|
|
|
|
setup() {
|
|
return $.global(function () {
|
|
const { FCX } = window
|
|
FCX.oekakiCB = () => window.Tegaki.flatten().toBlob(function (file) {
|
|
const source = `oekaki-${Date.now()}`
|
|
FCX.oekakiLatest = source
|
|
return document.dispatchEvent(new CustomEvent('QRSetFile', {
|
|
bubbles: true,
|
|
detail: { file, name: FCX.oekakiName, source }
|
|
}))
|
|
})
|
|
if (window.Tegaki) {
|
|
return document.querySelector('#qr .oekaki').hidden = false
|
|
}
|
|
})
|
|
},
|
|
|
|
load(cb) {
|
|
if ($('script[src^="//s.4cdn.org/js/tegaki"]', d.head)) {
|
|
return cb()
|
|
} else {
|
|
const style = $.el('link', {
|
|
rel: 'stylesheet',
|
|
href: `//s.4cdn.org/css/tegaki.${Date.now()}.css`
|
|
}
|
|
)
|
|
const script = $.el('script',
|
|
{ src: `//s.4cdn.org/js/tegaki.min.${Date.now()}.js` })
|
|
let n = 0
|
|
const onload = function () {
|
|
if (++n === 2) { return cb() }
|
|
}
|
|
$.on(style, 'load', onload)
|
|
$.on(script, 'load', onload)
|
|
return $.add(d.head, [style, script])
|
|
}
|
|
},
|
|
draw() {
|
|
return $.global(() => {
|
|
const { Tegaki, FCX } = window
|
|
|
|
if (Tegaki.bg) {
|
|
Tegaki.destroy()
|
|
}
|
|
|
|
FCX.oekakiName = 'tegaki.png'
|
|
|
|
const getWidth = (): number => +(document.querySelector('#qr [name=oekaki-width]') as HTMLInputElement).clientWidth
|
|
const getHeight = (): number => +(document.querySelector('#qr [name=oekaki-height]') as HTMLInputElement).clientHeight
|
|
const getBgColor = (): string => {
|
|
const bgColorCheckbox = document.querySelector('#qr [name=oekaki-bg]') as HTMLInputElement
|
|
const bgColorInput = document.querySelector('#qr [name=oekaki-bgcolor]') as HTMLInputElement
|
|
|
|
return bgColorCheckbox.checked ? bgColorInput.value : 'transparent'
|
|
}
|
|
|
|
return Tegaki.open({
|
|
onDone: FCX.oekakiCB,
|
|
onCancel: () => { Tegaki.bgColor = '#ffffff' },
|
|
width: getWidth(),
|
|
height: getHeight(),
|
|
bgColor: getBgColor(),
|
|
})
|
|
})
|
|
},
|
|
|
|
button() {
|
|
if (QR.selected.file) {
|
|
return QR.oekaki.edit()
|
|
} else {
|
|
return QR.oekaki.toggle()
|
|
}
|
|
},
|
|
|
|
edit() {
|
|
return QR.oekaki.load(() => $.global(function () {
|
|
const { Tegaki, FCX } = window
|
|
const name = document.getElementById('qr-filename').value.replace(/\.\w+$/, '') + '.png'
|
|
const { source } = document.getElementById('file-n-submit').dataset
|
|
const error = content => document.dispatchEvent(new CustomEvent('CreateNotification', {
|
|
bubbles: true,
|
|
detail: { type: 'warning', content, lifetime: 20 }
|
|
}))
|
|
const cb = function (e) {
|
|
if (e) { this.removeEventListener('QRMetadata', cb, false) }
|
|
const selected = document.getElementById('selected')
|
|
if (!selected?.dataset.type) { return error('No file to edit.') }
|
|
if (!/^(image|video)\//.test(selected.dataset.type)) { return error('Not an image.') }
|
|
if (!selected.dataset.height) { return error('Metadata not available.') }
|
|
if (selected.dataset.height === 'loading') {
|
|
selected.addEventListener('QRMetadata', cb, false)
|
|
return
|
|
}
|
|
if (Tegaki.bg) { Tegaki.destroy() }
|
|
FCX.oekakiName = name
|
|
Tegaki.open({
|
|
onDone: FCX.oekakiCB,
|
|
onCancel() { return Tegaki.bgColor = '#ffffff' },
|
|
width: +selected.dataset.width,
|
|
height: +selected.dataset.height,
|
|
bgColor: 'transparent'
|
|
})
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = +selected.dataset.width
|
|
canvas.height = +selected.dataset.height
|
|
canvas.hidden = true
|
|
document.body.appendChild(canvas)
|
|
|
|
canvas.addEventListener('QRImageDrawn', function () {
|
|
this.remove()
|
|
return Tegaki.onOpenImageLoaded.call(this)
|
|
}
|
|
, false)
|
|
return canvas.dispatchEvent(new CustomEvent('QRDrawFile', { bubbles: true }))
|
|
}
|
|
if (Tegaki.bg && (Tegaki.onDoneCb === FCX.oekakiCB) && (source === FCX.oekakiLatest)) {
|
|
FCX.oekakiName = name
|
|
return Tegaki.resume()
|
|
} else {
|
|
return cb(E)
|
|
}
|
|
}))
|
|
},
|
|
|
|
toggle() {
|
|
return QR.oekaki.load(() => QR.nodes.oekaki.hidden = !QR.nodes.oekaki.hidden)
|
|
}
|
|
},
|
|
email: null,
|
|
|
|
persona: {
|
|
pwd: '',
|
|
always: false,
|
|
types: {
|
|
name: [""],
|
|
email: [""],
|
|
sub: [""]
|
|
},
|
|
|
|
init() {
|
|
if (!Conf['Quick Reply'] && (!Conf['Menu'] || !Conf['Delete Link'])) { return }
|
|
for (const item of Conf['QR.personas'].split('\n')) {
|
|
QR.persona.parseItem(item.trim())
|
|
}
|
|
},
|
|
|
|
parseItem(item) {
|
|
let match, needle, type, val
|
|
if (item[0] === '#') { return }
|
|
if (!(match = item.match(/(name|options|email|subject|password):"(.*)"/i))) { return }
|
|
// eslint-disable-next-line prefer-const
|
|
[match, type, val] = Array.from(match)
|
|
|
|
// Don't mix up item settings with val.
|
|
item = item.replace(match, '')
|
|
|
|
const boards = item.match(/boards:([^;]+)/i)?.[1].toLowerCase() || 'global'
|
|
if ((boards !== 'global') && (needle = g.BOARD.ID, !boards.split(',').includes(needle))) { return }
|
|
|
|
|
|
if (type === 'password') {
|
|
QR.persona.pwd = val
|
|
return
|
|
}
|
|
|
|
if (type === 'options') { type = 'email' }
|
|
if (type === 'subject') { type = 'sub' }
|
|
|
|
if (/always/i.test(item)) {
|
|
QR.persona.always[type] = val
|
|
}
|
|
|
|
if (!QR.persona.types[type].includes(val)) {
|
|
return QR.persona.types[type].push(val)
|
|
}
|
|
},
|
|
|
|
load() {
|
|
for (const type in QR.persona.types) {
|
|
const arr = QR.persona.types[type]
|
|
const list = $(`#list-${type}`, QR.nodes.el)
|
|
for (const val of arr) {
|
|
if (val) {
|
|
$.add(list, $.el('option',
|
|
{ textContent: val })
|
|
)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
getPassword() {
|
|
let m
|
|
if (QR.persona.pwd != null) {
|
|
return QR.persona.pwd
|
|
} else if (m = d.cookie.match(/4chan_pass=([^;]+)/)) {
|
|
return decodeURIComponent(m[1])
|
|
} else {
|
|
return ''
|
|
}
|
|
},
|
|
|
|
get(cb) {
|
|
return $.get('QR.persona', {}, ({ 'QR.persona': persona }) => cb(persona))
|
|
},
|
|
|
|
set(post) {
|
|
return $.get('QR.persona', {}, function ({ 'QR.persona': persona }) {
|
|
persona = {
|
|
name: post.name,
|
|
flag: post.flag
|
|
}
|
|
return $.set('QR.persona', persona)
|
|
})
|
|
}
|
|
},
|
|
post: class {
|
|
thread: Thread
|
|
flag: string
|
|
sub: string
|
|
com: string
|
|
spoiler: boolean
|
|
name: string
|
|
email: string
|
|
fileUrl: string
|
|
fileThumb: string
|
|
nodes: { el: HTMLElement; rm: HTMLElement; spoiler: HTMLInputElement; span: HTMLElement }
|
|
draggable: boolean
|
|
parentNode: HTMLElement
|
|
filesize: string
|
|
filename: string
|
|
pasting: boolean
|
|
URL: string
|
|
file: File
|
|
constructor(select) {
|
|
this.select = this.select.bind(this)
|
|
const el = $.el('a', {
|
|
className: 'qr-preview',
|
|
draggable: true,
|
|
href: 'javascript:;'
|
|
}
|
|
)
|
|
$.extend(el, { innerHTML: '<a class="remove fa fa-times-circle" title="Remove"></a><label class="qr-preview-spoiler"><input type="checkbox"> Spoiler</label><span></span>' })
|
|
|
|
this.nodes = {
|
|
el,
|
|
rm: el.firstChild,
|
|
spoiler: $('.qr-preview-spoiler input', el),
|
|
span: el.lastChild
|
|
}
|
|
|
|
$.on(el, 'click', this.select)
|
|
$.on(this.nodes.rm, 'click', e => { e.stopPropagation(); return this.rm() })
|
|
$.on(this.nodes.spoiler, 'change', e => {
|
|
this.spoiler = e.target.checked
|
|
if (this === QR.selected) { QR.nodes.spoiler.checked = this.spoiler }
|
|
return this.preventAutoPost()
|
|
})
|
|
for (const label of $$('label', el)) {
|
|
$.on(label, 'click', e => e.stopPropagation())
|
|
}
|
|
$.add(QR.nodes.dumpList, el)
|
|
|
|
for (const event of ['dragStart', 'dragEnter', 'dragLeave', 'dragOver', 'dragEnd', 'drop']) {
|
|
$.on(el, event.toLowerCase(), this[event])
|
|
}
|
|
|
|
this.thread = g.VIEW === 'thread' ?
|
|
g.THREADID
|
|
:
|
|
'new'
|
|
|
|
const prev = QR.posts[QR.posts.length - 1]
|
|
QR.posts.push(this)
|
|
this.nodes.spoiler.checked = (this.spoiler = prev && Conf['Remember Spoiler'] ?
|
|
prev.spoiler
|
|
:
|
|
false)
|
|
QR.persona.get(persona => {
|
|
this.name = 'name' in QR.persona.always ?
|
|
QR.persona.always.name
|
|
: prev ?
|
|
prev.name
|
|
:
|
|
persona.name
|
|
|
|
this.email = 'email' in QR.persona.always ?
|
|
QR.persona.always.email
|
|
:
|
|
''
|
|
|
|
this.sub = 'sub' in QR.persona.always ?
|
|
QR.persona.always.sub
|
|
:
|
|
''
|
|
|
|
if (QR.nodes.flag) {
|
|
this.flag = (() => {
|
|
if (prev) {
|
|
return prev.flag
|
|
} else if (persona.flag && persona.flag in g.BOARD.config.board_flags) {
|
|
return persona.flag
|
|
}
|
|
})()
|
|
}
|
|
if (QR.selected === this) { return this.load() }
|
|
}) // load persona
|
|
if (select) { this.select() }
|
|
this.unlock()
|
|
QR.captcha.moreNeeded()
|
|
}
|
|
|
|
rm() {
|
|
this.delete()
|
|
const index = QR.posts.indexOf(this)
|
|
if (QR.posts.length === 1) {
|
|
new QR.post(true)
|
|
$.rmClass(QR.nodes.el, 'dump')
|
|
} else if (this === QR.selected) {
|
|
(QR.posts[index - 1] || QR.posts[index + 1]).select()
|
|
}
|
|
QR.posts.splice(index, 1)
|
|
QR.status()
|
|
return QR.captcha.updateThread?.()
|
|
}
|
|
|
|
delete() {
|
|
$.rm(this.nodes.el)
|
|
URL.revokeObjectURL(this.URL)
|
|
return this.dismissErrors()
|
|
}
|
|
|
|
lock(lock = true) {
|
|
this.isLocked = lock
|
|
if (this !== QR.selected) { return }
|
|
for (const name of ['thread', 'name', 'email', 'sub', 'com', 'fileButton', 'filename', 'spoiler', 'flag']) {
|
|
let node
|
|
if ((node = QR.nodes[name])) {
|
|
node.disabled = lock
|
|
}
|
|
}
|
|
this.nodes.rm.style.visibility = lock ? 'hidden' : ''
|
|
this.nodes.spoiler.disabled = lock
|
|
return this.nodes.el.draggable = !lock
|
|
}
|
|
|
|
unlock() {
|
|
return this.lock(false)
|
|
}
|
|
|
|
select() {
|
|
if (QR.selected) {
|
|
QR.selected.nodes.el.removeAttribute('id')
|
|
QR.selected.forceSave()
|
|
}
|
|
QR.selected = this
|
|
this.lock(this.isLocked)
|
|
this.nodes.el.id = 'selected'
|
|
// Scroll the list to center the focused post.
|
|
const rectEl = this.nodes.el.getBoundingClientRect()
|
|
const rectList = this.nodes.el.parentNode.getBoundingClientRect()
|
|
this.nodes.el.scrollLeft += (rectEl.left + (rectEl.width / 2)) - rectList.left - (rectList.width / 2)
|
|
return this.load()
|
|
}
|
|
|
|
load() {
|
|
// Load this post's values.
|
|
|
|
for (const name of ['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag']) {
|
|
let node
|
|
if (!(node = QR.nodes[name])) { continue }
|
|
node.value = this[name] || node.dataset.default || ''
|
|
}
|
|
|
|
(this.thread !== 'new' ? $.addClass : $.rmClass)(QR.nodes.el, 'reply-to-thread')
|
|
|
|
this.showFileData()
|
|
return QR.characterCount()
|
|
}
|
|
|
|
save(input, forced) {
|
|
if (input.type === 'checkbox') {
|
|
this.spoiler = input.checked
|
|
return
|
|
}
|
|
const { name } = input.dataset
|
|
if (!['thread', 'name', 'email', 'sub', 'com', 'filename', 'flag'].includes(name)) { return }
|
|
const prev = this[name] || input.dataset.default || null
|
|
this[name] = input.value || input.dataset.default || null
|
|
switch (name) {
|
|
case 'thread':
|
|
(this.thread !== 'new' ? $.addClass : $.rmClass)(QR.nodes.el, 'reply-to-thread')
|
|
QR.status()
|
|
QR.captcha.updateThread?.()
|
|
break
|
|
case 'com':
|
|
this.updateComment()
|
|
break
|
|
case 'filename':
|
|
if (!this.file) { return }
|
|
this.saveFilename()
|
|
this.updateFilename()
|
|
break
|
|
case 'name': case 'flag':
|
|
if (this[name] !== prev) { // only save manual changes, not values filled in by persona settings
|
|
QR.persona.set(this)
|
|
}
|
|
break
|
|
}
|
|
if (!forced) { return this.preventAutoPost() }
|
|
}
|
|
|
|
forceSave() {
|
|
if (this !== QR.selected) { return }
|
|
// Do this in case people use extensions
|
|
// that do not trigger the `input` event.
|
|
for (const name of ['thread', 'name', 'email', 'sub', 'com', 'filename', 'spoiler', 'flag']) {
|
|
let node
|
|
if (!(node = QR.nodes[name])) { continue }
|
|
this.save(node, true)
|
|
}
|
|
}
|
|
|
|
preventAutoPost() {
|
|
// Disable auto-posting if you're editing the first post
|
|
// during the last 5 seconds of the cooldown.
|
|
if (QR.cooldown.auto && (this === QR.posts[0])) {
|
|
QR.cooldown.update() // adding/removing file can change cooldown
|
|
if (QR.cooldown.seconds <= 5) { return QR.cooldown.auto = false }
|
|
}
|
|
}
|
|
|
|
setComment(com) {
|
|
this.com = com || null
|
|
if (this === QR.selected) {
|
|
QR.nodes.com.value = this.com
|
|
}
|
|
return this.updateComment()
|
|
}
|
|
|
|
updateComment() {
|
|
if (this === QR.selected) {
|
|
QR.characterCount()
|
|
}
|
|
this.nodes.span.textContent = this.com
|
|
QR.captcha.moreNeeded()
|
|
if (QR.captcha === Captcha.v2) {
|
|
return Captcha.cache.prerequest()
|
|
}
|
|
}
|
|
|
|
isOnlyQuotes() {
|
|
return (this.com || '').trim() === (this.quotedText || '').trim()
|
|
}
|
|
|
|
static rmErrored(e) {
|
|
e.stopPropagation()
|
|
for (let i = QR.posts.length - 1; i >= 0; i--) {
|
|
let errors
|
|
const post = QR.posts[i]
|
|
if ((errors = post.errors)) {
|
|
for (const error of errors) {
|
|
if (doc.contains(error)) {
|
|
post.rm()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
error(className, message, link) {
|
|
const div = $.el('div', { className })
|
|
$.extend(div, {
|
|
innerHTML: message + (link ? ` [<a href="${E(link)}" target="_blank">More info</a>]` : '') +
|
|
`<br>[<a href="javascript:;">delete post</a>] [<a href="javascript:;">delete all</a>]`
|
|
});
|
|
(this.errors || (this.errors = [])).push(div)
|
|
const [rm, rmAll] = Array.from($$('a', div))
|
|
$.on(div, 'click', () => {
|
|
if (QR.posts.includes(this)) { return this.select() }
|
|
})
|
|
$.on(rm, 'click', e => {
|
|
e.stopPropagation()
|
|
if (QR.posts.includes(this)) { return this.rm() }
|
|
})
|
|
$.on(rmAll, 'click', QR.post.rmErrored)
|
|
return QR.error(div, true)
|
|
}
|
|
|
|
fileError(message, link) {
|
|
return this.error('file-error', `${this.filename}: ${message}`, link)
|
|
}
|
|
|
|
dismissErrors(test = () => true) {
|
|
if (this.errors) {
|
|
for (const error of this.errors) {
|
|
if (doc.contains(error) && test(error)) {
|
|
error.parentNode.previousElementSibling.click()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
setFile(file) {
|
|
this.file = file
|
|
if (Conf['Randomize Filename'] && (g.BOARD.ID !== 'f')) {
|
|
let ext
|
|
this.filename = `${Date.now() * 1000 - Math.floor(Math.random() * 365 * DAY * 1000)}`
|
|
if (ext = this.file.name.match(QR.validExtension)) { this.filename += ext[0] }
|
|
} else {
|
|
this.filename = this.file.name
|
|
}
|
|
this.filesize = $.bytesToString(this.file.size)
|
|
this.checkSize()
|
|
$.addClass(this.nodes.el, 'has-file')
|
|
QR.captcha.moreNeeded()
|
|
URL.revokeObjectURL(this.URL)
|
|
this.saveFilename()
|
|
if (this === QR.selected) {
|
|
this.showFileData()
|
|
} else {
|
|
this.updateFilename()
|
|
}
|
|
this.rmMetadata()
|
|
this.nodes.el.dataset.type = this.file.type
|
|
this.nodes.el.style.backgroundImage = ''
|
|
if (!QR.mimeTypes.includes(this.file.type)) {
|
|
this.fileError('Unsupported file type.', meta.faq + '#supported-file-types')
|
|
} else if (/^(image|video)\//.test(this.file.type)) {
|
|
this.readFile()
|
|
}
|
|
return this.preventAutoPost()
|
|
}
|
|
|
|
checkSize() {
|
|
let max = QR.max_size
|
|
if (/^video\//.test(this.file.type)) { max = Math.min(max, QR.max_size_video) }
|
|
if (this.file.size > max) {
|
|
return this.fileError(`File too large (file: ${this.filesize}, max: ${$.bytesToString(max)}).`, meta.faq + '#file-too-large')
|
|
}
|
|
}
|
|
|
|
readFile() {
|
|
const isVideo = /^video\//.test(this.file.type)
|
|
const el = $.el(isVideo ? 'video' : 'img', { src: this.URL, style: 'display: none' })
|
|
if (isVideo && !el.canPlayType(this.file.type)) { return }
|
|
|
|
const event = isVideo ? 'loadeddata' : 'load'
|
|
const onload = () => {
|
|
$.off(el, event, onload)
|
|
$.off(el, 'error', onerror)
|
|
this.checkDimensions(el)
|
|
this.setThumbnail(el)
|
|
return $.event('QRMetadata', null, this.nodes.el)
|
|
}
|
|
const onerror = () => {
|
|
$.off(el, event, onload)
|
|
$.off(el, 'error', onerror)
|
|
this.fileError(`Corrupt ${isVideo ? 'video' : 'image'} or error reading metadata.`, meta.faq + '#error-reading-metadata')
|
|
URL.revokeObjectURL(el.src)
|
|
// XXX https://bugzilla.mozilla.org/show_bug.cgi?id=1021289
|
|
this.nodes.el.removeAttribute('data-height')
|
|
return $.event('QRMetadata', null, this.nodes.el)
|
|
}
|
|
this.nodes.el.dataset.height = 'loading'
|
|
$.on(el, event, onload)
|
|
$.on(el, 'error', onerror)
|
|
return el.src = URL.createObjectURL(this.file)
|
|
}
|
|
checkDimensions(el) {
|
|
let height, width
|
|
if (el.tagName === 'IMG') {
|
|
({ height, width } = el)
|
|
this.nodes.el.dataset.height = height
|
|
this.nodes.el.dataset.width = width
|
|
if ((height > QR.max_height) || (width > QR.max_width)) {
|
|
this.fileError(`Image too large (image: ${height}x${width}px, max: ${QR.max_height}x${QR.max_width}px)`, meta.faq + '#image-too-large')
|
|
}
|
|
if ((height < QR.min_height) || (width < QR.min_width)) {
|
|
return this.fileError(`Image too small (image: ${height}x${width}px, min: ${QR.min_height}x${QR.min_width}px)`, meta.faq + '#image-too-small')
|
|
}
|
|
} else {
|
|
const { videoHeight, videoWidth, duration } = el
|
|
this.nodes.el.dataset.height = videoHeight
|
|
this.nodes.el.dataset.width = videoWidth
|
|
this.nodes.el.dataset.duration = duration
|
|
const max_height = Math.min(QR.max_height, QR.max_height_video)
|
|
const max_width = Math.min(QR.max_width, QR.max_width_video)
|
|
if ((videoHeight > max_height) || (videoWidth > max_width)) {
|
|
this.fileError(`Video too large (video: ${videoHeight}x${videoWidth}px, max: ${max_height}x${max_width}px)`, meta.faq + '#video-too-large')
|
|
}
|
|
if ((videoHeight < QR.min_height) || (videoWidth < QR.min_width)) {
|
|
this.fileError(`Video too small (video: ${videoHeight}x${videoWidth}px, min: ${QR.min_height}x${QR.min_width}px)`, meta.faq + '#video-too-small')
|
|
}
|
|
if (!isFinite(duration)) {
|
|
this.fileError('Video lacks duration metadata (try remuxing)', meta.faq + '#video-lacks-duration-metadata')
|
|
} else if (duration > QR.max_duration_video) {
|
|
this.fileError(`Video too long (video: ${duration}s, max: ${QR.max_duration_video}s)`, meta.faq + '#video-too-long')
|
|
}
|
|
if (BoardConfig.noAudio(g.BOARD.ID) && $.hasAudio(el)) {
|
|
return this.fileError('Audio not allowed', meta.faq + '#audio-not-allowed')
|
|
}
|
|
}
|
|
}
|
|
|
|
setThumbnail(el) {
|
|
// Create a redimensioned thumbnail.
|
|
let height, width
|
|
const isVideo = el.tagName === 'VIDEO'
|
|
|
|
// 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.
|
|
let s = 90 * 2 * window.devicePixelRatio
|
|
if (this.file.type === 'image/gif') { s *= 3 } // let them animate
|
|
if (isVideo) {
|
|
height = el.videoHeight
|
|
width = el.videoWidth
|
|
} else {
|
|
({ height, width } = el)
|
|
if ((height < s) || (width < s)) {
|
|
this.URL = el.src
|
|
this.nodes.el.style.backgroundImage = `url(${this.URL})`
|
|
return
|
|
}
|
|
}
|
|
|
|
if (height <= width) {
|
|
width = (s / height) * width
|
|
height = s
|
|
} else {
|
|
height = (s / width) * height
|
|
width = s
|
|
}
|
|
const cv = $.el('canvas', null)
|
|
cv.height = height
|
|
cv.width = width
|
|
cv.getContext('2d').drawImage(el, 0, 0, width, height)
|
|
URL.revokeObjectURL(el.src)
|
|
return cv.toBlob(blob => {
|
|
this.URL = URL.createObjectURL(blob)
|
|
return this.nodes.el.style.backgroundImage = `url(${this.URL})`
|
|
})
|
|
}
|
|
|
|
rmFile() {
|
|
if (this.isLocked) { return }
|
|
delete this.file
|
|
delete this.filename
|
|
delete this.filesize
|
|
this.nodes.el.removeAttribute('title')
|
|
QR.nodes.filename.removeAttribute('title')
|
|
this.rmMetadata()
|
|
this.nodes.el.style.backgroundImage = ''
|
|
$.rmClass(this.nodes.el, 'has-file')
|
|
this.showFileData()
|
|
URL.revokeObjectURL(this.URL)
|
|
this.dismissErrors(error => $.hasClass(error, 'file-error'))
|
|
return this.preventAutoPost()
|
|
}
|
|
|
|
rmMetadata() {
|
|
for (const attr of ['type', 'height', 'width', 'duration']) {
|
|
// XXX https://bugzilla.mozilla.org/show_bug.cgi?id=1021289
|
|
this.nodes.el.removeAttribute(`data-${attr}`)
|
|
}
|
|
}
|
|
|
|
saveFilename() {
|
|
this.file.newName = (this.filename || '').replace(/[/\\]/g, '-')
|
|
if (!QR.validExtension.test(this.filename)) {
|
|
// 4chan will truncate the filename if it has no extension.
|
|
return this.file.newName += `.${$.getOwn(QR.extensionFromType, this.file.type) || 'jpg'}`
|
|
}
|
|
}
|
|
|
|
updateFilename() {
|
|
const long = `${this.filename} (${this.filesize})`
|
|
this.nodes.el.title = long
|
|
if (this !== QR.selected) { return }
|
|
return QR.nodes.filename.title = long
|
|
}
|
|
|
|
showFileData() {
|
|
if (this.file) {
|
|
this.updateFilename()
|
|
QR.nodes.filename.value = this.filename
|
|
$.addClass(QR.nodes.oekaki, 'has-file')
|
|
$.addClass(QR.nodes.fileSubmit, 'has-file')
|
|
} else {
|
|
$.rmClass(QR.nodes.oekaki, 'has-file')
|
|
$.rmClass(QR.nodes.fileSubmit, 'has-file')
|
|
}
|
|
if (this.file?.source != null) {
|
|
QR.nodes.fileSubmit.dataset.source = this.file.source
|
|
} else {
|
|
QR.nodes.fileSubmit.removeAttribute('data-source')
|
|
}
|
|
return QR.nodes.spoiler.checked = this.spoiler
|
|
}
|
|
|
|
pasteText(file) {
|
|
this.pasting = true
|
|
this.preventAutoPost()
|
|
const reader = new FileReader()
|
|
reader.onload = e => {
|
|
const { result } = e.target
|
|
this.setComment((this.com ? `${this.com}\n${result}` : result))
|
|
return delete this.pasting
|
|
}
|
|
return reader.readAsText(file)
|
|
}
|
|
|
|
dragStart(e) {
|
|
const { left, top } = this.getBoundingClientRect()
|
|
e.dataTransfer.setDragImage(this, e.clientX - left, e.clientY - top)
|
|
return $.addClass(this, 'drag')
|
|
}
|
|
dragEnd() { return $.rmClass(this, 'drag') }
|
|
dragEnter() { return $.addClass(this, 'over') }
|
|
dragLeave() { return $.rmClass(this, 'over') }
|
|
|
|
dragOver(e) {
|
|
e.preventDefault()
|
|
return e.dataTransfer.dropEffect = 'move'
|
|
}
|
|
|
|
drop() {
|
|
$.rmClass(this, 'over')
|
|
if (!this.draggable) { return }
|
|
const el = $('.drag', this.parentNode)
|
|
const index = el => [...Array.from(el.parentNode.children)].indexOf(el)
|
|
const oldIndex = index(el)
|
|
const newIndex = index(this)
|
|
if (QR.posts[oldIndex].isLocked || QR.posts[newIndex].isLocked) { return }
|
|
(oldIndex < newIndex ? $.after : $.before)(this, el)
|
|
const post = QR.posts.splice(oldIndex, 1)[0]
|
|
QR.posts.splice(newIndex, 0, post)
|
|
QR.status()
|
|
return QR.captcha.updateThread?.()
|
|
}
|
|
}
|
|
|
|
}
|
|
export default QR
|