4chan-XZ/src/General/Index.ts
2023-05-05 04:15:53 +02:00

1320 lines
43 KiB
TypeScript

/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS205: Consider reworking code to avoid use of IIFEs
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
*/
import Callbacks from '../classes/Callbacks'
import CatalogThread from '../classes/CatalogThread'
import Notice from '../classes/Notice'
import Post from '../classes/Post'
import Thread from '../classes/Thread'
import Config from '../config/Config'
import Filter from '../Filtering/Filter'
import PostHiding from '../Filtering/PostHiding'
import ThreadHiding from '../Filtering/ThreadHiding'
import { Board, c, Conf, d, doc, g } from '../globals/globals'
import Main from '../main/Main'
import Menu from '../Menu/Menu'
import CatalogLinks from '../Miscellaneous/CatalogLinks'
import RelativeDates from '../Miscellaneous/RelativeDates'
import ThreadWatcher from '../Monitoring/ThreadWatcher'
import $ from '../platform/$'
import $$ from '../platform/$$'
import { dict, SECOND } from '../platform/helpers'
import QuotePreview from '../Quotelinks/QuotePreview'
import BoardConfig from './BoardConfig'
import Get from './Get'
import Header from './Header'
import NavLinksPage from './Index/NavLinks.html'
import PageList from './Index/PageList.html'
import UI from './UI'
interface Index {
enabled: any
menu: any
req: boolean
liveThreadData: any
pageNum: any
currentPage: any
pagesNum: number
changed: any
showHiddenThreads: boolean
threadPosition: any
threadsNumPerPage: any
selectSort: any
initFinishedFired: boolean
cb: any
loaded: any
lastLongThresholds: any
inputs: any
selectRev: any
hashCommands: any
search: any
liveThreadDict: any
parsedThreads: any
replyData: any
selectMode: any
lastLongOptions: any
sortedThreadIDs: any
hideLabel: any
liveThreadIDs: any
notice: any
nTimeout: any
searchInput: any
update(): unknown
currentSort(currentSort: any): any
root(board: any, root: any): unknown
navLinks(topNavPos: Element, navLinks: any): unknown
pagelist(pagelist: any, pagelist1: any): unknown
endNotice(): unknown
threadsOnPage(pageNum: number): unknown
buildStructure(threadIDs: any): unknown
enabledOn(BOARD: Board): unknown
toggleHide(thread: any): unknown
sort(): unknown
buildIndex(): unknown
pushState(arg0: { mode: any }): unknown
pageLoad(arg0: boolean): unknown
saveSort(): unknown
saveLastLongThresholds(i: number): unknown
getCurrentPage(): unknown
setState(arg0: { search: any; mode: any; sort: any; page: any; hash: string }): unknown
processHash(): unknown
userPageNav(arg0: number): unknown
buildCatalogReplies(arg0: any): unknown
savePerBoard(arg0: string, currentSort: any): unknown
buildPagelist(): unknown
setupSearch(): unknown
setupMode(): unknown
setupSort(): unknown
setPage(): unknown
scrollToIndex(): unknown
getPagesNum(): number
getMaxPageNum(): unknown
isHidden(threadID: any): unknown
load(arg0: string, arg1: string, load: any): any
button(button: any, arg1: string): unknown
parse(response: any): unknown
parseThreadList(pages: any): unknown
buildReplies(threads: any[]): any
updateHideLabel(): unknown
isHiddenReply(ID: any, data: any): unknown
querySearch(search: any): any
sortOnTop(arg0: (obj: any) => any): unknown
buildCatalog(threadIDs: any): unknown
buildThreads(threadIDs: any, arg1: boolean, arg2: any): unknown
buildCatalogPart(arg0: any): unknown
buildCatalogViews(threads: any): unknown
sizeCatalogViews(threads: any): unknown
onSearchInput(): unknown
searchMatch(arg0: any, keywords: any): unknown
}
const Index: Index = {
showHiddenThreads: false,
changed: {},
enabledOn({ siteID, boardID }) {
return Conf['JSON Index'] && (g.sites[siteID].software === 'yotsuba') && (boardID !== 'f')
},
init() {
let input, inputs, name
if (g.VIEW !== 'index') { return }
// For IndexRefresh events
$.one(d, '4chanXInitFinished', this.cb.initFinished)
$.on(d, 'PostsInserted', this.cb.postsInserted)
if (!this.enabledOn(g.BOARD)) { return }
this.enabled = true
Callbacks.Post.push({
name: 'Index Page Numbers',
cb: this.node
})
Callbacks.CatalogThread.push({
name: 'Catalog Features',
cb: this.catalogNode
})
this.search = history.state?.searched || ''
if (history.state?.mode) {
Conf['Index Mode'] = history.state?.mode
}
this.currentSort = history.state?.sort
if (!this.currentSort) {
this.currentSort = typeof Conf['Index Sort'] === 'object' ? (
Conf['Index Sort'][g.BOARD.ID] || 'bump'
) : (
Conf['Index Sort']
)
}
this.currentPage = this.getCurrentPage()
this.processHash()
$.addClass(doc, 'index-loading', `${Conf['Index Mode'].replace(/\ /g, '-')}-mode`)
$.on(window, 'popstate', this.cb.popstate)
$.on(d, 'scroll', this.scroll)
$.on(d, 'SortIndex', this.cb.resort)
// Header refresh button
this.button = $.el('a', {
className: 'fa fa-refresh',
title: 'Refresh',
href: 'javascript:;',
textContent: 'Refresh Index'
}
)
$.on(this.button, 'click', () => Index.update())
Header.addShortcut('index-refresh', this.button, 590)
// Header "Index Navigation" submenu
const entries = []
this.inputs = (inputs = dict())
for (name in Config.Index) {
const arr = Config.Index[name]
if (arr instanceof Array) {
const label = UI.checkbox(name, `${name[0]}${name.slice(1).toLowerCase()}`)
label.title = arr[1]
entries.push({ el: label })
input = label.firstChild
$.on(input, 'change', $.cb.checked)
inputs[name] = input
}
}
$.on(inputs['Show Replies'], 'change', this.cb.replies)
$.on(inputs['Catalog Hover Expand'], 'change', this.cb.hover)
$.on(inputs['Pin Watched Threads'], 'change', this.cb.resort)
$.on(inputs['Anchor Hidden Threads'], 'change', this.cb.resort)
const watchSettings = function (e) {
if (input = $.getOwn(inputs, e.target.name)) {
input.checked = e.target.checked
return $.event('change', null, input)
}
}
$.on(d, 'OpenSettings', () => $.on($.id('fourchanx-settings'), 'change', watchSettings))
const sortEntry = UI.checkbox('Per-Board Sort Type', 'Per-board sort type', (typeof Conf['Index Sort'] === 'object'))
sortEntry.title = 'Set the sorting order of each board independently.'
$.on(sortEntry.firstChild, 'change', this.cb.perBoardSort)
entries.splice(3, 0, { el: sortEntry })
Header.menu.addEntry({
el: $.el('span',
{ textContent: 'Index Navigation' }),
order: 100,
subEntries: entries
})
// Navigation links at top of index
this.navLinks = $.el('div', { className: 'navLinks json-index' })
$.extend(this.navLinks, { innerHTML: NavLinksPage })
$('.cataloglink a', this.navLinks).href = CatalogLinks.catalog()
if (!BoardConfig.isArchived(g.BOARD.ID)) { $('.archlistlink', this.navLinks).hidden = true }
$.on($('#index-last-refresh a', this.navLinks), 'click', this.cb.refreshFront)
// Search field
this.searchInput = $('#index-search', this.navLinks)
this.setupSearch()
$.on(this.searchInput, 'input', this.onSearchInput)
$.on($('#index-search-clear', this.navLinks), 'click', this.clearSearch)
// Hidden threads toggle
this.hideLabel = $('#hidden-label', this.navLinks)
$.on($('#hidden-toggle a', this.navLinks), 'click', this.cb.toggleHiddenThreads)
// Drop-down menus and reverse sort toggle
this.selectRev = $('#index-rev', this.navLinks)
this.selectMode = $('#index-mode', this.navLinks)
this.selectSort = $('#index-sort', this.navLinks)
this.selectSize = $('#index-size', this.navLinks)
$.on(this.selectRev, 'change', this.cb.sort)
$.on(this.selectMode, 'change', this.cb.mode)
$.on(this.selectSort, 'change', this.cb.sort)
$.on(this.selectSize, 'change', $.cb.value)
$.on(this.selectSize, 'change', this.cb.size)
for (const select of [this.selectMode, this.selectSize]) {
select.value = Conf[select.name]
}
this.selectRev.checked = /-rev$/.test(Index.currentSort)
this.selectSort.value = Index.currentSort.replace(/-rev$/, '')
// Last Long Reply options
this.lastLongOptions = $('#lastlong-options', this.navLinks)
this.lastLongInputs = $$('input', this.lastLongOptions)
this.lastLongThresholds = [0, 0]
this.lastLongOptions.hidden = (this.selectSort.value !== 'lastlong')
for (let i = 0; i < this.lastLongInputs.length; i++) {
input = this.lastLongInputs[i]
$.on(input, 'change', this.cb.lastLongThresholds)
const tRaw = Conf[`Last Long Reply Thresholds ${i}`]
input.value = (this.lastLongThresholds[i] =
typeof tRaw === 'object' ? (tRaw[g.BOARD.ID] ?? 100) : tRaw)
}
// Thread container
this.root = $.el('div', { className: 'board json-index' })
$.on(this.root, 'click', this.cb.hoverToggle)
this.cb.size()
this.cb.hover()
// Page list
this.pagelist = $.el('div', { className: 'pagelist json-index' })
$.extend(this.pagelist, { innerHTML: PageList })
$('.cataloglink a', this.pagelist).href = CatalogLinks.catalog()
$.on(this.pagelist, 'click', this.cb.pageNav)
this.update(true)
$.onExists(doc, 'title + *', () => d.title = d.title.replace(/\ -\ Page\ \d+/, ''))
$.onExists(doc, '.board > .thread > .postContainer, .board + *', function () {
let el
g.SITE.Build.hat = $('.board > .thread > img:first-child')
if (g.SITE.Build.hat) {
g.BOARD.threads.forEach(function (thread) {
if (thread.nodes.root) {
return $.prepend(thread.nodes.root, g.SITE.Build.hat.cloneNode(false))
}
})
$.addClass(doc, 'hats-enabled')
$.addStyle(`.catalog-thread::after {background-image: url(${g.SITE.Build.hat.src});}`)
}
const board = $('.board')
$.replace(board, Index.root)
if (Index.loaded) {
$.event('PostsInserted', null, Index.root)
}
// Hacks:
// - When removing an element from the document during page load,
// its ancestors will still be correctly created inside of it.
// - Creating loadable elements inside of an origin-less document
// will not download them.
// - Combine the two and you get a download canceller!
// Does not work on Firefox unfortunately. bugzil.la/939713
try {
d.implementation.createDocument(null, null, null).appendChild(board)
} catch (error) { }
for (el of $$('.navLinks')) { $.rm(el) }
$.rm($.id('ctrl-top'))
const topNavPos = $.id('delform').previousElementSibling
$.before(topNavPos, $.el('hr'))
$.before(topNavPos, Index.navLinks)
const timeEl = $('#index-last-refresh time', Index.navLinks)
if (timeEl.dataset.utc) { return RelativeDates.update(timeEl) }
})
return Main.ready(function () {
let pagelist
if (pagelist = $('.pagelist')) {
$.replace(pagelist, Index.pagelist)
}
return $.rmClass(doc, 'index-loading')
})
},
scroll() {
if (Index.req || !Index.liveThreadData || (Conf['Index Mode'] !== 'infinite') || (window.scrollY <= (doc.scrollHeight - (300 + window.innerHeight)))) { return }
if (Index.pageNum == null) { Index.pageNum = Index.currentPage } // Avoid having to pushState to keep track of the current page
const pageNum = ++Index.pageNum
if (pageNum > Index.pagesNum) { return Index.endNotice() }
const threadIDs = Index.threadsOnPage(pageNum)
return Index.buildStructure(threadIDs)
},
endNotice: (function () {
let notify = false
const reset = () => notify = false
return function () {
if (notify) { return }
notify = true
new Notice('info', "Last page reached.", 2)
return setTimeout(reset, 3 * SECOND)
}
})(),
menu: {
init() {
if ((g.VIEW !== 'index') || !Conf['Menu'] || !Conf['Thread Hiding Link'] || !Index.enabledOn(g.BOARD)) { return }
return Menu.menu.addEntry({
el: $.el('a', {
href: 'javascript:;',
className: 'has-shortcut-text'
}
, { innerHTML: "<span></span><span class=\"shortcut-text\">Shift+click</span>" }),
order: 20,
open({ thread }) {
if (Conf['Index Mode'] !== 'catalog') { return false }
this.el.firstElementChild.textContent = thread.isHidden ?
'Unhide'
:
'Hide'
if (this.cb) { $.off(this.el, 'click', this.cb) }
this.cb = function () {
$.event('CloseMenu')
return Index.toggleHide(thread)
}
$.on(this.el, 'click', this.cb)
return true
}
})
}
},
node() {
if (this.isReply || this.isClone || (Index.threadPosition[this.ID] == null)) { return }
return this.thread.setPage(Math.floor(Index.threadPosition[this.ID] / Index.threadsNumPerPage) + 1)
},
catalogNode() {
return $.on(this.nodes.root, 'mousedown click', e => {
if ((e.button !== 0) || !e.shiftKey) { return }
if (e.type === 'click') { Index.toggleHide(this.thread) }
return e.preventDefault()
})
}, // Also on mousedown to prevent highlighting text.
toggleHide(thread) {
if (Index.showHiddenThreads) {
ThreadHiding.show(thread)
if (!ThreadHiding.db.get({ boardID: thread.board.ID, threadID: thread.ID })) { return }
// Don't save when un-hiding filtered threads.
} else {
ThreadHiding.hide(thread)
}
return ThreadHiding.saveHiddenState(thread)
},
cycleSortType() {
let i
const types = [...Array.from(Index.selectSort.options)].filter(option => !option.disabled)
for (i = 0; i < types.length; i++) {
const type = types[i]
if (type.selected) { break }
}
types[(i + 1) % types.length].selected = true
return $.event('change', null, Index.selectSort)
},
cb: {
initFinished() {
Index.initFinishedFired = true
return $.queueTask(() => Index.cb.postsInserted())
},
postsInserted() {
if (!Index.initFinishedFired) { return }
let n = 0
g.posts.forEach(function (post) {
if (!post.isFetchedQuote && !post.indexRefreshSeen && doc.contains(post.nodes.root)) {
post.indexRefreshSeen = true
return n++
}
})
if (n) { return $.event('IndexRefresh') }
},
toggleHiddenThreads() {
$('#hidden-toggle a', Index.navLinks).textContent = (Index.showHiddenThreads = !Index.showHiddenThreads) ?
'Hide'
:
'Show'
Index.sort()
return Index.buildIndex()
},
mode() {
Index.pushState({ mode: this.value })
return Index.pageLoad(false)
},
sort() {
const value = Index.selectRev.checked ? Index.selectSort.value + "-rev" : Index.selectSort.value
Index.pushState({ sort: value })
return Index.pageLoad(false)
},
resort(e) {
Index.changed.order = true
if (!e?.detail?.deferred) { return Index.pageLoad(false) }
},
perBoardSort() {
Conf['Index Sort'] = this.checked ? dict() : ''
Index.saveSort()
for (let i = 0; i < 2; i++) {
Conf[`Last Long Reply Thresholds ${i}`] = this.checked ? dict() : ''
Index.saveLastLongThresholds(i)
}
},
lastLongThresholds() {
const i = [...Array.from(this.parentNode.children)].indexOf(this)
const value = +this.value
if (!Number.isFinite(value)) {
this.value = Index.lastLongThresholds[i]
return
}
Index.lastLongThresholds[i] = value
Index.saveLastLongThresholds(i)
Index.changed.order = true
return Index.pageLoad(false)
},
size(e) {
if (Conf['Index Mode'] !== 'catalog') {
$.rmClass(Index.root, 'catalog-small')
$.rmClass(Index.root, 'catalog-large')
} else if (Conf['Index Size'] === 'small') {
$.addClass(Index.root, 'catalog-small')
$.rmClass(Index.root, 'catalog-large')
} else {
$.addClass(Index.root, 'catalog-large')
$.rmClass(Index.root, 'catalog-small')
}
if (e) { return Index.buildIndex() }
},
replies() {
return Index.buildIndex()
},
hover() {
return doc.classList.toggle('catalog-hover-expand', Conf['Catalog Hover Expand'])
},
hoverToggle(e) {
if (Conf['Catalog Hover Toggle'] && $.hasClass(doc, 'catalog-mode') && !$.modifiedClick(e) && !$.x('ancestor-or-self::a', e.target)) {
let thread
const input = Index.inputs['Catalog Hover Expand']
input.checked = !input.checked
$.event('change', null, input)
if (thread = Get.threadFromNode(e.target)) {
Index.cb.catalogReplies.call(thread)
return Index.cb.hoverAdjust.call(thread.OP.nodes)
}
}
},
popstate(e) {
if (e?.state) {
const { searched, mode, sort } = e.state
const page = Index.getCurrentPage()
Index.setState({ search: searched, mode, sort, page, hash: location.hash })
return Index.pageLoad(false)
} else {
// page load or hash change
const nCommands = Index.processHash()
if (Conf['Refreshed Navigation'] && nCommands) {
return Index.update()
} else {
return Index.pageLoad()
}
}
},
pageNav(e) {
let a
if ($.modifiedClick(e)) { return }
switch (e.target.nodeName) {
case 'BUTTON':
e.target.blur()
a = e.target.parentNode
break
case 'A':
a = e.target
break
default:
return
}
if (a.textContent === 'Catalog') { return }
e.preventDefault()
return Index.userPageNav(+a.pathname.split(/\/+/)[2] || 1)
},
refreshFront() {
Index.pushState({ page: 1 })
return Index.update()
},
catalogReplies() {
if (Conf['Show Replies'] && $.hasClass(doc, 'catalog-hover-expand') && !this.catalogView.nodes.replies) {
return Index.buildCatalogReplies(this)
}
},
hoverAdjust() {
// Prevent hovered catalog threads from going offscreen.
let x
if (!$.hasClass(doc, 'catalog-hover-expand')) { return }
const rect = this.post.getBoundingClientRect()
if (x = $.minmax(0, -rect.left, doc.clientWidth - rect.right)) {
const { style } = this.post
style.left = `${x}px`
style.right = `${-x}px`
return $.one(this.root, 'mouseleave', () => style.left = (style.right = null))
}
}
},
scrollToIndex() {
// Scroll to navlinks, or top of board if navlinks are hidden.
return Header.scrollToIfNeeded((Index.navLinks.getBoundingClientRect().height ? Index.navLinks : Index.root))
},
getCurrentPage() {
return +window.location.pathname.split(/\/+/)[2] || 1
},
userPageNav(page) {
Index.pushState({ page })
if (Conf['Refreshed Navigation']) {
return Index.update()
} else {
return Index.pageLoad()
}
},
hashCommands: {
mode: {
'paged': 'paged',
'infinite-scrolling': 'infinite',
'infinite': 'infinite',
'all-threads': 'all pages',
'all-pages': 'all pages',
'catalog': 'catalog'
},
sort: {
'bump-order': 'bump',
'last-reply': 'lastreply',
'last-long-reply': 'lastlong',
'creation-date': 'birth',
'reply-count': 'replycount',
'file-count': 'filecount',
'posts-per-minute': 'activity'
}
},
processHash() {
// XXX https://bugzilla.mozilla.org/show_bug.cgi?id=483304
let hash = location.href.match(/#.*/)?.[0] || ''
const state =
{ replace: true }
const commands = hash.slice(1).split('/')
const leftover = []
for (const command of commands) {
var mode, sort
if (mode = $.getOwn(Index.hashCommands.mode, command)) {
state.mode = mode
} else if (command === 'index') {
state.mode = Conf['Previous Index Mode']
state.page = 1
} else if (sort = $.getOwn(Index.hashCommands.sort, command.replace(/-rev$/, ''))) {
state.sort = sort
if (/-rev$/.test(command)) { state.sort += '-rev' }
} else if (/^s=/.test(command)) {
state.search = decodeURIComponent(command.slice(2)).replace(/\+/g, ' ').trim()
} else {
leftover.push(command)
}
}
hash = leftover.join('/')
if (hash) { state.hash = `#${hash}` }
Index.pushState(state)
return commands.length - leftover.length
},
pushState(state) {
let { search, hash, replace } = state
let pageBeforeSearch = history.state?.oldpage
if ((search != null) && (search !== Index.search)) {
state.page = search ? 1 : (pageBeforeSearch || 1)
if (!search) {
pageBeforeSearch = undefined
} else if (!Index.search) {
pageBeforeSearch = Index.currentPage
}
}
Index.setState(state)
const pathname = Index.currentPage === 1 ? `/${g.BOARD}/` : `/${g.BOARD}/${Index.currentPage}`
if (!hash) { hash = '' }
return history[replace ? 'replaceState' : 'pushState']({
mode: Conf['Index Mode'],
sort: Index.currentSort,
searched: Index.search,
oldpage: pageBeforeSearch
}
, '', `${location.protocol}//${location.host}${pathname}${hash}`)
},
setState({ search, mode, sort, page, hash }) {
if ((search != null) && (search !== Index.search)) {
Index.changed.search = true
Index.search = search
}
if ((mode != null) && (mode !== Conf['Index Mode'])) {
Index.changed.mode = true
Conf['Index Mode'] = mode
$.set('Index Mode', mode)
if ((mode !== 'catalog') && (Conf['Previous Index Mode'] !== mode)) {
Conf['Previous Index Mode'] = mode
$.set('Previous Index Mode', mode)
}
}
if ((sort != null) && (sort !== Index.currentSort)) {
Index.changed.sort = true
Index.currentSort = sort
Index.saveSort()
}
if (['all pages', 'catalog'].includes(Conf['Index Mode'])) { page = 1 }
if ((page != null) && (page !== Index.currentPage)) {
Index.changed.page = true
Index.currentPage = page
}
if (hash != null) {
return Index.changed.hash = true
}
},
savePerBoard(key, value) {
if (typeof Conf[key] === 'object') {
Conf[key][g.BOARD.ID] = value
} else {
Conf[key] = value
}
return $.set(key, Conf[key])
},
saveSort() {
return Index.savePerBoard('Index Sort', Index.currentSort)
},
saveLastLongThresholds(i) {
return Index.savePerBoard(`Last Long Reply Thresholds ${i}`, Index.lastLongThresholds[i])
},
pageLoad(scroll = true) {
if (!Index.liveThreadData) { return }
let { threads, order, search, mode, sort, page, hash } = Index.changed
if (!threads) { threads = search }
if (!order) { order = sort }
if (threads || order) { Index.sort() }
if (threads) { Index.buildPagelist() }
if (search) { Index.setupSearch() }
if (mode) { Index.setupMode() }
if (sort) { Index.setupSort() }
if (threads || mode || page || order) { Index.buildIndex() }
if (threads || page) { Index.setPage() }
if (scroll && !hash) { Index.scrollToIndex() }
if (hash) { Header.hashScroll() }
return Index.changed = {}
},
setupMode() {
for (const mode of ['paged', 'infinite', 'all pages', 'catalog']) {
$[mode === Conf['Index Mode'] ? 'addClass' : 'rmClass'](doc, `${mode.replace(/\ /g, '-')}-mode`)
}
Index.selectMode.value = Conf['Index Mode']
Index.cb.size()
Index.showHiddenThreads = false
return $('#hidden-toggle a', Index.navLinks).textContent = 'Show'
},
setupSort() {
Index.selectRev.checked = /-rev$/.test(Index.currentSort)
Index.selectSort.value = Index.currentSort.replace(/-rev$/, '')
return Index.lastLongOptions.hidden = (Index.selectSort.value !== 'lastlong')
},
getPagesNum() {
if (Index.search) {
return Math.ceil(Index.sortedThreadIDs.length / Index.threadsNumPerPage)
} else {
return Index.pagesNum
}
},
getMaxPageNum() {
return Math.max(1, Index.getPagesNum())
},
buildPagelist() {
const pagesRoot = $('.pages', Index.pagelist)
const maxPageNum = Index.getMaxPageNum()
if (pagesRoot.childElementCount !== maxPageNum) {
const nodes = []
for (let i = 1, end = maxPageNum; i <= end; i++) {
const a = $.el('a', {
textContent: i,
href: i === 1 ? './' : i
}
)
nodes.push($.tn('['), a, $.tn('] '))
}
$.rmAll(pagesRoot)
return $.add(pagesRoot, nodes)
}
},
setPage() {
let a, strong
const pageNum = Index.currentPage
const maxPageNum = Index.getMaxPageNum()
const pagesRoot = $('.pages', Index.pagelist)
// Previous/Next buttons
const prev = pagesRoot.previousElementSibling.firstElementChild
const next = pagesRoot.nextElementSibling.firstElementChild
let href = Math.max(pageNum - 1, 1)
prev.href = href === 1 ? './' : href
prev.firstElementChild.disabled = href === pageNum
href = Math.min(pageNum + 1, maxPageNum)
next.href = href === 1 ? './' : href
next.firstElementChild.disabled = href === pageNum
// <strong> current page
if (strong = $('strong', pagesRoot)) {
if (+strong.textContent === pageNum) { return }
$.replace(strong, strong.firstChild)
} else {
strong = $.el('strong')
}
if (a = pagesRoot.children[pageNum - 1]) {
$.before(a, strong)
return $.add(strong, a)
}
},
updateHideLabel() {
if (!Index.hideLabel) { return }
let hiddenCount = 0
for (const threadID of Index.liveThreadIDs) {
if (Index.isHidden(threadID)) {
hiddenCount++
}
}
if (!hiddenCount) {
Index.hideLabel.hidden = true
if (Index.showHiddenThreads) { Index.cb.toggleHiddenThreads() }
return
}
Index.hideLabel.hidden = false
return $('#hidden-count', Index.navLinks).textContent = hiddenCount === 1 ?
'1 hidden thread'
:
`${hiddenCount} hidden threads`
},
update(firstTime) {
let oldReq
if (oldReq = Index.req) {
delete Index.req
oldReq.abort()
}
if (Conf['Index Refresh Notifications']) {
// Optional notification for manual refreshes
if (!Index.notice) { Index.notice = new Notice('info', 'Refreshing index...') }
if (!Index.nTimeout) {
Index.nTimeout = setTimeout(() => {
if (Index.notice) {
Index.notice.el.lastElementChild.textContent += ' (disable JSON Index if this takes too long)'
}
}
, 3 * SECOND)
}
} else {
// Also display notice if Index Refresh is taking too long
if (!Index.nTimeout) {
Index.nTimeout = setTimeout(() => Index.notice || (Index.notice = new Notice('info', 'Refreshing index... (disable JSON Index if this takes too long)'))
, 3 * SECOND)
}
}
// Hard refresh in case of incomplete page load.
if (!firstTime && (d.readyState !== 'loading') && !$('.board + *')) {
location.reload()
return
}
Index.req = $.whenModified(
g.SITE.urls.catalogJSON({ siteID: g.SITE.siteID, boardID: g.BOARD.boardID }),
'Index',
Index.load
)
return $.addClass(Index.button, 'fa-spin')
},
load() {
let err
if (this !== Index.req) { return } // aborted
$.rmClass(Index.button, 'fa-spin')
const { notice, nTimeout } = Index
if (nTimeout) { clearTimeout(nTimeout) }
delete Index.nTimeout
delete Index.req
delete Index.notice
if (![200, 304].includes(this.status)) {
err = `Index refresh failed. ${this.status ? `Error ${this.statusText} (${this.status})` : 'Connection Error'}`
if (notice) {
notice.setType('warning')
notice.el.lastElementChild.textContent = err
setTimeout(notice.close, SECOND)
} else {
new Notice('warning', err, 1)
}
return
}
try {
if (this.status === 200) {
Index.parse(this.response)
} else if (this.status === 304) {
Index.pageLoad()
}
} catch (error) {
err = error
c.error(`Index failure: ${err.message}`, err.stack)
if (notice) {
notice.setType('error')
notice.el.lastElementChild.textContent = 'Index refresh failed.'
setTimeout(notice.close, SECOND)
} else {
new Notice('error', 'Index refresh failed.', 1)
}
return
}
if (notice) {
if (Conf['Index Refresh Notifications']) {
notice.setType('success')
notice.el.lastElementChild.textContent = 'Index refreshed!'
setTimeout(notice.close, SECOND)
} else {
notice.close()
}
}
const timeEl = $('#index-last-refresh time', Index.navLinks)
timeEl.dataset.utc = Date.parse(this.getResponseHeader('Last-Modified'))
return RelativeDates.update(timeEl)
},
parse(pages) {
$.cleanCache(url => /^https?:\/\/a\.4cdn\.org\//.test(url))
Index.parseThreadList(pages)
Index.changed.threads = true
return Index.pageLoad()
},
parseThreadList(pages) {
Index.pagesNum = pages.length
Index.threadsNumPerPage = pages[0]?.threads.length || 1
Index.liveThreadData = pages.reduce(((arr, next) => arr.concat(next.threads)), [])
Index.liveThreadIDs = Index.liveThreadData.map(data => data.no)
Index.liveThreadDict = dict()
Index.threadPosition = dict()
Index.parsedThreads = dict()
Index.replyData = dict()
for (let i = 0; i < Index.liveThreadData.length; i++) {
var obj, results
const data = Index.liveThreadData[i]
Index.liveThreadDict[data.no] = data
Index.threadPosition[data.no] = i
Index.parsedThreads[data.no] = (obj = g.SITE.Build.parseJSON(data, g.BOARD))
obj.filterResults = (results = Filter.test(obj))
obj.isOnTop = results.top
obj.isHidden = results.hide || ThreadHiding.isHidden(obj.boardID, obj.threadID)
if (data.last_replies) {
for (const reply of data.last_replies) {
Index.replyData[`${g.BOARD}.${reply.no}`] = reply
}
}
}
if (Index.liveThreadData[0]) {
g.SITE.Build.spoilerRange[g.BOARD.ID] = Index.liveThreadData[0].custom_spoiler
}
g.BOARD.threads.forEach(function (thread) {
if (!Index.liveThreadIDs.includes(thread.ID)) { return thread.collect() }
})
$.event('IndexUpdate',
{ threads: ((Index.liveThreadIDs.map((ID) => `${g.BOARD}.${ID}`))) })
},
isHidden(threadID) {
let thread
if ((thread = g.BOARD.threads.get(threadID)) && thread.OP && !thread.OP.isFetchedQuote) {
return thread.isHidden
} else {
return Index.parsedThreads[threadID].isHidden
}
},
isHiddenReply(threadID, replyData) {
return PostHiding.isHidden(g.BOARD.ID, threadID, replyData.no) || Filter.isHidden(g.SITE.Build.parseJSON(replyData, g.BOARD))
},
buildThreads(threadIDs, isCatalog, withReplies) {
let errors
const threads = []
const newThreads = []
let newPosts = []
for (const ID of threadIDs) {
var opRoot, thread
try {
var OP
const threadData = Index.liveThreadDict[ID]
if (thread = g.BOARD.threads.get(ID)) {
const isStale = (thread.json !== threadData) && (JSON.stringify(thread.json) !== JSON.stringify(threadData))
if (isStale) {
thread.setCount('post', threadData.replies + 1, threadData.bumplimit)
thread.setCount('file', threadData.images + !!threadData.ext, threadData.imagelimit)
thread.setStatus('Sticky', !!threadData.sticky)
thread.setStatus('Closed', !!threadData.closed)
}
if (thread.catalogView) {
$.rm(thread.catalogView.nodes.replies)
thread.catalogView.nodes.replies = null
}
} else {
thread = new Thread(ID, g.BOARD)
newThreads.push(thread)
}
const lastPost = threadData.last_replies && threadData.last_replies.length ? threadData.last_replies[threadData.last_replies.length - 1].no : ID
if (lastPost > thread.lastPost) { thread.lastPost = lastPost }
thread.json = threadData
threads.push(thread)
if ((OP = thread.OP) && !OP.isFetchedQuote) {
OP.setCatalogOP(isCatalog)
thread.setPage(Math.floor(Index.threadPosition[ID] / Index.threadsNumPerPage) + 1)
} else {
const obj = Index.parsedThreads[ID]
opRoot = g.SITE.Build.post(obj)
OP = new Post(opRoot, thread, g.BOARD)
OP.filterResults = obj.filterResults
newPosts.push(OP)
}
if (!isCatalog || !thread.nodes.root) {
g.SITE.Build.thread(thread, threadData, withReplies)
}
} catch (err) {
// Skip posts that we failed to parse.
if (!errors) { errors = [] }
errors.push({
message: `Parsing of Thread No.${thread} failed. Thread will be skipped.`,
error: err,
html: opRoot?.outerHTML
})
}
}
if (errors) { Main.handleErrors(errors) }
if (withReplies) {
newPosts = newPosts.concat(Index.buildReplies(threads))
}
Main.callbackNodes('Thread', newThreads)
Main.callbackNodes('Post', newPosts)
Index.updateHideLabel()
$.event('IndexRefreshInternal', { threadIDs: (threads.map((t) => t.fullID)), isCatalog })
return threads
},
buildReplies(threads) {
let errors
const posts = []
for (const thread of threads) {
var lastReplies
if (!(lastReplies = Index.liveThreadDict[thread.ID].last_replies)) { continue }
const nodes = []
for (const data of lastReplies) {
var node, post
if ((post = thread.posts.get(data.no)) && !post.isFetchedQuote) {
nodes.push(post.nodes.root)
continue
}
nodes.push(node = g.SITE.Build.postFromObject(data, thread.board.ID))
try {
posts.push(new Post(node, thread, thread.board))
} catch (err) {
// Skip posts that we failed to parse.
if (!errors) { errors = [] }
errors.push({
message: `Parsing of Post No.${data.no} failed. Post will be skipped.`,
error: err,
html: node?.outerHTML
})
}
}
$.add(thread.nodes.root, nodes)
}
if (errors) { Main.handleErrors(errors) }
return posts
},
buildCatalogViews(threads) {
const catalogThreads = []
for (const thread of threads) {
if (!thread.catalogView) {
const { ID } = thread
const page = Math.floor(Index.threadPosition[ID] / Index.threadsNumPerPage) + 1
const root = g.SITE.Build.catalogThread(thread, Index.liveThreadDict[ID], page)
catalogThreads.push(new CatalogThread(root, thread))
}
}
Main.callbackNodes('CatalogThread', catalogThreads)
},
sizeCatalogViews(threads) {
// XXX When browsers support CSS3 attr(), use it instead.
const size = Conf['Index Size'] === 'small' ? 150 : 250
for (const thread of threads) {
const { thumb } = thread.catalogView.nodes
const { width, height } = thumb.dataset
if (!width) { continue }
const ratio = size / Math.max(width, height)
thumb.style.width = (width * ratio) + 'px'
thumb.style.height = (height * ratio) + 'px'
}
},
buildCatalogReplies(thread) {
let lastReplies
const { nodes } = thread.catalogView
if (!(lastReplies = Index.liveThreadDict[thread.ID].last_replies)) { return }
const replies = []
for (const data of lastReplies) {
if (Index.isHiddenReply(thread.ID, data)) { continue }
const reply = g.SITE.Build.catalogReply(thread, data)
RelativeDates.update($('time', reply))
$.on($('.catalog-reply-preview', reply), 'mouseover', QuotePreview.mouseover)
replies.push(reply)
}
nodes.replies = $.el('div', { className: 'catalog-replies' })
$.add(nodes.replies, replies)
$.add(thread.OP.nodes.post, nodes.replies)
},
sort() {
let threadIDs
const { liveThreadIDs, liveThreadData } = Index
if (!liveThreadData) { return }
const tmp_time = new Date().getTime() / 1000
const sortType = Index.currentSort.replace(/-rev$/, '')
Index.sortedThreadIDs = (() => {
switch (sortType) {
case 'lastreply': case 'lastlong':
var repliesAvailable = liveThreadData.some(thread => thread.last_replies?.length)
var lastlong = function (thread) {
if (!repliesAvailable) {
return thread.last_modified
}
const iterable = thread.last_replies || []
for (let i = iterable.length - 1; i >= 0; i--) {
const r = iterable[i]
if (Index.isHiddenReply(thread.no, r)) { continue }
if (sortType === 'lastreply') {
return r
}
const len = r.com ? g.SITE.Build.parseComment(r.com).replace(/[^a-z]/ig, '').length : 0
if (len >= Index.lastLongThresholds[+!!r.ext]) {
return r
}
}
if (thread.omitted_posts && thread.last_replies?.length) { return thread.last_replies[0] } else { return thread }
}
var lastlongD = dict()
for (const thread of liveThreadData) {
lastlongD[thread.no] = lastlong(thread).no
}
return [...Array.from(liveThreadData)].sort((a, b) => lastlongD[b.no] - lastlongD[a.no]).map(post => post.no)
case 'bump': return liveThreadIDs
case 'birth': return [...Array.from(liveThreadIDs)].sort((a, b) => b - a)
case 'replycount': return [...Array.from(liveThreadData)].sort((a, b) => b.replies - a.replies).map(post => post.no)
case 'filecount': return [...Array.from(liveThreadData)].sort((a, b) => b.images - a.images).map(post => post.no)
case 'activity': return [...Array.from(liveThreadData)].sort((a, b) => ((tmp_time - a.time) / (a.replies + 1)) - ((tmp_time - b.time) / (b.replies + 1))).map(post => post.no)
default: return liveThreadIDs
}
})()
if (/-rev$/.test(Index.currentSort)) {
Index.sortedThreadIDs = [...Array.from(Index.sortedThreadIDs)].reverse()
}
if (Index.search && (threadIDs = Index.querySearch(Index.search))) {
Index.sortedThreadIDs = threadIDs
}
// Sticky threads
Index.sortOnTop(obj => obj.isSticky)
// Highlighted threads
Index.sortOnTop(obj => obj.isOnTop || (Conf['Pin Watched Threads'] && ThreadWatcher.isWatchedRaw(obj.boardID, obj.threadID)))
// Non-hidden threads
if (Conf['Anchor Hidden Threads']) { return Index.sortOnTop(obj => !Index.isHidden(obj.threadID)) }
},
sortOnTop(match) {
const topThreads = []
const bottomThreads = []
for (const ID of Index.sortedThreadIDs) {
(match(Index.parsedThreads[ID]) ? topThreads : bottomThreads).push(ID)
}
return Index.sortedThreadIDs = topThreads.concat(bottomThreads)
},
buildIndex() {
let threadIDs
if (!Index.liveThreadData) { return }
switch (Conf['Index Mode']) {
case 'all pages':
threadIDs = Index.sortedThreadIDs
break
case 'catalog':
threadIDs = Index.sortedThreadIDs.filter(ID => !Index.isHidden(ID) !== Index.showHiddenThreads)
break
default:
threadIDs = Index.threadsOnPage(Index.currentPage)
}
delete Index.pageNum
$.rmAll(Index.root)
$.rmAll(Header.hover)
if (Index.loaded && Index.root.parentNode) {
$.event('PostsRemoved', null, Index.root)
}
if (Conf['Index Mode'] === 'catalog') {
Index.buildCatalog(threadIDs)
} else {
Index.buildStructure(threadIDs)
}
},
threadsOnPage(pageNum) {
const nodesPerPage = Index.threadsNumPerPage
const offset = nodesPerPage * (pageNum - 1)
return Index.sortedThreadIDs.slice(offset, offset + nodesPerPage)
},
buildStructure(threadIDs) {
const threads = Index.buildThreads(threadIDs, false, Conf['Show Replies'])
const nodes = []
for (const thread of threads) {
nodes.push(thread.nodes.root, $.el('hr'))
}
$.add(Index.root, nodes)
if (Index.root.parentNode) {
$.event('PostsInserted', null, Index.root)
}
Index.loaded = true
},
buildCatalog(threadIDs) {
let i = 0
const n = threadIDs.length
let node0 = null
const fn = function () {
if (node0 && !node0.parentNode) { return } // Index.root cleared
const j = (i > 0) && Index.root.parentNode ? n : i + 30
node0 = Index.buildCatalogPart(threadIDs.slice(i, j))[0]
i = j
if (i < n) {
return $.queueTask(fn)
} else {
if (Index.root.parentNode) {
$.event('PostsInserted', null, Index.root)
}
return Index.loaded = true
}
}
fn()
},
buildCatalogPart(threadIDs) {
const threads = Index.buildThreads(threadIDs, true)
Index.buildCatalogViews(threads)
Index.sizeCatalogViews(threads)
const nodes = []
for (const thread of threads) {
thread.OP.setCatalogOP(true)
$.add(thread.catalogView.nodes.root, thread.OP.nodes.root)
nodes.push(thread.catalogView.nodes.root)
$.on(thread.catalogView.nodes.root, 'mouseenter', Index.cb.catalogReplies.bind(thread))
$.on(thread.OP.nodes.root, 'mouseenter', Index.cb.hoverAdjust.bind(thread.OP.nodes))
}
$.add(Index.root, nodes)
return nodes
},
clearSearch() {
Index.searchInput.value = ''
Index.onSearchInput()
return Index.searchInput.focus()
},
setupSearch() {
Index.searchInput.value = Index.search
if (Index.search) {
return Index.searchInput.dataset.searching = 1
} else {
// XXX https://bugzilla.mozilla.org/show_bug.cgi?id=1021289
return Index.searchInput.removeAttribute('data-searching')
}
},
onSearchInput() {
const search = Index.searchInput.value.trim()
if (search === Index.search) { return }
Index.pushState({
search,
replace: !!search === !!Index.search
})
return Index.pageLoad(false)
},
querySearch(query) {
let keywords, match
if (match = query.match(/^([\w+]+):\/(.*)\/(\w*)$/)) {
let regexp
try {
regexp = RegExp(match[2], match[3])
} catch (error) {
return []
}
return Index.sortedThreadIDs.filter(ID => regexp.test(Filter.values(match[1], Index.parsedThreads[ID]).join('\n')))
}
if (!(keywords = query.toLowerCase().match(/\S+/g))) { return }
return Index.sortedThreadIDs.filter(ID => Index.searchMatch(Index.parsedThreads[ID], keywords))
},
searchMatch(obj, keywords) {
const { info, file } = obj
if (info.comment == null) { info.comment = g.SITE.Build.parseComment(info.commentHTML.innerHTML) }
let text = []
for (const key of ['comment', 'subject', 'name', 'tripcode']) {
if (key in info) { text.push(info[key]) }
}
if (file) { text.push(file.name) }
text = text.join(' ').toLowerCase()
for (const keyword of keywords) {
if (-1 === text.indexOf(keyword.toLowerCase())) { return false }
}
return true
}
}
export default Index