added a few types

This commit is contained in:
Lalle 2023-04-28 03:25:13 +02:00
parent cc4155500b
commit 329fc4dd14
No known key found for this signature in database
GPG Key ID: A6583D207A8F6B0D
15 changed files with 559 additions and 508 deletions

View File

@ -48,6 +48,7 @@
"never" "never"
], ],
"simple-import-sort/imports": "error", "simple-import-sort/imports": "error",
"simple-import-sort/exports": "error" "simple-import-sort/exports": "error",
"no-cond-assign": "off"
} }
} }

View File

@ -1,22 +1,26 @@
import BoardConfig from "../General/BoardConfig" import BoardConfig from "../General/BoardConfig"
import { d, g } from "../globals/globals" import { d, g } from "../globals/globals"
import Post from "./Post"
import SimpleDict from "./SimpleDict" import SimpleDict from "./SimpleDict"
import Thread from "./Thread"
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
export default class Board { export default class Board {
ID: string
boardID: number | string
siteID: number
threads: SimpleDict<Thread>
posts: SimpleDict<Post>
config: any
toString() { return this.ID } toString() { return this.ID }
constructor(ID) { constructor(ID) {
this.ID = ID this.ID = ID
this.boardID = this.ID this.boardID = this.ID
this.siteID = g.SITE.ID this.siteID = g.SITE.ID
this.threads = new SimpleDict() this.threads = new SimpleDict()
this.posts = new SimpleDict() this.posts = new SimpleDict()
this.config = BoardConfig.boards?.[this.ID] || {} this.config = BoardConfig.boards?.[this.ID] || {}
g.boards[this] = this g.boards[this] = this
} }
@ -25,8 +29,8 @@ export default class Board {
const c2 = (this.config || {}).cooldowns || {} const c2 = (this.config || {}).cooldowns || {}
const c = { const c = {
thread: c2.threads || 0, thread: c2.threads || 0,
reply: c2.replies || 0, reply: c2.replies || 0,
image: c2.images || 0, image: c2.images || 0,
thread_global: 300 // inter-board thread cooldown thread_global: 300 // inter-board thread cooldown
} }
// Pass users have reduced cooldowns. // Pass users have reduced cooldowns.

View File

@ -1,15 +1,17 @@
import Main from "../main/Main" import Main from "../main/Main"
import Post from "./Post"
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
export default class Callbacks { export default class Callbacks {
static Post: Callbacks
static Thread: Callbacks
static CatalogThread: Callbacks
static CatalogThreadNative: Callbacks
type: string
keys: string[]
static initClass() { static initClass() {
this.Post = new Callbacks('Post') this.Post = new Callbacks('Post')
this.Thread = new Callbacks('Thread') this.Thread = new Callbacks('Thread')
this.CatalogThread = new Callbacks('Catalog Thread') this.CatalogThread = new Callbacks('Catalog Thread')
this.CatalogThreadNative = new Callbacks('Catalog Thread') this.CatalogThreadNative = new Callbacks('Catalog Thread')
} }
@ -19,12 +21,12 @@ export default class Callbacks {
this.keys = [] this.keys = []
} }
push({name, cb}) { push({ name, cb }) {
if (!this[name]) { this.keys.push(name) } if (!this[name]) { this.keys.push(name) }
return this[name] = cb return this[name] = cb
} }
execute(node, keys=this.keys, force=false) { execute(node, keys = this.keys, force = false) {
let errors let errors
if (node.callbacksExecuted && !force) { return } if (node.callbacksExecuted && !force) { return }
node.callbacksExecuted = true node.callbacksExecuted = true

View File

@ -1,21 +1,28 @@
import $ from "../platform/$" import $ from "../platform/$"
import Board from "./Board"
import Post from "./Post"
import Thread from "./Thread"
export default class CatalogThread { export default class CatalogThread {
ID: any
thread: Thread
board: any
nodes: { root: Post; thumb: HTMLElement; icons: any; postCount: number; fileCount: number; pageCount: number; replies: any }
toString() { return this.ID } toString() { return this.ID }
constructor(root, thread) { constructor(root: Post, thread: Thread) {
this.thread = thread this.thread = thread
this.ID = this.thread.ID this.ID = this.thread.ID + ''
this.board = this.thread.board this.board = this.thread.board
const {post} = this.thread.OP.nodes const { post } = this.thread.OP.nodes
this.nodes = { this.nodes = {
root, root,
thumb: $('.catalog-thumb', post), thumb: $('.catalog-thumb', post),
icons: $('.catalog-icons', post), icons: $('.catalog-icons', post),
postCount: $('.post-count', post), postCount: $('.post-count', post),
fileCount: $('.file-count', post), fileCount: $('.file-count', post),
pageCount: $('.page-count', post), pageCount: $('.page-count', post),
replies: null replies: null
} }
this.thread.catalogView = this this.thread.catalogView = this
} }

View File

@ -4,6 +4,13 @@ import Board from "./Board"
import Thread from "./Thread" import Thread from "./Thread"
export default class CatalogThreadNative { export default class CatalogThreadNative {
ID: number | string
nodes: { root: Thread; thumb: HTMLElement }
siteID: string
boardID: string
board: Board | import("/home/victor/proj/4chan-XZ/src/globals/globals").Board
threadID: number
thread: Thread
toString() { return this.ID } toString() { return this.ID }
constructor(root) { constructor(root) {
@ -11,7 +18,7 @@ export default class CatalogThreadNative {
root, root,
thumb: $(g.SITE.selectors.catalog.thumb, root) thumb: $(g.SITE.selectors.catalog.thumb, root)
} }
this.siteID = g.SITE.ID this.siteID = g.SITE.ID
this.boardID = this.nodes.thumb.parentNode.pathname.split(/\/+/)[1] this.boardID = this.nodes.thumb.parentNode.pathname.split(/\/+/)[1]
this.board = g.boards[this.boardID] || new Board(this.boardID) this.board = g.boards[this.boardID] || new Board(this.boardID)
this.ID = (this.threadID = +(root.dataset.id || root.id).match(/\d*$/)[0]) this.ID = (this.threadID = +(root.dataset.id || root.id).match(/\d*$/)[0])

View File

@ -1,13 +1,13 @@
import { g } from "../globals/globals" import { g } from "../globals/globals"
import $ from "../platform/$" import $ from "../platform/$"
import Callbacks from "./Callbacks"
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
export default class Connection { export default class Connection {
constructor(target, origin, cb={}) { target: any
origin: any
cb: Callbacks
constructor(target: Window, origin: string, cb: Callbacks) {
this.send = this.send.bind(this) this.send = this.send.bind(this)
this.onMessage = this.onMessage.bind(this) this.onMessage = this.onMessage.bind(this)
this.target = target this.target = target
@ -28,7 +28,7 @@ export default class Connection {
return this.targetWindow().postMessage(`${g.NAMESPACE}${JSON.stringify(data)}`, this.origin) return this.targetWindow().postMessage(`${g.NAMESPACE}${JSON.stringify(data)}`, this.origin)
} }
onMessage(e) { onMessage(e: MessageEvent) {
if ((e.source !== this.targetWindow()) || if ((e.source !== this.targetWindow()) ||
(e.origin !== this.origin) || (e.origin !== this.origin) ||
(typeof e.data !== 'string') || (typeof e.data !== 'string') ||

View File

@ -11,6 +11,11 @@ import { dict, HOUR } from "../platform/helpers"
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/ */
export default class DataBoard { export default class DataBoard {
static keys: string[]
static changes: string[]
key: string
sync: VoidFunction
data: any
static initClass() { static initClass() {
this.keys = ['hiddenThreads', 'hiddenPosts', 'lastReadPosts', 'yourPosts', 'watchedThreads', 'watcherLastModified', 'customTitles'] this.keys = ['hiddenThreads', 'hiddenPosts', 'lastReadPosts', 'yourPosts', 'watchedThreads', 'watcherLastModified', 'customTitles']
@ -38,8 +43,8 @@ export default class DataBoard {
this.data = data this.data = data
if (this.data.boards) { if (this.data.boards) {
let lastChecked; let lastChecked;
({boards, lastChecked} = this.data) ({ boards, lastChecked } = this.data)
this.data['4chan.org'] = {boards, lastChecked} this.data['4chan.org'] = { boards, lastChecked }
delete this.data.boards delete this.data.boards
delete this.data.lastChecked delete this.data.lastChecked
} }
@ -76,31 +81,31 @@ export default class DataBoard {
}) })
} }
delete({siteID, boardID, threadID, postID}, cb) { delete({ siteID, boardID, threadID, postID }, cb) {
if (!siteID) { siteID = g.SITE.ID } if (!siteID) { siteID = g.SITE.ID }
if (!this.data[siteID]) { return } if (!this.data[siteID]) { return }
return this.save(() => { return this.save(() => {
if (postID) { if (postID) {
if (!this.data[siteID].boards[boardID]?.[threadID]) { return } if (!this.data[siteID].boards[boardID]?.[threadID]) { return }
delete this.data[siteID].boards[boardID][threadID][postID] delete this.data[siteID].boards[boardID][threadID][postID]
return this.deleteIfEmpty({siteID, boardID, threadID}) return this.deleteIfEmpty({ siteID, boardID, threadID })
} else if (threadID) { } else if (threadID) {
if (!this.data[siteID].boards[boardID]) { return } if (!this.data[siteID].boards[boardID]) { return }
delete this.data[siteID].boards[boardID][threadID] delete this.data[siteID].boards[boardID][threadID]
return this.deleteIfEmpty({siteID, boardID}) return this.deleteIfEmpty({ siteID, boardID })
} else { } else {
return delete this.data[siteID].boards[boardID] return delete this.data[siteID].boards[boardID]
} }
} }
, cb) , cb)
} }
deleteIfEmpty({siteID, boardID, threadID}) { deleteIfEmpty({ siteID, boardID, threadID }) {
if (!this.data[siteID]) { return } if (!this.data[siteID]) { return }
if (threadID) { if (threadID) {
if (!Object.keys(this.data[siteID].boards[boardID][threadID]).length) { if (!Object.keys(this.data[siteID].boards[boardID][threadID]).length) {
delete this.data[siteID].boards[boardID][threadID] delete this.data[siteID].boards[boardID][threadID]
return this.deleteIfEmpty({siteID, boardID}) return this.deleteIfEmpty({ siteID, boardID })
} }
} else if (!Object.keys(this.data[siteID].boards[boardID]).length) { } else if (!Object.keys(this.data[siteID].boards[boardID]).length) {
return delete this.data[siteID].boards[boardID] return delete this.data[siteID].boards[boardID]
@ -111,10 +116,10 @@ export default class DataBoard {
return this.save(() => { return this.save(() => {
return this.setUnsafe(data) return this.setUnsafe(data)
} }
, cb) , cb)
} }
setUnsafe({siteID, boardID, threadID, postID, val}) { setUnsafe({ siteID, boardID, threadID, postID, val }) {
if (!siteID) { siteID = g.SITE.ID } if (!siteID) { siteID = g.SITE.ID }
if (!this.data[siteID]) { this.data[siteID] = { boards: dict() } } if (!this.data[siteID]) { this.data[siteID] = { boards: dict() } }
if (postID !== undefined) { if (postID !== undefined) {
@ -127,7 +132,7 @@ export default class DataBoard {
} }
} }
extend({siteID, boardID, threadID, postID, val}, cb) { extend({ siteID, boardID, threadID, postID, val }, cb) {
return this.save(() => { return this.save(() => {
const oldVal = this.get({ siteID, boardID, threadID, postID, defaultValue: dict() }) const oldVal = this.get({ siteID, boardID, threadID, postID, defaultValue: dict() })
for (const key in val) { for (const key in val) {
@ -138,18 +143,18 @@ export default class DataBoard {
oldVal[key] = subVal oldVal[key] = subVal
} }
} }
return this.setUnsafe({siteID, boardID, threadID, postID, val: oldVal}) return this.setUnsafe({ siteID, boardID, threadID, postID, val: oldVal })
} }
, cb) , cb)
} }
setLastChecked(key='lastChecked') { setLastChecked(key = 'lastChecked') {
return this.save(() => { return this.save(() => {
return this.data[key] = Date.now() return this.data[key] = Date.now()
}) })
} }
get({siteID, boardID, threadID, postID, defaultValue}) { get({ siteID, boardID, threadID, postID, defaultValue }) {
let board, val let board, val
if (!siteID) { siteID = g.SITE.ID } if (!siteID) { siteID = g.SITE.ID }
if (board = this.data[siteID]?.boards[boardID]) { if (board = this.data[siteID]?.boards[boardID]) {
@ -169,7 +174,7 @@ export default class DataBoard {
} else if (thread = board[threadID]) { } else if (thread = board[threadID]) {
val = (postID != null) ? val = (postID != null) ?
thread[postID] thread[postID]
: :
thread thread
} }
} }
@ -181,7 +186,7 @@ export default class DataBoard {
const siteID = g.SITE.ID const siteID = g.SITE.ID
for (boardID in this.data[siteID].boards) { for (boardID in this.data[siteID].boards) {
const val = this.data[siteID].boards[boardID] const val = this.data[siteID].boards[boardID]
this.deleteIfEmpty({siteID, boardID}) this.deleteIfEmpty({ siteID, boardID })
} }
const now = Date.now() const now = Date.now()
if (now - (2 * HOUR) >= ((middle = this.data[siteID].lastChecked || 0)) || middle > now) { if (now - (2 * HOUR) >= ((middle = this.data[siteID].lastChecked || 0)) || middle > now) {
@ -195,14 +200,14 @@ export default class DataBoard {
ajaxClean(boardID) { ajaxClean(boardID) {
const that = this const that = this
const siteID = g.SITE.ID const siteID = g.SITE.ID
const threadsList = g.SITE.urls.threadsListJSON?.({siteID, boardID}) const threadsList = g.SITE.urls.threadsListJSON?.({ siteID, boardID })
if (!threadsList) { return } if (!threadsList) { return }
return $.cache(threadsList, function() { return $.cache(threadsList, function () {
if (this.status !== 200) { return } if (this.status !== 200) { return }
const archiveList = g.SITE.urls.archiveListJSON?.({siteID, boardID}) const archiveList = g.SITE.urls.archiveListJSON?.({ siteID, boardID })
if (!archiveList) { return that.ajaxCleanParse(boardID, this.response) } if (!archiveList) { return that.ajaxCleanParse(boardID, this.response) }
const response1 = this.response const response1 = this.response
return $.cache(archiveList, function() { return $.cache(archiveList, function () {
if ((this.status !== 200) && (!!g.SITE.archivedBoardsKnown || (this.status !== 404))) { return } if ((this.status !== 200) && (!!g.SITE.archivedBoardsKnown || (this.status !== 404))) { return }
return that.ajaxCleanParse(boardID, response1, this.response) return that.ajaxCleanParse(boardID, response1, this.response)
}) })
@ -228,7 +233,7 @@ export default class DataBoard {
} }
} }
this.data[siteID].boards[boardID] = threads this.data[siteID].boards[boardID] = threads
this.deleteIfEmpty({siteID, boardID}) this.deleteIfEmpty({ siteID, boardID })
return $.set(this.key, this.data) return $.set(this.key, this.data)
} }

View File

@ -1,7 +1,7 @@
import Redirect from "../Archive/Redirect" import Redirect from "../Archive/Redirect"
import Get from "../General/Get" import Get from "../General/Get"
import Index from "../General/Index" import Index from "../General/Index"
import { Conf, d,E, g } from "../globals/globals" import { Conf, d, E, g } from "../globals/globals"
import ImageHost from "../Images/ImageHost" import ImageHost from "../Images/ImageHost"
import Main from "../main/Main" import Main from "../main/Main"
import $ from "../platform/$" import $ from "../platform/$"
@ -21,30 +21,37 @@ import Thread from "./Thread"
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/ */
export default class Fetcher { export default class Fetcher {
archiveTags: { '\n': { innerHTML: string }; '[b]': { innerHTML: string }; '[/b]': { innerHTML: string }; '[spoiler]': { innerHTML: string }; '[/spoiler]': { innerHTML: string }; '[code]': { innerHTML: string }; '[/code]': { innerHTML: string }; '[moot]': { innerHTML: string }; '[/moot]': { innerHTML: string }; '[banned]': { innerHTML: string }; '[/banned]': { innerHTML: string }; '[fortune]'(text: string): { innerHTML: string }; '[/fortune]': { innerHTML: string }; '[i]': { innerHTML: string }; '[/i]': { innerHTML: string }; '[red]': { innerHTML: string }; '[/red]': { innerHTML: string }; '[green]': { innerHTML: string }; '[/green]': { innerHTML: string }; '[blue]': { innerHTML: string }; '[/blue]': { innerHTML: string } }
boardID: string
threadID: number
postID: number
root: HTMLElement
quoter: any
static flagCSS: any
static initClass() { static initClass() {
this.prototype.archiveTags = { this.prototype.archiveTags = {
'\n': {innerHTML: "<br>"}, '\n': { innerHTML: "<br>" },
'[b]': {innerHTML: "<b>"}, '[b]': { innerHTML: "<b>" },
'[/b]': {innerHTML: "</b>"}, '[/b]': { innerHTML: "</b>" },
'[spoiler]': {innerHTML: "<s>"}, '[spoiler]': { innerHTML: "<s>" },
'[/spoiler]': {innerHTML: "</s>"}, '[/spoiler]': { innerHTML: "</s>" },
'[code]': {innerHTML: "<pre class=\"prettyprint\">"}, '[code]': { innerHTML: "<pre class=\"prettyprint\">" },
'[/code]': {innerHTML: "</pre>"}, '[/code]': { innerHTML: "</pre>" },
'[moot]': {innerHTML: "<div style=\"padding:5px;margin-left:.5em;border-color:#faa;border:2px dashed rgba(255,0,0,.1);border-radius:2px\">"}, '[moot]': { innerHTML: "<div style=\"padding:5px;margin-left:.5em;border-color:#faa;border:2px dashed rgba(255,0,0,.1);border-radius:2px\">" },
'[/moot]': {innerHTML: "</div>"}, '[/moot]': { innerHTML: "</div>" },
'[banned]': {innerHTML: "<strong style=\"color: red;\">"}, '[banned]': { innerHTML: "<strong style=\"color: red;\">" },
'[/banned]': {innerHTML: "</strong>"}, '[/banned]': { innerHTML: "</strong>" },
'[fortune]'(text) { return {innerHTML: "<span class=\"fortune\" style=\"color:" + E(text.match(/#\w+|$/)[0]) + "\"><b>"} }, '[fortune]'(text) { return { innerHTML: "<span class=\"fortune\" style=\"color:" + E(text.match(/#\w+|$/)[0]) + "\"><b>" } },
'[/fortune]': {innerHTML: "</b></span>"}, '[/fortune]': { innerHTML: "</b></span>" },
'[i]': {innerHTML: "<span class=\"mu-i\">"}, '[i]': { innerHTML: "<span class=\"mu-i\">" },
'[/i]': {innerHTML: "</span>"}, '[/i]': { innerHTML: "</span>" },
'[red]': {innerHTML: "<span class=\"mu-r\">"}, '[red]': { innerHTML: "<span class=\"mu-r\">" },
'[/red]': {innerHTML: "</span>"}, '[/red]': { innerHTML: "</span>" },
'[green]': {innerHTML: "<span class=\"mu-g\">"}, '[green]': { innerHTML: "<span class=\"mu-g\">" },
'[/green]': {innerHTML: "</span>"}, '[/green]': { innerHTML: "</span>" },
'[blue]': {innerHTML: "<span class=\"mu-b\">"}, '[blue]': { innerHTML: "<span class=\"mu-b\">" },
'[/blue]': {innerHTML: "</span>"} '[/blue]': { innerHTML: "</span>" }
} }
} }
constructor(boardID, threadID, postID, root, quoter) { constructor(boardID, threadID, postID, root, quoter) {
@ -61,8 +68,8 @@ export default class Fetcher {
// 4chan X catalog data // 4chan X catalog data
if ((post = Index.replyData?.[`${this.boardID}.${this.postID}`]) && (thread = g.threads.get(`${this.boardID}.${this.threadID}`))) { if ((post = Index.replyData?.[`${this.boardID}.${this.postID}`]) && (thread = g.threads.get(`${this.boardID}.${this.threadID}`))) {
const board = g.boards[this.boardID] const board = g.boards[this.boardID]
post = new Post(g.SITE.Build.postFromObject(post, this.boardID), thread, board, {isFetchedQuote: true}) post = new Post(g.SITE.Build.postFromObject(post, this.boardID), thread, board, { isFetchedQuote: true })
Main.callbackNodes('Post', [post]) Main.callbackNodes('Post', [post])
this.insert(post) this.insert(post)
return return
@ -71,7 +78,7 @@ export default class Fetcher {
this.root.textContent = `Loading post No.${this.postID}...` this.root.textContent = `Loading post No.${this.postID}...`
if (this.threadID) { if (this.threadID) {
const that = this const that = this
$.cache(g.SITE.urls.threadJSON({boardID: this.boardID, threadID: this.threadID}), function({isCached}) { $.cache(g.SITE.urls.threadJSON({ boardID: this.boardID, threadID: this.threadID }), function ({ isCached }) {
return that.fetchedPost(this, isCached) return that.fetchedPost(this, isCached)
}) })
} else { } else {
@ -87,14 +94,14 @@ export default class Fetcher {
Main.callbackNodes('Post', [clone]) Main.callbackNodes('Post', [clone])
// Get rid of the side arrows/stubs. // Get rid of the side arrows/stubs.
const {nodes} = clone const { nodes } = clone
$.rmAll(nodes.root) $.rmAll(nodes.root)
$.add(nodes.root, nodes.post) $.add(nodes.root, nodes.post)
// Indicate links to the containing post. // Indicate links to the containing post.
const quotes = [...clone.nodes.quotelinks, ...clone.nodes.backlinks] const quotes = [...clone.nodes.quotelinks, ...clone.nodes.backlinks]
for (const quote of quotes) { for (const quote of quotes) {
const {boardID, postID} = Get.postDataFromLink(quote) const { boardID, postID } = Get.postDataFromLink(quote)
if ((postID === this.quoter.ID) && (boardID === this.quoter.board.ID)) { if ((postID === this.quoter.ID) && (boardID === this.quoter.board.ID)) {
$.addClass(quote, 'forwardlink') $.addClass(quote, 'forwardlink')
} }
@ -125,7 +132,7 @@ export default class Fetcher {
return return
} }
const {status} = req const { status } = req
if (status !== 200) { if (status !== 200) {
// The thread can die by the time we check a quote. // The thread can die by the time we check a quote.
if (status && this.archivedPost()) { return } if (status && this.archivedPost()) { return }
@ -134,14 +141,14 @@ export default class Fetcher {
this.root.textContent = this.root.textContent =
status === 404 ? status === 404 ?
`Thread No.${this.threadID} 404'd.` `Thread No.${this.threadID} 404'd.`
: !status ? : !status ?
'Connection Error' 'Connection Error'
: :
`Error ${req.statusText} (${req.status}).` `Error ${req.statusText} (${req.status}).`
return return
} }
const {posts} = req.response const { posts } = req.response
g.SITE.Build.spoilerRange[this.boardID] = posts[0].custom_spoiler g.SITE.Build.spoilerRange[this.boardID] = posts[0].custom_spoiler
for (post of posts) { for (post of posts) {
if (post.no === this.postID) { break } if (post.no === this.postID) { break }
@ -150,10 +157,10 @@ export default class Fetcher {
if (post.no !== this.postID) { if (post.no !== this.postID) {
// Cached requests can be stale and must be rechecked. // Cached requests can be stale and must be rechecked.
if (isCached) { if (isCached) {
const api = g.SITE.urls.threadJSON({boardID: this.boardID, threadID: this.threadID}) const api = g.SITE.urls.threadJSON({ boardID: this.boardID, threadID: this.threadID })
$.cleanCache(url => url === api) $.cleanCache(url => url === api)
const that = this const that = this
$.cache(api, function() { $.cache(api, function () {
return that.fetchedPost(this, false) return that.fetchedPost(this, false)
}) })
return return
@ -171,7 +178,7 @@ export default class Fetcher {
new Board(this.boardID) new Board(this.boardID)
const thread = g.threads.get(`${this.boardID}.${this.threadID}`) || const thread = g.threads.get(`${this.boardID}.${this.threadID}`) ||
new Thread(this.threadID, board) new Thread(this.threadID, board)
post = new Post(g.SITE.Build.postFromObject(post, this.boardID), thread, board, {isFetchedQuote: true}) post = new Post(g.SITE.Build.postFromObject(post, this.boardID), thread, board, { isFetchedQuote: true })
Main.callbackNodes('Post', [post]) Main.callbackNodes('Post', [post])
return this.insert(post) return this.insert(post)
} }
@ -179,14 +186,14 @@ export default class Fetcher {
archivedPost() { archivedPost() {
let url let url
if (!Conf['Resurrect Quotes']) { return false } if (!Conf['Resurrect Quotes']) { return false }
if (!(url = Redirect.to('post', {boardID: this.boardID, postID: this.postID}))) { return false } if (!(url = Redirect.to('post', { boardID: this.boardID, postID: this.postID }))) { return false }
const archive = Redirect.data.post[this.boardID] const archive = Redirect.data.post[this.boardID]
const encryptionOK = /^https:\/\//.test(url) || (location.protocol === 'http:') const encryptionOK = /^https:\/\//.test(url) || (location.protocol === 'http:')
if (encryptionOK || Conf['Exempt Archives from Encryption']) { if (encryptionOK || Conf['Exempt Archives from Encryption']) {
const that = this const that = this
CrossOrigin.cache(url, function() { CrossOrigin.cache(url, function () {
if (!encryptionOK && this.response?.media) { if (!encryptionOK && this.response?.media) {
const {media} = this.response const { media } = this.response
for (const key in media) { for (const key in media) {
// Image/thumbnail URLs loaded over HTTP can be modified in transit. // Image/thumbnail URLs loaded over HTTP can be modified in transit.
// Require them to be from an HTTP host so that no referrer is sent to them from an HTTPS page. // Require them to be from an HTTP host so that no referrer is sent to them from an HTTPS page.
@ -237,42 +244,43 @@ export default class Fetcher {
} else { } else {
const greentext = text[0] === '>' const greentext = text[0] === '>'
text = text.replace(/(\[\/?[a-z]+):lit(\])/g, '$1$2') text = text.replace(/(\[\/?[a-z]+):lit(\])/g, '$1$2')
text = text.split(/(>>(?:>\/[a-z\d]+\/)?\d+)/g).map((text2, j) => text = text.split(/(>>(?:>\/[a-z\d]+\/)?\d+)/g).map((text2, j) => { ((j % 2) ? "<span class=\"deadlink\">" + E(text2) + "</span>" : E(text2)) })
{((j % 2) ? "<span class=\"deadlink\">" + E(text2) + "</span>" : E(text2))}) text = { innerHTML: ((greentext) ? "<span class=\"quote\">" + E.cat(text) + "</span>" : E.cat(text)) }
text = {innerHTML: ((greentext) ? "<span class=\"quote\">" + E.cat(text) + "</span>" : E.cat(text))}
result.push(text) result.push(text)
} }
} }
return result return result
})() })()
comment = {innerHTML: E.cat(comment)} comment = { innerHTML: E.cat(comment) }
this.threadID = +data.thread_num this.threadID = +data.thread_num
const o = { const o = {
ID: this.postID, ID: this.postID,
threadID: this.threadID, threadID: this.threadID,
boardID: this.boardID, boardID: this.boardID,
isReply: this.postID !== this.threadID isReply: this.postID !== this.threadID
} }
o.info = { o.info = {
subject: data.title, subject: data.title,
email: data.email, email: data.email,
name: data.name || '', name: data.name || '',
tripcode: data.trip, tripcode: data.trip,
capcode: (() => { switch (data.capcode) { capcode: (() => {
// https://github.com/pleebe/FoolFuuka/blob/bf4224eed04637a4d0bd4411c2bf5f9945dfec0b/assets/themes/foolz/foolfuuka-theme-fuuka/src/Partial/Board.php#L77 switch (data.capcode) {
case 'M': return 'Mod' // https://github.com/pleebe/FoolFuuka/blob/bf4224eed04637a4d0bd4411c2bf5f9945dfec0b/assets/themes/foolz/foolfuuka-theme-fuuka/src/Partial/Board.php#L77
case 'A': return 'Admin' case 'M': return 'Mod'
case 'D': return 'Developer' case 'A': return 'Admin'
case 'V': return 'Verified' case 'D': return 'Developer'
case 'F': return 'Founder' case 'V': return 'Verified'
case 'G': return 'Manager' case 'F': return 'Founder'
} })(), case 'G': return 'Manager'
}
})(),
uniqueID: data.poster_hash, uniqueID: data.poster_hash,
flagCode: data.poster_country, flagCode: data.poster_country,
flagCodeTroll: data.troll_country_code, flagCodeTroll: data.troll_country_code,
flag: data.poster_country_name || data.troll_country_name, flag: data.poster_country_name || data.troll_country_name,
dateUTC: data.timestamp, dateUTC: data.timestamp,
dateText: data.fourchan_date, dateText: data.fourchan_date,
commentHTML: comment commentHTML: comment
} }
@ -280,26 +288,26 @@ export default class Fetcher {
if (data.media && !!+data.media.banned) { if (data.media && !!+data.media.banned) {
o.fileDeleted = true o.fileDeleted = true
} else if (data.media?.media_filename) { } else if (data.media?.media_filename) {
let {thumb_link} = data.media let { thumb_link } = data.media
// Fix URLs missing origin // Fix URLs missing origin
if (thumb_link?.[0] === '/') { thumb_link = url.split('/', 3).join('/') + thumb_link } if (thumb_link?.[0] === '/') { thumb_link = url.split('/', 3).join('/') + thumb_link }
if (!Redirect.securityCheck(thumb_link)) { thumb_link = '' } if (!Redirect.securityCheck(thumb_link)) { thumb_link = '' }
let media_link = Redirect.to('file', {boardID: this.boardID, filename: data.media.media_orig}) let media_link = Redirect.to('file', { boardID: this.boardID, filename: data.media.media_orig })
if (!Redirect.securityCheck(media_link)) { media_link = '' } if (!Redirect.securityCheck(media_link)) { media_link = '' }
o.file = { o.file = {
name: data.media.media_filename, name: data.media.media_filename,
url: media_link || url: media_link ||
(this.boardID === 'f' ? (this.boardID === 'f' ?
`${location.protocol}//${ImageHost.flashHost()}/${this.boardID}/${encodeURIComponent(E(data.media.media_filename))}` `${location.protocol}//${ImageHost.flashHost()}/${this.boardID}/${encodeURIComponent(E(data.media.media_filename))}`
: :
`${location.protocol}//${ImageHost.host()}/${this.boardID}/${data.media.media_orig}`), `${location.protocol}//${ImageHost.host()}/${this.boardID}/${data.media.media_orig}`),
height: data.media.media_h, height: data.media.media_h,
width: data.media.media_w, width: data.media.media_w,
MD5: data.media.media_hash, MD5: data.media.media_hash,
size: $.bytesToString(data.media.media_size), size: $.bytesToString(data.media.media_size),
thumbURL: thumb_link || `${location.protocol}//${ImageHost.thumbHost()}/${this.boardID}/${data.media.preview_orig}`, thumbURL: thumb_link || `${location.protocol}//${ImageHost.thumbHost()}/${this.boardID}/${data.media.preview_orig}`,
theight: data.media.preview_h, theight: data.media.preview_h,
twidth: data.media.preview_w, twidth: data.media.preview_w,
isSpoiler: data.media.spoiler === '1' isSpoiler: data.media.spoiler === '1'
} }
if (!/\.pdf$/.test(o.file.url)) { o.file.dimensions = `${o.file.width}x${o.file.height}` } if (!/\.pdf$/.test(o.file.url)) { o.file.dimensions = `${o.file.width}x${o.file.height}` }
@ -311,7 +319,7 @@ export default class Fetcher {
new Board(this.boardID) new Board(this.boardID)
const thread = g.threads.get(`${this.boardID}.${this.threadID}`) || const thread = g.threads.get(`${this.boardID}.${this.threadID}`) ||
new Thread(this.threadID, board) new Thread(this.threadID, board)
post = new Post(g.SITE.Build.post(o), thread, board, {isFetchedQuote: true}) post = new Post(g.SITE.Build.post(o), thread, board, { isFetchedQuote: true })
post.kill() post.kill()
if (post.file) { post.file.thumbURL = o.file.thumbURL } if (post.file) { post.file.thumbURL = o.file.thumbURL }
Main.callbackNodes('Post', [post]) Main.callbackNodes('Post', [post])

View File

@ -1,5 +1,5 @@
import Get from "../General/Get" import Get from "../General/Get"
import { Conf,g } from "../globals/globals" import { Conf, g } from "../globals/globals"
import ImageExpand from "../Images/ImageExpand" import ImageExpand from "../Images/ImageExpand"
import $ from "../platform/$" import $ from "../platform/$"
import $$ from "../platform/$$" import $$ from "../platform/$$"
@ -8,41 +8,41 @@ import Callbacks from "./Callbacks"
import type Thread from "./Thread" import type Thread from "./Thread"
export default class Post { export default class Post {
declare root: HTMLElement declare root: HTMLElement
declare thread: Thread declare thread: Thread
declare board: Board declare board: Board
declare ID: number declare ID: number
declare postID: number declare postID: number
declare threadID: number declare threadID: number
declare boardID: number | string declare boardID: number | string
declare siteID: number | string declare siteID: number | string
declare fullID: string declare fullID: string
declare context: Post declare context: Post
declare isReply: boolean declare isReply: boolean
declare nodes: ReturnType<Post['parseNodes']> declare nodes: ReturnType<Post['parseNodes']>
declare isDead: boolean declare isDead: boolean
declare isHidden: boolean declare isHidden: boolean
declare clones: any[] declare clones: any[]
declare isRebuilt?: boolean declare isRebuilt?: boolean
declare isFetchedQuote: boolean declare isFetchedQuote: boolean
declare isClone: boolean declare isClone: boolean
declare quotes: string[] declare quotes: string[]
declare file: ReturnType<Post['parseFile']> declare file: ReturnType<Post['parseFile']>
declare files: ReturnType<Post['parseFile']>[] declare files: ReturnType<Post['parseFile']>[]
declare info: { declare info: {
subject: string | undefined, subject: string | undefined,
name: string | undefined, name: string | undefined,
email: string | undefined, email: string | undefined,
tripcode: string | undefined, tripcode: string | undefined,
uniqueID: string | undefined, uniqueID: string | undefined,
capcode: string | undefined, capcode: string | undefined,
pass: string | undefined, pass: string | undefined,
flagCode: string | undefined, flagCode: string | undefined,
flagCodeTroll: string | undefined, flagCodeTroll: string | undefined,
flag: string | undefined, flag: string | undefined,
date: Date | undefined, date: Date | undefined,
nameBlock: string, nameBlock: string,
} }
// because of a circular dependency $ might not be initialized, so we can't use $.el // because of a circular dependency $ might not be initialized, so we can't use $.el
@ -57,7 +57,7 @@ export default class Post {
toString() { return this.ID } toString() { return this.ID }
constructor(root?: HTMLElement, thread?: Thread, board?: Board, flags={}) { constructor(root?: HTMLElement, thread?: Thread, board?: Board, flags = {}) {
// <% if (readJSON('/.tests_enabled')) { %> // <% if (readJSON('/.tests_enabled')) { %>
// @normalizedOriginal = Test.normalize root // @normalizedOriginal = Test.normalize root
// <% } %> // <% } %>
@ -69,14 +69,14 @@ export default class Post {
this.thread = thread this.thread = thread
this.board = board this.board = board
$.extend(this, flags) $.extend(this, flags)
this.ID = +root.id.match(/\d*$/)[0] this.ID = +root.id.match(/\d*$/)[0]
this.postID = this.ID this.postID = this.ID
this.threadID = this.thread.ID this.threadID = this.thread.ID
this.boardID = this.board.ID this.boardID = this.board.ID
this.siteID = g.SITE.ID this.siteID = g.SITE.ID
this.fullID = `${this.board}.${this.ID}` this.fullID = `${this.board}.${this.ID}`
this.context = this this.context = this
this.isReply = (this.ID !== this.threadID) this.isReply = (this.ID !== this.threadID)
root.dataset.fullID = this.fullID root.dataset.fullID = this.fullID
@ -100,17 +100,17 @@ export default class Post {
const tripcode = this.nodes.tripcode?.textContent const tripcode = this.nodes.tripcode?.textContent
this.info = { this.info = {
subject: this.nodes.subject?.textContent || undefined, subject: this.nodes.subject?.textContent || undefined,
name, name,
email: this.nodes.email ? decodeURIComponent(this.nodes.email.href.replace(/^mailto:/, '')) : undefined, email: this.nodes.email ? decodeURIComponent(this.nodes.email.href.replace(/^mailto:/, '')) : undefined,
tripcode, tripcode,
uniqueID: this.nodes.uniqueID?.textContent, uniqueID: this.nodes.uniqueID?.textContent,
capcode: this.nodes.capcode?.textContent.replace('## ', ''), capcode: this.nodes.capcode?.textContent.replace('## ', ''),
pass: this.nodes.pass?.title.match(/\d*$/)[0], pass: this.nodes.pass?.title.match(/\d*$/)[0],
flagCode: this.nodes.flag?.className.match(/flag-(\w+)/)?.[1].toUpperCase(), flagCode: this.nodes.flag?.className.match(/flag-(\w+)/)?.[1].toUpperCase(),
flagCodeTroll: this.nodes.flag?.className.match(/bfl-(\w+)/)?.[1].toUpperCase(), flagCodeTroll: this.nodes.flag?.className.match(/bfl-(\w+)/)?.[1].toUpperCase(),
flag: this.nodes.flag?.title, flag: this.nodes.flag?.title,
date: this.nodes.date ? g.SITE.parseDate(this.nodes.date) : undefined, date: this.nodes.date ? g.SITE.parseDate(this.nodes.date) : undefined,
nameBlock: Conf['Anonymize'] ? 'Anonymous' : `${name || ''} ${tripcode || ''}`.trim(), nameBlock: Conf['Anonymize'] ? 'Anonymous' : `${name || ''} ${tripcode || ''}`.trim(),
} }
@ -121,7 +121,7 @@ export default class Post {
this.parseQuotes() this.parseQuotes()
this.parseFiles() this.parseFiles()
this.isDead = false this.isDead = false
this.isHidden = false this.isHidden = false
this.clones = [] this.clones = []
@ -149,31 +149,31 @@ export default class Post {
const info: HTMLElement = $(s.infoRoot, post) const info: HTMLElement = $(s.infoRoot, post)
interface Node { interface Node {
root: HTMLElement, root: HTMLElement,
bottom: false | HTMLElement, bottom: false | HTMLElement,
post: HTMLElement, post: HTMLElement,
info: HTMLElement, info: HTMLElement,
comment: HTMLElement; comment: HTMLElement;
quotelinks: HTMLAnchorElement[], quotelinks: HTMLAnchorElement[],
archivelinks: HTMLAnchorElement[], archivelinks: HTMLAnchorElement[],
embedlinks: HTMLAnchorElement[], embedlinks: HTMLAnchorElement[],
backlinks: HTMLCollectionOf<HTMLAnchorElement>; backlinks: HTMLCollectionOf<HTMLAnchorElement>;
uniqueIDRoot: any, uniqueIDRoot: any,
uniqueID: any, uniqueID: any,
} }
const nodes: Node & Partial<Record<keyof Post['info'], HTMLElement>> = { const nodes: Node & Partial<Record<keyof Post['info'], HTMLElement>> = {
root, root,
bottom: this.isReply || !g.SITE.isOPContainerThread ? root : $(s.opBottom, root), bottom: this.isReply || !g.SITE.isOPContainerThread ? root : $(s.opBottom, root),
post, post,
info, info,
comment: $(s.comment, post), comment: $(s.comment, post),
quotelinks: [], quotelinks: [],
archivelinks: [], archivelinks: [],
embedlinks: [], embedlinks: [],
backlinks: post.getElementsByClassName('backlink') as HTMLCollectionOf<HTMLAnchorElement>, backlinks: post.getElementsByClassName('backlink') as HTMLCollectionOf<HTMLAnchorElement>,
uniqueIDRoot: undefined as any, uniqueIDRoot: undefined as any,
uniqueID: undefined as any, uniqueID: undefined as any,
} }
for (const key in s.info) { for (const key in s.info) {
const selector = s.info[key] const selector = s.info[key]
@ -293,13 +293,13 @@ export default class Post {
parseFile(fileRoot: HTMLElement) { parseFile(fileRoot: HTMLElement) {
interface File { interface File {
text: string, text: string,
link: HTMLAnchorElement, link: HTMLAnchorElement,
thumb: HTMLElement, thumb: HTMLElement,
thumbLink: HTMLElement, thumbLink: HTMLElement,
size: string, size: string,
sizeInBytes: number, sizeInBytes: number,
isDead: boolean, isDead: boolean,
} }
const file: Partial<File> = { isDead: false } const file: Partial<File> = { isDead: false }
@ -313,20 +313,20 @@ export default class Post {
if (!g.SITE.parseFile(this, file)) { return } if (!g.SITE.parseFile(this, file)) { return }
$.extend(file, { $.extend(file, {
url: file.link.href, url: file.link.href,
isImage: $.isImage(file.link.href), isImage: $.isImage(file.link.href),
isVideo: $.isVideo(file.link.href) isVideo: $.isVideo(file.link.href)
} }
) )
let size = +file.size.match(/[\d.]+/)[0] let size = +file.size.match(/[\d.]+/)[0]
let unit = ['B', 'KB', 'MB', 'GB'].indexOf(file.size.match(/\w+$/)[0]) let unit = ['B', 'KB', 'MB', 'GB'].indexOf(file.size.match(/\w+$/)[0])
while (unit-- > 0) { size *= 1024 } while (unit-- > 0) { size *= 1024 }
file.sizeInBytes = size file.sizeInBytes = size
return file as File return file as File
} }
kill(file, index=0) { kill(file, index = 0) {
let strong let strong
if (file) { if (file) {
if (this.isDead || this.files[index].isDead) { return } if (this.isDead || this.files[index].isDead) { return }
@ -341,7 +341,7 @@ export default class Post {
if (!(strong = $('strong.warning', this.nodes.info))) { if (!(strong = $('strong.warning', this.nodes.info))) {
strong = $.el('strong', strong = $.el('strong',
{className: 'warning'}) { className: 'warning' })
$.after($('input', this.nodes.info), strong) $.after($('input', this.nodes.info), strong)
} }
strong.textContent = file ? '[File deleted]' : '[Deleted]' strong.textContent = file ? '[File deleted]' : '[Deleted]'

View File

@ -4,6 +4,9 @@
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/ */
export default class RandomAccessList { export default class RandomAccessList {
length: number
last: any
first: any
constructor(items) { constructor(items) {
this.length = 0 this.length = 0
if (items) { for (const item of items) { this.push(item) } } if (items) { for (const item of items) { this.push(item) } }
@ -11,10 +14,10 @@ export default class RandomAccessList {
push(data) { push(data) {
let item let item
let {ID} = data let { ID } = data
if (!ID) { ID = data.id } if (!ID) { ID = data.id }
if (this[ID]) { return } if (this[ID]) { return }
const {last} = this const { last } = this
this[ID] = (item = { this[ID] = (item = {
prev: last, prev: last,
next: null, next: null,
@ -24,7 +27,7 @@ export default class RandomAccessList {
item.prev = last item.prev = last
this.last = last ? this.last = last ?
(last.next = item) (last.next = item)
: :
(this.first = item) (this.first = item)
return this.length++ return this.length++
} }
@ -34,7 +37,7 @@ export default class RandomAccessList {
this.rmi(item) this.rmi(item)
const {prev} = root const { prev } = root
root.prev = item root.prev = item
item.next = root item.next = root
item.prev = prev item.prev = prev
@ -50,7 +53,7 @@ export default class RandomAccessList {
this.rmi(item) this.rmi(item)
const {next} = root const { next } = root
root.next = item root.next = item
item.prev = root item.prev = root
item.next = next item.next = next
@ -62,10 +65,10 @@ export default class RandomAccessList {
} }
prepend(item) { prepend(item) {
const {first} = this const { first } = this
if ((item === first) || !this[item.ID]) { return } if ((item === first) || !this[item.ID]) { return }
this.rmi(item) this.rmi(item)
item.next = first item.next = first
if (first) { if (first) {
first.prev = item first.prev = item
} else { } else {
@ -97,7 +100,7 @@ export default class RandomAccessList {
} }
rmi(item) { rmi(item) {
const {prev, next} = item const { prev, next } = item
if (prev) { if (prev) {
prev.next = next prev.next = next
} else { } else {

View File

@ -4,6 +4,8 @@
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/ */
class ShimSet { class ShimSet {
elements: any
size: number
constructor() { constructor() {
this.elements = $.dict() this.elements = $.dict()
this.size = 0 this.size = 0

View File

@ -1,11 +1,5 @@
import $ from "../platform/$" import $ from "../platform/$"
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
export default class SimpleDict<T> { export default class SimpleDict<T> {
keys: string[] keys: string[]
@ -13,14 +7,14 @@ export default class SimpleDict<T> {
this.keys = [] this.keys = []
} }
push(key, data: T) { push(key: string, data: T): T {
key = `${key}` key = `${key}`
if (!this[key]) { this.keys.push(key) } if (!this[key]) { this.keys.push(key) }
return this[key] = data return this[key] = data
} }
rm(key) { rm(key: string) {
let i let i: number
key = `${key}` key = `${key}`
if ((i = this.keys.indexOf(key)) !== -1) { if ((i = this.keys.indexOf(key)) !== -1) {
this.keys.splice(i, 1) this.keys.splice(i, 1)
@ -28,11 +22,11 @@ export default class SimpleDict<T> {
} }
} }
forEach(fn) { forEach(fn: (value: T) => void): void {
for (const key of [...Array.from(this.keys)]) { fn(this[key]) } for (const key of [...Array.from(this.keys)]) { fn(this[key]) }
} }
get(key): T { get(key: string): T {
if (key === 'keys') { if (key === 'keys') {
return undefined return undefined
} else { } else {

View File

@ -1,74 +1,92 @@
import { g } from "../globals/globals" import { g } from "../globals/globals"
import $ from "../platform/$" import $ from "../platform/$"
import Board from "./Board"
import Post from "./Post"
import SimpleDict from "./SimpleDict" import SimpleDict from "./SimpleDict"
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
export default class Thread { export default class Thread {
ID: number
OP: Post
isArchived: boolean
isClosed: boolean
lastPost: number
posts: SimpleDict<Post>
board: Board
threadID: number
boardID: number | string
siteID: number
fullID: string
isDead: boolean
isHidden: boolean
isSticky: boolean
postLimit: boolean
fileLimit: boolean
ipCount: number
json: JSON
catalogView: Node
nodes: { root: Post }
toString() { return this.ID } toString() { return this.ID }
constructor(ID, board) { constructor(ID: number | string, board: Board) {
this.board = board this.board = board
this.ID = +ID this.ID = +ID
this.threadID = this.ID this.threadID = this.ID
this.boardID = this.board.ID this.boardID = this.board.ID
this.siteID = g.SITE.ID this.siteID = g.SITE.ID
this.fullID = `${this.board}.${this.ID}` this.fullID = `${this.board}.${this.ID}`
this.posts = new SimpleDict() this.posts = new SimpleDict()
this.isDead = false this.isDead = false
this.isHidden = false this.isHidden = false
this.isSticky = false this.isSticky = false
this.isClosed = false this.isClosed = false
this.isArchived = false this.isArchived = false
this.postLimit = false this.postLimit = false
this.fileLimit = false this.fileLimit = false
this.lastPost = 0 this.lastPost = 0
this.ipCount = undefined this.ipCount = undefined
this.json = null this.json = null
this.OP = null this.OP = null
this.catalogView = null this.catalogView = null
this.nodes = this.nodes =
{root: null} { root: null }
this.board.threads.push(this.ID, this) this.board.threads.push(this.ID, this)
g.threads.push(this.fullID, this) g.threads.push(this.fullID, this)
} }
setPage(pageNum) { setPage(pageNum: number) {
let icon let icon: HTMLElement
const {info, reply} = this.OP.nodes const { info, reply } = this.OP.nodes
if (!(icon = $('.page-num', info))) { if (!(icon = $('.page-num', info))) {
icon = $.el('span', {className: 'page-num'}) icon = $.el('span', { className: 'page-num' })
$.replace(reply.parentNode.previousSibling, [$.tn(' '), icon, $.tn(' ')]) $.replace(reply.parentNode.previousSibling, [$.tn(' '), icon, $.tn(' ')])
} }
icon.title = `This thread is on page ${pageNum} in the original index.` icon.title = `This thread is on page ${pageNum} in the original index.`
icon.textContent = `[${pageNum}]` icon.textContent = `[${pageNum}]`
if (this.catalogView) { return this.catalogView.nodes.pageCount.textContent = pageNum } if (this.catalogView) { return this.catalogView.nodes.pageCount.textContent = pageNum }
} }
setCount(type, count, reachedLimit) { setCount(type: string, count: number, reachedLimit: boolean) {
if (!this.catalogView) { return } if (!this.catalogView) { return }
const el = this.catalogView.nodes[`${type}Count`] const el = this.catalogView.nodes[`${type}Count`]
el.textContent = count el.textContent = count
return (reachedLimit ? $.addClass : $.rmClass)(el, 'warning') return (reachedLimit ? $.addClass : $.rmClass)(el, 'warning')
} }
setStatus(type, status) { setStatus(type: string, status: boolean) {
const name = `is${type}` const name = `is${type}`
if (this[name] === status) { return } if (this[name] === status) { return }
this[name] = status this[name] = status
if (!this.OP) { return } if (!this.OP) { return }
this.setIcon('Sticky', this.isSticky) this.setIcon('Sticky', this.isSticky)
this.setIcon('Closed', this.isClosed && !this.isArchived) this.setIcon('Closed', this.isClosed && !this.isArchived)
return this.setIcon('Archived', this.isArchived) return this.setIcon('Archived', this.isArchived)
} }
setIcon(type, status) { setIcon(type: string, status: boolean) {
const typeLC = type.toLowerCase() const typeLC = type.toLowerCase()
let icon = $(`.${typeLC}Icon`, this.OP.nodes.info) let icon = $(`.${typeLC}Icon`, this.OP.nodes.info)
if (!!icon === status) { return } if (!!icon === status) { return }
@ -81,18 +99,17 @@ export default class Thread {
} }
icon = $.el('img', { icon = $.el('img', {
src: `${g.SITE.Build.staticPath}${typeLC}${g.SITE.Build.gifIcon}`, src: `${g.SITE.Build.staticPath}${typeLC}${g.SITE.Build.gifIcon}`,
alt: type, alt: type,
title: type, title: type,
className: `${typeLC}Icon retina` className: `${typeLC}Icon retina`
} }, g.BOARD.ID)
)
if (g.BOARD.ID === 'f') { if (g.BOARD.ID === 'f') {
icon.style.cssText = 'height: 18px; width: 18px;' icon.style.cssText = 'height: 18px; width: 18px;'
} }
const root = (type !== 'Sticky') && this.isSticky ? const root = (type !== 'Sticky') && this.isSticky ?
$('.stickyIcon', this.OP.nodes.info) $('.stickyIcon', this.OP.nodes.info)
: :
$('.page-num', this.OP.nodes.info) || this.OP.nodes.quote $('.page-num', this.OP.nodes.info) || this.OP.nodes.quote
$.after(root, [$.tn(' '), icon]) $.after(root, [$.tn(' '), icon])
@ -106,7 +123,7 @@ export default class Thread {
collect() { collect() {
let n = 0 let n = 0
this.posts.forEach(function(post) { this.posts.forEach(function (post) {
if (post.clones.length) { if (post.clones.length) {
return n++ return n++
} else { } else {

View File

@ -18,10 +18,10 @@ const $ = (selector, root = document.body) => root.querySelector(selector)
$.id = id => d.getElementById(id) $.id = id => d.getElementById(id)
$.ajaxPage = function(url, options) { $.ajaxPage = function (url, options) {
if (options.responseType == null) { options.responseType = 'json' } if (options.responseType == null) { options.responseType = 'json' }
if (!options.type) { options.type = (options.form && 'post') || 'get' } if (!options.type) { options.type = (options.form && 'post') || 'get' }
const {onloadend, timeout, responseType, withCredentials, type, onprogress, form, headers} = options const { onloadend, timeout, responseType, withCredentials, type, onprogress, form, headers } = options
const r = new XMLHttpRequest() const r = new XMLHttpRequest()
r.open(type, url, true) r.open(type, url, true)
const object = headers || {} const object = headers || {}
@ -29,26 +29,26 @@ $.ajaxPage = function(url, options) {
const value = object[key] const value = object[key]
r.setRequestHeader(key, value) r.setRequestHeader(key, value)
} }
$.extend(r, {onloadend, timeout, responseType, withCredentials}) $.extend(r, { onloadend, timeout, responseType, withCredentials })
$.extend(r.upload, {onprogress}) $.extend(r.upload, { onprogress })
// connection error or content blocker // connection error or content blocker
$.on(r, 'error', function() { if (!r.status) { return c.warn(`4chan X failed to load: ${url}`) } }) $.on(r, 'error', function () { if (!r.status) { return c.warn(`4chan X failed to load: ${url}`) } })
r.send(form) r.send(form)
return r return r
} }
$.ready = function(fc) { $.ready = function (fc) {
if (d.readyState !== 'loading') { if (d.readyState !== 'loading') {
$.queueTask(fc) $.queueTask(fc)
return return
} }
const cb = function() { const cb = function () {
$.off(d, 'DOMContentLoaded', cb) $.off(d, 'DOMContentLoaded', cb)
return fc() return fc()
} }
return $.on(d, 'DOMContentLoaded', cb) return $.on(d, 'DOMContentLoaded', cb)
} }
$.formData = function(form) { $.formData = function (form) {
if (form instanceof HTMLFormElement) { if (form instanceof HTMLFormElement) {
return new FormData(form) return new FormData(form)
} }
@ -66,7 +66,7 @@ $.formData = function(form) {
return fd return fd
} }
$.extend = function(object, properties) { $.extend = function (object, properties) {
for (const key in properties) { for (const key in properties) {
const val = properties[key] const val = properties[key]
object[key] = val object[key] = val
@ -75,11 +75,11 @@ $.extend = function(object, properties) {
$.hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) $.hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key)
$.getOwn = function(obj, key) { $.getOwn = function (obj, key) {
if (Object.prototype.hasOwnProperty.call(obj, key)) { return obj[key] } else { return undefined } if (Object.prototype.hasOwnProperty.call(obj, key)) { return obj[key] } else { return undefined }
} }
$.ajax = (function() { $.ajax = (function () {
let pageXHR let pageXHR
// @ts-ignore // @ts-ignore
if (window.wrappedJSObject && !XMLHttpRequest.wrappedJSObject) { if (window.wrappedJSObject && !XMLHttpRequest.wrappedJSObject) {
@ -88,7 +88,7 @@ $.ajax = (function() {
pageXHR = XMLHttpRequest pageXHR = XMLHttpRequest
} }
const r = (function (url, options={}) { const r = (function (url, options = {}) {
if (options.responseType == null) { options.responseType = 'json' } if (options.responseType == null) { options.responseType = 'json' }
if (!options.type) { options.type = (options.form && 'post') || 'get' } if (!options.type) { options.type = (options.form && 'post') || 'get' }
// XXX https://forums.lanik.us/viewtopic.php?f=64&t=24173&p=78310 // XXX https://forums.lanik.us/viewtopic.php?f=64&t=24173&p=78310
@ -99,7 +99,7 @@ $.ajax = (function() {
return $.ajaxPage(url, options) return $.ajaxPage(url, options)
} }
} }
const {onloadend, timeout, responseType, withCredentials, type, onprogress, form, headers} = options const { onloadend, timeout, responseType, withCredentials, type, onprogress, form, headers } = options
const r = new pageXHR() const r = new pageXHR()
try { try {
r.open(type, url, true) r.open(type, url, true)
@ -108,10 +108,10 @@ $.ajax = (function() {
const value = object[key] const value = object[key]
r.setRequestHeader(key, value) r.setRequestHeader(key, value)
} }
$.extend(r, {onloadend, timeout, responseType, withCredentials}) $.extend(r, { onloadend, timeout, responseType, withCredentials })
$.extend(r.upload, {onprogress}) $.extend(r.upload, { onprogress })
// connection error or content blocker // connection error or content blocker
$.on(r, 'error', function() { if (!r.status) { return c.warn(`4chan X failed to load: ${url}`) } }) $.on(r, 'error', function () { if (!r.status) { return c.warn(`4chan X failed to load: ${url}`) } })
if (platform === 'crx') { if (platform === 'crx') {
// https://bugs.chromium.org/p/chromium/issues/detail?id=920638 // https://bugs.chromium.org/p/chromium/issues/detail?id=920638
$.on(r, 'load', () => { $.on(r, 'load', () => {
@ -125,7 +125,7 @@ $.ajax = (function() {
// XXX Some content blockers in Firefox (e.g. Adblock Plus and NoScript) throw an exception instead of simulating a connection error. // XXX Some content blockers in Firefox (e.g. Adblock Plus and NoScript) throw an exception instead of simulating a connection error.
if (err.result !== 0x805e0006) { throw err } if (err.result !== 0x805e0006) { throw err }
r.onloadend = onloadend r.onloadend = onloadend
$.queueTask($.event, 'error', null, r) $.queueTask($.event, 'error', null, r)
$.queueTask($.event, 'loadend', null, r) $.queueTask($.event, 'loadend', null, r)
} }
return r return r
@ -138,13 +138,13 @@ $.ajax = (function() {
let requestID = 0 let requestID = 0
const requests = dict() const requests = dict()
$.ajaxPageInit = function() { $.ajaxPageInit = function () {
$.global(function() { $.global(function () {
window.FCX.requests = Object.create(null) window.FCX.requests = Object.create(null)
document.addEventListener('4chanXAjax', function(e) { document.addEventListener('4chanXAjax', function (e) {
let fd, r let fd, r
const {url, timeout, responseType, withCredentials, type, onprogress, form, headers, id} = e.detail const { url, timeout, responseType, withCredentials, type, onprogress, form, headers, id } = e.detail
window.FCX.requests[id] = (r = new XMLHttpRequest()) window.FCX.requests[id] = (r = new XMLHttpRequest())
r.open(type, url, true) r.open(type, url, true)
const object = headers || {} const object = headers || {}
@ -156,21 +156,21 @@ $.ajax = (function() {
r.timeout = timeout r.timeout = timeout
r.withCredentials = withCredentials r.withCredentials = withCredentials
if (onprogress) { if (onprogress) {
r.upload.onprogress = function(e) { r.upload.onprogress = function (e) {
const {loaded, total} = e const { loaded, total } = e
const detail = {loaded, total, id} const detail = { loaded, total, id }
return document.dispatchEvent(new CustomEvent('4chanXAjaxProgress', {bubbles: true, detail})) return document.dispatchEvent(new CustomEvent('4chanXAjaxProgress', { bubbles: true, detail }))
} }
} }
r.onloadend = function() { r.onloadend = function () {
delete window.FCX.requests[id] delete window.FCX.requests[id]
const {status, statusText, response} = this const { status, statusText, response } = this
const responseHeaderString = this.getAllResponseHeaders() const responseHeaderString = this.getAllResponseHeaders()
const detail = {status, statusText, response, responseHeaderString, id} const detail = { status, statusText, response, responseHeaderString, id }
return document.dispatchEvent(new CustomEvent('4chanXAjaxLoadend', {bubbles: true, detail})) return document.dispatchEvent(new CustomEvent('4chanXAjaxLoadend', { bubbles: true, detail }))
} }
// connection error or content blocker // connection error or content blocker
r.onerror = function() { r.onerror = function () {
if (!r.status) { return console.warn(`4chan X failed to load: ${url}`) } if (!r.status) { return console.warn(`4chan X failed to load: ${url}`) }
} }
if (form) { if (form) {
@ -182,50 +182,50 @@ $.ajax = (function() {
fd = null fd = null
} }
return r.send(fd) return r.send(fd)
}
, false)
return document.addEventListener('4chanXAjaxAbort', function(e) {
let r
if (!(r = window.FCX.requests[e.detail.id])) { return }
return r.abort()
}
, false)
})
$.on(d, '4chanXAjaxProgress', function(e) {
let req
if (!(req = requests[e.detail.id])) { return }
return req.upload.onprogress.call(req.upload, e.detail)
})
return $.on(d, '4chanXAjaxLoadend', function(e) {
let req
if (!(req = requests[e.detail.id])) { return }
delete requests[e.detail.id]
if (e.detail.status) {
for (const key of ['status', 'statusText', 'response', 'responseHeaderString']) {
req[key] = e.detail[key]
} }
if (req.responseType === 'document') { , false)
req.response = new DOMParser().parseFromString(e.detail.response, 'text/html')
}
}
return req.onloadend()
})
}
return $.ajaxPage = function(url, options={}) { return document.addEventListener('4chanXAjaxAbort', function (e) {
let r
if (!(r = window.FCX.requests[e.detail.id])) { return }
return r.abort()
}
, false)
})
$.on(d, '4chanXAjaxProgress', function (e) {
let req
if (!(req = requests[e.detail.id])) { return }
return req.upload.onprogress.call(req.upload, e.detail)
})
return $.on(d, '4chanXAjaxLoadend', function (e) {
let req
if (!(req = requests[e.detail.id])) { return }
delete requests[e.detail.id]
if (e.detail.status) {
for (const key of ['status', 'statusText', 'response', 'responseHeaderString']) {
req[key] = e.detail[key]
}
if (req.responseType === 'document') {
req.response = new DOMParser().parseFromString(e.detail.response, 'text/html')
}
}
return req.onloadend()
})
}
return $.ajaxPage = function (url, options = {}) {
let req let req
let {onloadend, timeout, responseType, withCredentials, type, onprogress, form, headers} = options let { onloadend, timeout, responseType, withCredentials, type, onprogress, form, headers } = options
const id = requestID++ const id = requestID++
requests[id] = (req = new CrossOrigin.Request()) requests[id] = (req = new CrossOrigin.Request())
$.extend(req, {responseType, onloadend}) $.extend(req, { responseType, onloadend })
req.upload = {onprogress} req.upload = { onprogress }
req.abort = () => $.event('4chanXAjaxAbort', {id}) req.abort = () => $.event('4chanXAjaxAbort', { id })
if (form) { form = Array.from(form.entries()) } if (form) { form = Array.from(form.entries()) }
$.event('4chanXAjax', {url, timeout, responseType, withCredentials, type, onprogress: !!onprogress, form, headers, id}) $.event('4chanXAjax', { url, timeout, responseType, withCredentials, type, onprogress: !!onprogress, form, headers, id })
return req return req
} }
} }
})() })()
@ -234,9 +234,9 @@ $.ajax = (function() {
// With the `If-Modified-Since` header we only receive the HTTP headers and no body for 304 responses. // With the `If-Modified-Since` header we only receive the HTTP headers and no body for 304 responses.
// This saves a lot of bandwidth and CPU time for both the users and the servers. // This saves a lot of bandwidth and CPU time for both the users and the servers.
$.lastModified = dict() $.lastModified = dict()
$.whenModified = function(url, bucket, cb, options={}) { $.whenModified = function (url, bucket, cb, options = {}) {
let t let t
const {timeout, ajax} = options const { timeout, ajax } = options
const params = [] const params = []
// XXX https://bugs.chromium.org/p/chromium/issues/detail?id=643659 // XXX https://bugs.chromium.org/p/chromium/issues/detail?id=643659
if ($.engine === 'blink') { params.push(`s=${bucket}`) } if ($.engine === 'blink') { params.push(`s=${bucket}`) }
@ -258,33 +258,33 @@ $.whenModified = function(url, bucket, cb, options={}) {
return r return r
}; };
(function() { (function () {
const reqs = dict() const reqs = dict()
$.cache = function(url, cb, options={}) { $.cache = function (url, cb, options = {}) {
let req let req
const {ajax} = options const { ajax } = options
if (req = reqs[url]) { if (req = reqs[url]) {
if (req.callbacks) { if (req.callbacks) {
req.callbacks.push(cb) req.callbacks.push(cb)
} else { } else {
$.queueTask(() => cb.call(req, {isCached: true})) $.queueTask(() => cb.call(req, { isCached: true }))
} }
return req return req
} }
const onloadend = function() { const onloadend = function () {
if (!this.status) { if (!this.status) {
delete reqs[url] delete reqs[url]
} }
for (cb of this.callbacks) { for (cb of this.callbacks) {
(cb => $.queueTask(() => cb.call(this, {isCached: false})))(cb) (cb => $.queueTask(() => cb.call(this, { isCached: false })))(cb)
} }
return delete this.callbacks return delete this.callbacks
} }
req = (ajax || $.ajax)(url, {onloadend}) req = (ajax || $.ajax)(url, { onloadend })
req.callbacks = [cb] req.callbacks = [cb]
return reqs[url] = req return reqs[url] = req
} }
return $.cleanCache = function(testf) { return $.cleanCache = function (testf) {
for (const url in reqs) { for (const url in reqs) {
if (testf(url)) { if (testf(url)) {
delete reqs[url] delete reqs[url]
@ -308,7 +308,7 @@ $.cb = {
} }
} }
$.asap = function(test, cb) { $.asap = function (test, cb) {
if (test()) { if (test()) {
return cb() return cb()
} else { } else {
@ -316,32 +316,32 @@ $.asap = function(test, cb) {
} }
} }
$.onExists = function(root, selector, cb) { $.onExists = function (root, selector, cb) {
let el let el
if (el = $(selector, root)) { if (el = $(selector, root)) {
return cb(el) return cb(el)
} }
var observer = new MutationObserver(function() { var observer = new MutationObserver(function () {
if (el = $(selector, root)) { if (el = $(selector, root)) {
observer.disconnect() observer.disconnect()
return cb(el) return cb(el)
} }
}) })
return observer.observe(root, {childList: true, subtree: true}) return observer.observe(root, { childList: true, subtree: true })
} }
$.addStyle = function(css, id, test='head') { $.addStyle = function (css, id, test = 'head') {
const style = $.el('style', const style = $.el('style',
{textContent: css}) { textContent: css })
if (id != null) { style.id = id } if (id != null) { style.id = id }
$.onExists(doc, test, () => $.add(d.head, style)) $.onExists(doc, test, () => $.add(d.head, style))
return style return style
} }
$.addCSP = function(policy) { $.addCSP = function (policy) {
const meta = $.el('meta', { const meta = $.el('meta', {
httpEquiv: 'Content-Security-Policy', httpEquiv: 'Content-Security-Policy',
content: policy content: policy
} }
) )
if (d.head) { if (d.head) {
@ -354,23 +354,23 @@ $.addCSP = function(policy) {
} }
} }
$.x = function(path, root) { $.x = function (path, root) {
if (!root) { root = d.body } if (!root) { root = d.body }
// XPathResult.ANY_UNORDERED_NODE_TYPE === 8 // XPathResult.ANY_UNORDERED_NODE_TYPE === 8
return d.evaluate(path, root, null, 8, null).singleNodeValue return d.evaluate(path, root, null, 8, null).singleNodeValue
} }
$.X = function(path, root) { $.X = function (path, root) {
if (!root) { root = d.body } if (!root) { root = d.body }
// XPathResult.ORDERED_NODE_SNAPSHOT_TYPE === 7 // XPathResult.ORDERED_NODE_SNAPSHOT_TYPE === 7
return d.evaluate(path, root, null, 7, null) return d.evaluate(path, root, null, 7, null)
} }
$.addClass = function(el, ...classNames) { $.addClass = function (el, ...classNames) {
for (const className of classNames) { el.classList.add(className) } for (const className of classNames) { el.classList.add(className) }
} }
$.rmClass = function(el, ...classNames) { $.rmClass = function (el, ...classNames) {
for (const className of classNames) { el.classList.remove(className) } for (const className of classNames) { el.classList.remove(className) }
} }
@ -381,13 +381,13 @@ $.hasClass = (el, className) => el.classList.contains(className)
$.rm = el => el?.remove() $.rm = el => el?.remove()
$.rmAll = root => // https://gist.github.com/MayhemYDG/8646194 $.rmAll = root => // https://gist.github.com/MayhemYDG/8646194
root.textContent = null root.textContent = null
$.tn = s => d.createTextNode(s) $.tn = s => d.createTextNode(s)
$.frag = () => d.createDocumentFragment() $.frag = () => d.createDocumentFragment()
$.nodes = function(nodes) { $.nodes = function (nodes) {
if (!(nodes instanceof Array)) { if (!(nodes instanceof Array)) {
return nodes return nodes
} }
@ -408,55 +408,55 @@ $.before = (root, el) => root.parentNode.insertBefore($.nodes(el), root)
$.replace = (root, el) => root.parentNode.replaceChild($.nodes(el), root) $.replace = (root, el) => root.parentNode.replaceChild($.nodes(el), root)
$.el = function(tag, properties, properties2) { $.el = function (tag, properties, properties2?) {
const el = d.createElement(tag) const el = d.createElement(tag)
if (properties) { $.extend(el, properties) } if (properties) { $.extend(el, properties) }
if (properties2) { $.extend(el, properties2) } if (properties2) { $.extend(el, properties2) }
return el return el
} }
$.on = function(el, events, handler) { $.on = function (el, events, handler) {
for (const event of events.split(' ')) { for (const event of events.split(' ')) {
el.addEventListener(event, handler, false) el.addEventListener(event, handler, false)
} }
} }
$.off = function(el, events, handler) { $.off = function (el, events, handler) {
for (const event of events.split(' ')) { for (const event of events.split(' ')) {
el.removeEventListener(event, handler, false) el.removeEventListener(event, handler, false)
} }
} }
$.one = function(el, events, handler) { $.one = function (el, events, handler) {
const cb = function(e) { const cb = function (e) {
$.off(el, events, cb) $.off(el, events, cb)
return handler.call(this, e) return handler.call(this, e)
} }
return $.on(el, events, cb) return $.on(el, events, cb)
} }
$.event = function(event, detail, root=d) { $.event = function (event, detail, root = d) {
if (!globalThis.chrome?.extension) { if (!globalThis.chrome?.extension) {
if ((detail != null) && (typeof cloneInto === 'function')) { if ((detail != null) && (typeof cloneInto === 'function')) {
detail = cloneInto(detail, d.defaultView) detail = cloneInto(detail, d.defaultView)
} }
} }
return root.dispatchEvent(new CustomEvent(event, {bubbles: true, cancelable: true, detail})) return root.dispatchEvent(new CustomEvent(event, { bubbles: true, cancelable: true, detail }))
} }
if (platform === 'userscript') { if (platform === 'userscript') {
// XXX Make $.event work in Pale Moon with GM 3.x (no cloneInto function). // XXX Make $.event work in Pale Moon with GM 3.x (no cloneInto function).
(function() { (function () {
if (!/PaleMoon\//.test(navigator.userAgent) || (+GM_info?.version?.split('.')[0] < 2) || (typeof cloneInto !== 'undefined')) { return } if (!/PaleMoon\//.test(navigator.userAgent) || (+GM_info?.version?.split('.')[0] < 2) || (typeof cloneInto !== 'undefined')) { return }
try { try {
return new CustomEvent('x', {detail: {}}) return new CustomEvent('x', { detail: {} })
} catch (err) { } catch (err) {
const unsafeConstructors = { const unsafeConstructors = {
Object: unsafeWindow.Object, Object: unsafeWindow.Object,
Array: unsafeWindow.Array Array: unsafeWindow.Array
} }
const clone = function(obj) { const clone = function (obj) {
let constructor let constructor
if ((obj != null) && (typeof obj === 'object') && (constructor = unsafeConstructors[obj.constructor.name])) { if ((obj != null) && (typeof obj === 'object') && (constructor = unsafeConstructors[obj.constructor.name])) {
const obj2 = new constructor() const obj2 = new constructor()
@ -466,36 +466,36 @@ if (platform === 'userscript') {
return obj return obj
} }
} }
return $.event = (event, detail, root=d) => root.dispatchEvent(new CustomEvent(event, {bubbles: true, cancelable: true, detail: clone(detail)})) return $.event = (event, detail, root = d) => root.dispatchEvent(new CustomEvent(event, { bubbles: true, cancelable: true, detail: clone(detail) }))
} }
})() })()
} }
$.modifiedClick = e => e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || (e.button !== 0) $.modifiedClick = e => e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || (e.button !== 0)
if (!globalThis.chrome?.extension) { if (!globalThis.chrome?.extension) {
$.open = $.open =
(GM?.openInTab != null) ? (GM?.openInTab != null) ?
GM.openInTab GM.openInTab
: (typeof GM_openInTab !== 'undefined' && GM_openInTab !== null) ? : (typeof GM_openInTab !== 'undefined' && GM_openInTab !== null) ?
GM_openInTab GM_openInTab
: :
url => window.open(url, '_blank')
} else {
$.open =
url => window.open(url, '_blank') url => window.open(url, '_blank')
} else { }
$.open =
url => window.open(url, '_blank')
}
$.debounce = function(wait, fn) { $.debounce = function (wait, fn) {
let lastCall = 0 let lastCall = 0
let timeout = null let timeout = null
let that = null let that = null
let args = null let args = null
const exec = function() { const exec = function () {
lastCall = Date.now() lastCall = Date.now()
return fn.apply(that, args) return fn.apply(that, args)
} }
return function() { return function () {
args = arguments args = arguments
that = this that = this
if (lastCall < (Date.now() - wait)) { if (lastCall < (Date.now() - wait)) {
@ -508,10 +508,10 @@ $.debounce = function(wait, fn) {
} }
} }
$.queueTask = (function() { $.queueTask = (function () {
// inspired by https://www.w3.org/Bugs/Public/show_bug.cgi?id=15007 // inspired by https://www.w3.org/Bugs/Public/show_bug.cgi?id=15007
const taskQueue = [] const taskQueue = []
const execTask = function() { const execTask = function () {
const task = taskQueue.shift() const task = taskQueue.shift()
const func = task[0] const func = task[0]
const args = Array.prototype.slice.call(task, 1) const args = Array.prototype.slice.call(task, 1)
@ -520,22 +520,22 @@ $.queueTask = (function() {
if (window.MessageChannel) { if (window.MessageChannel) {
const taskChannel = new MessageChannel() const taskChannel = new MessageChannel()
taskChannel.port1.onmessage = execTask taskChannel.port1.onmessage = execTask
return function() { return function () {
taskQueue.push(arguments) taskQueue.push(arguments)
return taskChannel.port2.postMessage(null) return taskChannel.port2.postMessage(null)
} }
} else { // XXX Firefox } else { // XXX Firefox
return function() { return function () {
taskQueue.push(arguments) taskQueue.push(arguments)
return setTimeout(execTask, 0) return setTimeout(execTask, 0)
} }
} }
})() })()
$.global = function(fn, data) { $.global = function (fn, data) {
if (doc) { if (doc) {
const script = $.el('script', const script = $.el('script',
{textContent: `(${fn}).call(document.currentScript.dataset);`}) { textContent: `(${fn}).call(document.currentScript.dataset);` })
if (data) { $.extend(script.dataset, data) } if (data) { $.extend(script.dataset, data) }
$.add((d.head || doc), script) $.add((d.head || doc), script)
$.rm(script) $.rm(script)
@ -544,12 +544,12 @@ $.global = function(fn, data) {
// XXX dwb // XXX dwb
try { try {
fn.call(data) fn.call(data)
} catch (error) {} } catch (error) { }
return data return data
} }
} }
$.bytesToString = function(size) { $.bytesToString = function (size) {
let unit = 0 // Bytes let unit = 0 // Bytes
while (size >= 1024) { while (size >= 1024) {
size /= 1024 size /= 1024
@ -561,7 +561,7 @@ $.bytesToString = function(size) {
// Keep the size as a float if the size is greater than 2^20 B. // Keep the size as a float if the size is greater than 2^20 B.
// Round to hundredth. // Round to hundredth.
Math.round(size * 100) / 100 Math.round(size * 100) / 100
: :
// Round to an integer otherwise. // Round to an integer otherwise.
Math.round(size) Math.round(size)
return `${size} ${['B', 'KB', 'MB', 'GB'][unit]}` return `${size} ${['B', 'KB', 'MB', 'GB'][unit]}`
@ -569,32 +569,32 @@ $.bytesToString = function(size) {
$.minmax = (value, min, max) => value < min ? $.minmax = (value, min, max) => value < min ?
min min
: :
value > max ? value > max ?
max max
: :
value value
$.hasAudio = video => video.mozHasAudio || !!video.webkitAudioDecodedByteCount $.hasAudio = video => video.mozHasAudio || !!video.webkitAudioDecodedByteCount
$.luma = rgb => (rgb[0] * 0.299) + (rgb[1] * 0.587) + (rgb[2] * 0.114) $.luma = rgb => (rgb[0] * 0.299) + (rgb[1] * 0.587) + (rgb[2] * 0.114)
$.unescape = function(text) { $.unescape = function (text) {
if (text == null) { return text } if (text == null) { return text }
return text.replace(/<[^>]*>/g, '').replace(/&(amp|#039|quot|lt|gt|#44);/g, c => ({'&amp;': '&', '&#039;': "'", '&quot;': '"', '&lt;': '<', '&gt;': '>', '&#44;': ','})[c]) return text.replace(/<[^>]*>/g, '').replace(/&(amp|#039|quot|lt|gt|#44);/g, c => ({ '&amp;': '&', '&#039;': "'", '&quot;': '"', '&lt;': '<', '&gt;': '>', '&#44;': ',' })[c])
} }
$.isImage = url => /\.(jpe?g|jfif|png|gif|bmp|webp|avif|jxl)$/i.test(url) $.isImage = url => /\.(jpe?g|jfif|png|gif|bmp|webp|avif|jxl)$/i.test(url)
$.isVideo = url => /\.(webm|mp4|ogv)$/i.test(url) $.isVideo = url => /\.(webm|mp4|ogv)$/i.test(url)
$.engine = (function() { $.engine = (function () {
if (/Edge\//.test(navigator.userAgent)) { return 'edge' } if (/Edge\//.test(navigator.userAgent)) { return 'edge' }
if (/Chrome\//.test(navigator.userAgent)) { return 'blink' } if (/Chrome\//.test(navigator.userAgent)) { return 'blink' }
if (/WebKit\//.test(navigator.userAgent)) { return 'webkit' } if (/WebKit\//.test(navigator.userAgent)) { return 'webkit' }
if (/Gecko\/|Goanna/.test(navigator.userAgent)) { return 'gecko' } // Goanna = Pale Moon 26+ if (/Gecko\/|Goanna/.test(navigator.userAgent)) { return 'gecko' } // Goanna = Pale Moon 26+
})() })()
$.hasStorage = (function() { $.hasStorage = (function () {
try { try {
if (localStorage.getItem(g.NAMESPACE + 'hasStorage') === 'true') { return true } if (localStorage.getItem(g.NAMESPACE + 'hasStorage') === 'true') { return true }
localStorage.setItem(g.NAMESPACE + 'hasStorage', 'true') localStorage.setItem(g.NAMESPACE + 'hasStorage', 'true')
@ -604,13 +604,13 @@ $.hasStorage = (function() {
} }
})() })()
$.item = function(key, val) { $.item = function (key, val) {
const item = dict() const item = dict()
item[key] = val item[key] = val
return item return item
} }
$.oneItemSugar = fn => (function(key, val, cb) { $.oneItemSugar = fn => (function (key, val, cb) {
if (typeof key === 'string') { if (typeof key === 'string') {
return fn($.item(key, val), cb) return fn($.item(key, val), cb)
} else { } else {
@ -620,7 +620,7 @@ $.oneItemSugar = fn => (function(key, val, cb) {
$.syncing = dict() $.syncing = dict()
$.securityCheck = function(data) { $.securityCheck = function (data) {
if (location.protocol !== 'https:') { if (location.protocol !== 'https:') {
return delete data['Redirect to HTTPS'] return delete data['Redirect to HTTPS']
} }
@ -630,10 +630,10 @@ if (platform === 'crx') {
// https://developer.chrome.com/extensions/storage.html // https://developer.chrome.com/extensions/storage.html
$.oldValue = { $.oldValue = {
local: dict(), local: dict(),
sync: dict() sync: dict()
} }
chrome.storage.onChanged.addListener(function(changes, area) { chrome.storage.onChanged.addListener(function (changes, area) {
for (const key in changes) { for (const key in changes) {
const oldValue = $.oldValue.local[key] ?? $.oldValue.sync[key] const oldValue = $.oldValue.local[key] ?? $.oldValue.sync[key]
$.oldValue[area][key] = dict.clone(changes[key].newValue) $.oldValue[area][key] = dict.clone(changes[key].newValue)
@ -645,17 +645,17 @@ if (platform === 'crx') {
} }
}) })
$.sync = (key, cb) => $.syncing[key] = cb $.sync = (key, cb) => $.syncing[key] = cb
$.forceSync = function() { } $.forceSync = function () { }
$.crxWorking = function() { $.crxWorking = function () {
try { try {
if (chrome.runtime.getManifest()) { if (chrome.runtime.getManifest()) {
return true return true
} }
} catch (error) {} } catch (error) { }
if (!$.crxWarningShown) { if (!$.crxWarningShown) {
const msg = $.el('div', const msg = $.el('div',
{innerHTML: '4chan X seems to have been updated. You will need to <a href="javascript:;">reload</a> the page.'}) { innerHTML: '4chan X seems to have been updated. You will need to <a href="javascript:;">reload</a> the page.' })
$.on($('a', msg), 'click', () => location.reload()) $.on($('a', msg), 'click', () => location.reload())
new Notice('warning', msg) new Notice('warning', msg)
$.crxWarningShown = true $.crxWarningShown = true
@ -663,16 +663,16 @@ if (platform === 'crx') {
return false return false
} }
$.get = $.oneItemSugar(function(data, cb) { $.get = $.oneItemSugar(function (data, cb) {
if (!$.crxWorking()) { return } if (!$.crxWorking()) { return }
const results = {} const results = {}
const get = function(area) { const get = function (area) {
let keys = Object.keys(data) let keys = Object.keys(data)
// XXX slow performance in Firefox // XXX slow performance in Firefox
if (($.engine === 'gecko') && (area === 'sync') && (keys.length > 3)) { if (($.engine === 'gecko') && (area === 'sync') && (keys.length > 3)) {
keys = null keys = null
} }
return chrome.storage[area].get(keys, function(result) { return chrome.storage[area].get(keys, function (result) {
let key let key
result = dict.clone(result) result = dict.clone(result)
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
@ -698,16 +698,16 @@ if (platform === 'crx') {
return get('sync') return get('sync')
}); });
(function() { (function () {
const items = { const items = {
local: dict(), local: dict(),
sync: dict() sync: dict()
} }
const exceedsQuota = (key, value) => // bytes in UTF-8 const exceedsQuota = (key, value) => // bytes in UTF-8
unescape(encodeURIComponent(JSON.stringify(key))).length + unescape(encodeURIComponent(JSON.stringify(value))).length > chrome.storage.sync.QUOTA_BYTES_PER_ITEM unescape(encodeURIComponent(JSON.stringify(key))).length + unescape(encodeURIComponent(JSON.stringify(value))).length > chrome.storage.sync.QUOTA_BYTES_PER_ITEM
$.delete = function(keys) { $.delete = function (keys) {
if (!$.crxWorking()) { return } if (!$.crxWorking()) { return }
if (typeof keys === 'string') { if (typeof keys === 'string') {
keys = [keys] keys = [keys]
@ -721,11 +721,11 @@ if (platform === 'crx') {
} }
const timeout = {} const timeout = {}
const setArea = function(area, cb) { const setArea = function (area, cb) {
const data = dict() const data = dict()
$.extend(data, items[area]) $.extend(data, items[area])
if (!Object.keys(data).length || (timeout[area] > Date.now())) { return } if (!Object.keys(data).length || (timeout[area] > Date.now())) { return }
return chrome.storage[area].set(data, function() { return chrome.storage[area].set(data, function () {
let err let err
let key let key
if (err = chrome.runtime.lastError) { if (err = chrome.runtime.lastError) {
@ -757,20 +757,20 @@ if (platform === 'crx') {
var setSync = debounce(SECOND, () => setArea('sync')) var setSync = debounce(SECOND, () => setArea('sync'))
$.set = $.oneItemSugar(function(data, cb) { $.set = $.oneItemSugar(function (data, cb) {
if (!$.crxWorking()) { return } if (!$.crxWorking()) { return }
$.securityCheck(data) $.securityCheck(data)
$.extend(items.local, data) $.extend(items.local, data)
return setArea('local', cb) return setArea('local', cb)
}) })
return $.clear = function(cb) { return $.clear = function (cb) {
if (!$.crxWorking()) { return } if (!$.crxWorking()) { return }
items.local = dict() items.local = dict()
items.sync = dict() items.sync = dict()
let count = 2 let count = 2
let err = null let err = null
const done = function() { const done = function () {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
c.error(chrome.runtime.lastError.message) c.error(chrome.runtime.lastError.message)
} }
@ -804,19 +804,20 @@ if (platform === 'crx') {
$.sync = (key, cb) => $.syncing[key] = cb $.sync = (key, cb) => $.syncing[key] = cb
$.forceSync = function() {} $.forceSync = function () { }
$.delete = function(keys, cb) { $.delete = function (keys, cb) {
let key let key
if (!(keys instanceof Array)) { if (!(keys instanceof Array)) {
keys = [keys] keys = [keys]
} }
return Promise.all((() => { return Promise.all((() => {
const result = [] const result = []
for (key of keys) { result.push(GM.deleteValue(g.NAMESPACE + key)) for (key of keys) {
result.push(GM.deleteValue(g.NAMESPACE + key))
} }
return result return result
})()).then(function() { })()).then(function () {
const items = dict() const items = dict()
for (key of keys) { items[key] = undefined } for (key of keys) { items[key] = undefined }
$.syncChannel.postMessage(items) $.syncChannel.postMessage(items)
@ -824,9 +825,9 @@ if (platform === 'crx') {
}) })
} }
$.get = $.oneItemSugar(function(items, cb) { $.get = $.oneItemSugar(function (items, cb) {
const keys = Object.keys(items) const keys = Object.keys(items)
return Promise.all(keys.map((key) => GM.getValue(g.NAMESPACE + key))).then(function(values) { return Promise.all(keys.map((key) => GM.getValue(g.NAMESPACE + key))).then(function (values) {
for (let i = 0; i < values.length; i++) { for (let i = 0; i < values.length; i++) {
const val = values[i] const val = values[i]
if (val) { if (val) {
@ -837,7 +838,7 @@ if (platform === 'crx') {
}) })
}) })
$.set = $.oneItemSugar(function(items, cb) { $.set = $.oneItemSugar(function (items, cb) {
$.securityCheck(items) $.securityCheck(items)
return Promise.all((() => { return Promise.all((() => {
const result = [] const result = []
@ -846,13 +847,13 @@ if (platform === 'crx') {
result.push(GM.setValue(g.NAMESPACE + key, JSON.stringify(val))) result.push(GM.setValue(g.NAMESPACE + key, JSON.stringify(val)))
} }
return result return result
})()).then(function() { })()).then(function () {
$.syncChannel.postMessage(items) $.syncChannel.postMessage(items)
return cb?.() return cb?.()
}) })
}) })
$.clear = cb => GM.listValues().then(keys => $.delete(keys.map(key => key.replace(g.NAMESPACE, '')), cb)).catch( () => $.delete(Object.keys(Conf).concat(['previousversion', 'QR Size', 'QR.persona']), cb)) $.clear = cb => GM.listValues().then(keys => $.delete(keys.map(key => key.replace(g.NAMESPACE, '')), cb)).catch(() => $.delete(Object.keys(Conf).concat(['previousversion', 'QR Size', 'QR.persona']), cb))
} else { } else {
if (typeof GM_deleteValue === 'undefined' || GM_deleteValue === null) { if (typeof GM_deleteValue === 'undefined' || GM_deleteValue === null) {
@ -860,7 +861,7 @@ if (platform === 'crx') {
} }
if (typeof GM_deleteValue !== 'undefined' && GM_deleteValue !== null) { if (typeof GM_deleteValue !== 'undefined' && GM_deleteValue !== null) {
$.getValue = GM_getValue $.getValue = GM_getValue
$.listValues = () => GM_listValues() // error when called if missing $.listValues = () => GM_listValues() // error when called if missing
} else if ($.hasStorage) { } else if ($.hasStorage) {
$.getValue = key => localStorage.getItem(key) $.getValue = key => localStorage.getItem(key)
@ -874,23 +875,23 @@ if (platform === 'crx') {
return result return result
})() })()
} else { } else {
$.getValue = function() {} $.getValue = function () { }
$.listValues = () => [] $.listValues = () => []
} }
if (typeof GM_addValueChangeListener !== 'undefined' && GM_addValueChangeListener !== null) { if (typeof GM_addValueChangeListener !== 'undefined' && GM_addValueChangeListener !== null) {
$.setValue = GM_setValue $.setValue = GM_setValue
$.deleteValue = GM_deleteValue $.deleteValue = GM_deleteValue
} else if (typeof GM_deleteValue !== 'undefined' && GM_deleteValue !== null) { } else if (typeof GM_deleteValue !== 'undefined' && GM_deleteValue !== null) {
$.oldValue = dict() $.oldValue = dict()
$.setValue = function(key, val) { $.setValue = function (key, val) {
GM_setValue(key, val) GM_setValue(key, val)
if (key in $.syncing) { if (key in $.syncing) {
$.oldValue[key] = val $.oldValue[key] = val
if ($.hasStorage) { return localStorage.setItem(key, val) } // for `storage` events if ($.hasStorage) { return localStorage.setItem(key, val) } // for `storage` events
} }
} }
$.deleteValue = function(key) { $.deleteValue = function (key) {
GM_deleteValue(key) GM_deleteValue(key)
if (key in $.syncing) { if (key in $.syncing) {
delete $.oldValue[key] delete $.oldValue[key]
@ -900,37 +901,37 @@ if (platform === 'crx') {
if (!$.hasStorage) { $.cantSync = true } if (!$.hasStorage) { $.cantSync = true }
} else if ($.hasStorage) { } else if ($.hasStorage) {
$.oldValue = dict() $.oldValue = dict()
$.setValue = function(key, val) { $.setValue = function (key, val) {
if (key in $.syncing) { $.oldValue[key] = val } if (key in $.syncing) { $.oldValue[key] = val }
return localStorage.setItem(key, val) return localStorage.setItem(key, val)
} }
$.deleteValue = function(key) { $.deleteValue = function (key) {
if (key in $.syncing) { delete $.oldValue[key] } if (key in $.syncing) { delete $.oldValue[key] }
return localStorage.removeItem(key) return localStorage.removeItem(key)
} }
} else { } else {
$.setValue = function() {} $.setValue = function () { }
$.deleteValue = function() {} $.deleteValue = function () { }
$.cantSync = ($.cantSet = true) $.cantSync = ($.cantSet = true)
} }
if (typeof GM_addValueChangeListener !== 'undefined' && GM_addValueChangeListener !== null) { if (typeof GM_addValueChangeListener !== 'undefined' && GM_addValueChangeListener !== null) {
$.sync = (key, cb) => $.syncing[key] = GM_addValueChangeListener(g.NAMESPACE + key, function(key2, oldValue, newValue, remote) { $.sync = (key, cb) => $.syncing[key] = GM_addValueChangeListener(g.NAMESPACE + key, function (key2, oldValue, newValue, remote) {
if (remote) { if (remote) {
if (newValue !== undefined) { newValue = dict.json(newValue) } if (newValue !== undefined) { newValue = dict.json(newValue) }
return cb(newValue, key) return cb(newValue, key)
} }
}) })
$.forceSync = function() {} $.forceSync = function () { }
} else if ((typeof GM_deleteValue !== 'undefined' && GM_deleteValue !== null) || $.hasStorage) { } else if ((typeof GM_deleteValue !== 'undefined' && GM_deleteValue !== null) || $.hasStorage) {
$.sync = function(key, cb) { $.sync = function (key, cb) {
key = g.NAMESPACE + key key = g.NAMESPACE + key
$.syncing[key] = cb $.syncing[key] = cb
return $.oldValue[key] = $.getValue(key) return $.oldValue[key] = $.getValue(key)
}; };
(function() { (function () {
const onChange = function({key, newValue}) { const onChange = function ({ key, newValue }) {
let cb let cb
if (!(cb = $.syncing[key])) { return } if (!(cb = $.syncing[key])) { return }
if (newValue != null) { if (newValue != null) {
@ -945,20 +946,20 @@ if (platform === 'crx') {
} }
$.on(window, 'storage', onChange) $.on(window, 'storage', onChange)
return $.forceSync = function(key) { return $.forceSync = function (key) {
// Storage events don't work across origins // Storage events don't work across origins
// e.g. http://boards.4chan.org and https://boards.4chan.org // e.g. http://boards.4chan.org and https://boards.4chan.org
// so force a check for changes to avoid lost data. // so force a check for changes to avoid lost data.
key = g.NAMESPACE + key key = g.NAMESPACE + key
return onChange({key, newValue: $.getValue(key)}) return onChange({ key, newValue: $.getValue(key) })
} }
})() })()
} else { } else {
$.sync = function() {} $.sync = function () { }
$.forceSync = function() {} $.forceSync = function () { }
} }
$.delete = function(keys) { $.delete = function (keys) {
if (!(keys instanceof Array)) { if (!(keys instanceof Array)) {
keys = [keys] keys = [keys]
} }
@ -969,7 +970,7 @@ if (platform === 'crx') {
$.get = $.oneItemSugar((items, cb) => $.queueTask($.getSync, items, cb)) $.get = $.oneItemSugar((items, cb) => $.queueTask($.getSync, items, cb))
$.getSync = function(items, cb) { $.getSync = function (items, cb) {
for (const key in items) { for (const key in items) {
var val2 var val2
if (val2 = $.getValue(g.NAMESPACE + key)) { if (val2 = $.getValue(g.NAMESPACE + key)) {
@ -986,9 +987,9 @@ if (platform === 'crx') {
return cb(items) return cb(items)
} }
$.set = $.oneItemSugar(function(items, cb) { $.set = $.oneItemSugar(function (items, cb) {
$.securityCheck(items) $.securityCheck(items)
return $.queueTask(function() { return $.queueTask(function () {
for (const key in items) { for (const key in items) {
const value = items[key] const value = items[key]
$.setValue(g.NAMESPACE + key, JSON.stringify(value)) $.setValue(g.NAMESPACE + key, JSON.stringify(value))
@ -997,14 +998,14 @@ if (platform === 'crx') {
}) })
}) })
$.clear = function(cb) { $.clear = function (cb) {
// XXX https://github.com/greasemonkey/greasemonkey/issues/2033 // XXX https://github.com/greasemonkey/greasemonkey/issues/2033
// Also support case where GM_listValues is not defined. // Also support case where GM_listValues is not defined.
$.delete(Object.keys(Conf)) $.delete(Object.keys(Conf))
$.delete(['previousversion', 'QR Size', 'QR.persona']) $.delete(['previousversion', 'QR Size', 'QR.persona'])
try { try {
$.delete($.listValues().map(key => key.replace(g.NAMESPACE, ''))) $.delete($.listValues().map(key => key.replace(g.NAMESPACE, '')))
} catch (error) {} } catch (error) { }
return cb?.() return cb?.()
} }
} }

View File

@ -50,7 +50,7 @@ const SWTinyboard = {
} else if (/^https?:/.test(root)) { } else if (/^https?:/.test(root)) {
properties.root = root properties.root = root
} }
} catch (error) {} } catch (error) { }
return properties return properties
} }
} }
@ -61,7 +61,7 @@ const SWTinyboard = {
let reactUI let reactUI
if (reactUI = $.id('react-ui')) { if (reactUI = $.id('react-ui')) {
const s = (this.selectors = Object.create(this.selectors)) const s = (this.selectors = Object.create(this.selectors))
s.boardFor = {index: '.page-container'} s.boardFor = { index: '.page-container' }
s.thread = 'div[id^="thread_"]' s.thread = 'div[id^="thread_"]'
return Main.mounted(cb) return Main.mounted(cb)
} else { } else {
@ -70,32 +70,32 @@ const SWTinyboard = {
}, },
urls: { urls: {
thread({siteID, boardID, threadID}, isArchived) { thread({ siteID, boardID, threadID }, isArchived) {
return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/${isArchived ? 'archive/' : ''}res/${threadID}.html` return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/${isArchived ? 'archive/' : ''}res/${threadID}.html`
}, },
post({postID}) { return `#${postID}` }, post({ postID }) { return `#${postID}` },
index({siteID, boardID}) { return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/` }, index({ siteID, boardID }) { return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/` },
catalog({siteID, boardID}) { return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/catalog.html` }, catalog({ siteID, boardID }) { return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/catalog.html` },
threadJSON({siteID, boardID, threadID}, isArchived) { threadJSON({ siteID, boardID, threadID }, isArchived) {
const root = Conf['siteProperties'][siteID]?.root const root = Conf['siteProperties'][siteID]?.root
if (root) { return `${root}${boardID}/${isArchived ? 'archive/' : ''}res/${threadID}.json` } else { return '' } if (root) { return `${root}${boardID}/${isArchived ? 'archive/' : ''}res/${threadID}.json` } else { return '' }
}, },
archivedThreadJSON(thread) { archivedThreadJSON(thread) {
return SWTinyboard.urls.threadJSON(thread, true) return SWTinyboard.urls.threadJSON(thread, true)
}, },
threadsListJSON({siteID, boardID}) { threadsListJSON({ siteID, boardID }) {
const root = Conf['siteProperties'][siteID]?.root const root = Conf['siteProperties'][siteID]?.root
if (root) { return `${root}${boardID}/threads.json` } else { return '' } if (root) { return `${root}${boardID}/threads.json` } else { return '' }
}, },
archiveListJSON({siteID, boardID}) { archiveListJSON({ siteID, boardID }) {
const root = Conf['siteProperties'][siteID]?.root const root = Conf['siteProperties'][siteID]?.root
if (root) { return `${root}${boardID}/archive/archive.json` } else { return '' } if (root) { return `${root}${boardID}/archive/archive.json` } else { return '' }
}, },
catalogJSON({siteID, boardID}) { catalogJSON({ siteID, boardID }) {
const root = Conf['siteProperties'][siteID]?.root const root = Conf['siteProperties'][siteID]?.root
if (root) { return `${root}${boardID}/catalog.json` } else { return '' } if (root) { return `${root}${boardID}/catalog.json` } else { return '' }
}, },
file({siteID, boardID}, filename) { file({ siteID, boardID }, filename) {
return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/${filename}` return `${Conf['siteProperties'][siteID]?.root || `http://${siteID}/`}${boardID}/${filename}`
}, },
thumb(board, filename) { thumb(board, filename) {
@ -104,55 +104,55 @@ const SWTinyboard = {
}, },
selectors: { selectors: {
board: 'form[name="postcontrols"]', board: 'form[name="postcontrols"]',
thread: 'input[name="board"] ~ div[id^="thread_"]', thread: 'input[name="board"] ~ div[id^="thread_"]',
threadDivider: 'div[id^="thread_"] > hr:last-child', threadDivider: 'div[id^="thread_"] > hr:last-child',
summary: '.omitted', summary: '.omitted',
postContainer: 'div[id^="reply_"]:not(.hidden)', // postContainer is thread for OP postContainer: 'div[id^="reply_"]:not(.hidden)', // postContainer is thread for OP
opBottom: '.op', opBottom: '.op',
replyOriginal: 'div[id^="reply_"]:not(.hidden)', replyOriginal: 'div[id^="reply_"]:not(.hidden)',
infoRoot: '.intro', infoRoot: '.intro',
info: { info: {
subject: '.subject', subject: '.subject',
name: '.name', name: '.name',
email: '.email', email: '.email',
tripcode: '.trip', tripcode: '.trip',
uniqueID: '.poster_id', uniqueID: '.poster_id',
capcode: '.capcode', capcode: '.capcode',
flag: '.flag', flag: '.flag',
date: 'time', date: 'time',
nameBlock: 'label', nameBlock: 'label',
quote: 'a[href*="#q"]', quote: 'a[href*="#q"]',
reply: 'a[href*="/res/"]:not([href*="#"])' reply: 'a[href*="/res/"]:not([href*="#"])'
}, },
icons: { icons: {
isSticky: '.fa-thumb-tack', isSticky: '.fa-thumb-tack',
isClosed: '.fa-lock' isClosed: '.fa-lock'
}, },
file: { file: {
text: '.fileinfo', text: '.fileinfo',
link: '.fileinfo > a', link: '.fileinfo > a',
thumb: 'a > .post-image' thumb: 'a > .post-image'
}, },
thumbLink: '.file > a', thumbLink: '.file > a',
multifile: '.files > .file', multifile: '.files > .file',
highlightable: { highlightable: {
op: ' > .op', op: ' > .op',
reply: '.reply', reply: '.reply',
catalog: ' > .thread' catalog: ' > .thread'
}, },
comment: '.body', comment: '.body',
spoiler: '.spoiler', spoiler: '.spoiler',
quotelink: 'a[onclick*="highlightReply("]', quotelink: 'a[onclick*="highlightReply("]',
catalog: { catalog: {
board: '#Grid', board: '#Grid',
thread: '.mix', thread: '.mix',
thumb: '.thread-image' thumb: '.thread-image'
}, },
boardList: '.boardlist', boardList: '.boardlist',
boardListBottom: '.boardlist.bottom', boardListBottom: '.boardlist.bottom',
styleSheet: '#stylesheet', styleSheet: '#stylesheet',
psa: '.blotter', psa: '.blotter',
nav: { nav: {
prev: '.pages > form > [value=Previous]', prev: '.pages > form > [value=Previous]',
next: '.pages > form > [value=Next]' next: '.pages > form > [value=Next]'
@ -164,8 +164,8 @@ const SWTinyboard = {
}, },
xpath: { xpath: {
thread: 'div[starts-with(@id,"thread_")]', thread: 'div[starts-with(@id,"thread_")]',
postContainer: 'div[starts-with(@id,"reply_") or starts-with(@id,"thread_")]', postContainer: 'div[starts-with(@id,"reply_") or starts-with(@id,"thread_")]',
replyContainer: 'div[starts-with(@id,"reply_")]' replyContainer: 'div[starts-with(@id,"reply_")]'
}, },
@ -222,7 +222,7 @@ $\
}, },
bgColoredEl() { bgColoredEl() {
return $.el('div', {className: 'post reply'}) return $.el('div', { className: 'post reply' })
}, },
isFileURL(url) { isFileURL(url) {
@ -250,10 +250,10 @@ $\
if (m = text.match(/(\s*ID:\s*)(\S+)/)) { if (m = text.match(/(\s*ID:\s*)(\S+)/)) {
let uniqueID let uniqueID
nodes.info.normalize() nodes.info.normalize()
let {nextSibling} = nodes.nameBlock let { nextSibling } = nodes.nameBlock
nextSibling = nextSibling.splitText(m[1].length) nextSibling = nextSibling.splitText(m[1].length)
nextSibling.splitText(m[2].length) nextSibling.splitText(m[2].length)
nodes.uniqueID = (uniqueID = $.el('span', {className: 'poster_id'})) nodes.uniqueID = (uniqueID = $.el('span', { className: 'poster_id' }))
$.replace(nextSibling, uniqueID) $.replace(nextSibling, uniqueID)
return $.add(uniqueID, nextSibling) return $.add(uniqueID, nextSibling)
} }
@ -269,19 +269,19 @@ $\
parseFile(post, file) { parseFile(post, file) {
let info, infoNode let info, infoNode
const {text, link, thumb} = file const { text, link, thumb } = file
if ($.x(`ancestor::${this.xpath.postContainer}[1]`, text) !== post.nodes.root) { return false } // file belongs to a reply if ($.x(`ancestor::${this.xpath.postContainer}[1]`, text) !== post.nodes.root) { return false } // file belongs to a reply
if (!(infoNode = link.nextSibling?.textContent.includes('(') ? link.nextSibling : link.nextElementSibling)) { return false } if (!(infoNode = link.nextSibling?.textContent.includes('(') ? link.nextSibling : link.nextElementSibling)) { return false }
if (!(info = infoNode.textContent.match(/\((.*,\s*)?([\d.]+ ?[KMG]?B).*\)/))) { return false } if (!(info = infoNode.textContent.match(/\((.*,\s*)?([\d.]+ ?[KMG]?B).*\)/))) { return false }
const nameNode = $('.postfilename', text) const nameNode = $('.postfilename', text)
$.extend(file, { $.extend(file, {
name: nameNode ? (nameNode.title || nameNode.textContent) : link.pathname.match(/[^/]*$/)[0], name: nameNode ? (nameNode.title || nameNode.textContent) : link.pathname.match(/[^/]*$/)[0],
size: info[2], size: info[2],
dimensions: info[0].match(/\d+x\d+/)?.[0] dimensions: info[0].match(/\d+x\d+/)?.[0]
}) })
if (thumb) { if (thumb) {
$.extend(file, { $.extend(file, {
thumbURL: /\/static\//.test(thumb.src) && $.isImage(link.href) ? link.href : thumb.src, thumbURL: /\/static\//.test(thumb.src) && $.isImage(link.href) ? link.href : thumb.src,
isSpoiler: /^Spoiler/i.test(info[1] || '') || (link.textContent === 'Spoiler Image') isSpoiler: /^Spoiler/i.test(info[1] || '') || (link.textContent === 'Spoiler Image')
} }
) )