4chan-x/src/classes/Post.ts
Tuxedo Takodachi 7d6dd7c653 Audio posts
Not yet available int the gallery. Ill add that if someone asks for it.
2023-06-24 17:52:48 +02:00

520 lines
16 KiB
TypeScript

import Get from "../General/Get";
import { g, Conf } from "../globals/globals";
import ImageExpand from "../Images/ImageExpand";
import $ from "../platform/$";
import $$ from "../platform/$$";
import type Board from "./Board";
import Callbacks from "./Callbacks";
import type Thread from "./Thread";
export interface File {
text: string,
link: HTMLAnchorElement,
thumb: HTMLElement,
thumbLink: HTMLElement,
size: string,
sizeInBytes: number,
isDead: boolean,
url: string,
name: string,
isImage: boolean,
isVideo: boolean,
isExpanding: boolean,
isExpanded: boolean,
fullImage?: HTMLImageElement | HTMLVideoElement,
audio?: HTMLAudioElement,
audioSlider?:HTMLSpanElement,
};
export default class Post {
declare root: HTMLElement;
declare thread: Thread;
declare board: Board;
declare ID: number;
declare postID: number;
declare threadID: number;
declare boardID: number | string;
declare siteID: number | string;
declare fullID: string;
declare context: Post;
declare isReply: boolean
declare nodes: ReturnType<Post['parseNodes']>;
declare isDead: boolean;
declare isHidden: boolean;
declare clones: any[];
declare isRebuilt?: boolean;
declare isFetchedQuote: boolean;
declare isClone: boolean;
declare quotes: string[];
declare file: ReturnType<Post['parseFile']>;
declare files: ReturnType<Post['parseFile']>[];
declare info: {
subject: string | undefined,
name: string | undefined,
email: string | undefined,
tripcode: string | undefined,
uniqueID: string | undefined,
capcode: string | undefined,
pass: string | undefined,
flagCode: string | undefined,
flagCodeTroll: string | undefined,
flag: string | undefined,
date: Date | undefined,
nameBlock: string,
};
// because of a circular dependency $ might not be initialized, so we can't use $.el
static deadMark = (() => {
const el = document.createElement('span');
// \u00A0 is nbsp
el.textContent = '\u00A0(Dead)';
el.className = 'qmark-dead';
return el;
})();
toString() { return this.ID; }
constructor(root?: HTMLElement, thread?: Thread, board?: Board, flags={}) {
// <% if (readJSON('/.tests_enabled')) { %>
// @normalizedOriginal = Test.normalize root
// <% } %>
// Skip initialization for PostClone
if (root === undefined && thread === undefined && board === undefined) return;
this.root = root;
this.thread = thread;
this.board = board;
$.extend(this, flags);
this.ID = +root.id.match(/\d*$/)[0];
this.postID = this.ID;
this.threadID = this.thread.ID;
this.boardID = this.board.ID;
this.siteID = g.SITE.ID;
this.fullID = `${this.board}.${this.ID}`;
this.context = this;
this.isReply = (this.ID !== this.threadID);
root.dataset.fullID = this.fullID;
this.nodes = this.parseNodes(root);
if (!this.isReply) {
this.thread.OP = this;
for (var key of ['isSticky', 'isClosed', 'isArchived']) {
var selector;
if (selector = g.SITE.selectors.icons[key]) {
this.thread[key] = !!$(selector, this.nodes.info);
}
}
if (this.thread.isArchived) {
this.thread.isClosed = true;
this.thread.kill();
}
}
const name = this.nodes.name?.textContent;
const tripcode = this.nodes.tripcode?.textContent;
this.info = {
subject: this.nodes.subject?.textContent || undefined,
name,
email: this.nodes.email ? decodeURIComponent(this.nodes.email.href.replace(/^mailto:/, '')) : undefined,
tripcode,
uniqueID: this.nodes.uniqueID?.textContent,
capcode: this.nodes.capcode?.textContent.replace('## ', ''),
pass: this.nodes.pass?.title.match(/\d*$/)[0],
flagCode: this.nodes.flag?.className.match(/flag-(\w+)/)?.[1].toUpperCase(),
flagCodeTroll: this.nodes.flag?.className.match(/bfl-(\w+)/)?.[1].toUpperCase(),
flag: this.nodes.flag?.title,
date: this.nodes.date ? g.SITE.parseDate(this.nodes.date) : undefined,
nameBlock: Conf['Anonymize'] ? 'Anonymous' : `${name || ''} ${tripcode || ''}`.trim(),
};
if (this.info.capcode) { this.info.nameBlock += ` ## ${this.info.capcode}`; }
if (this.info.uniqueID) { this.info.nameBlock += ` (ID: ${this.info.uniqueID})`; }
this.parseComment();
this.parseQuotes();
this.parseFiles();
this.isDead = false;
this.isHidden = false;
this.clones = [];
// <% if (readJSON('/.tests_enabled')) { %>
// return if @forBuildTest
// <% } %>
if (g.posts.get(this.fullID)) {
this.isRebuilt = true;
this.clones = g.posts.get(this.fullID).clones;
for (var clone of this.clones) { clone.origin = this; }
}
if (!this.isFetchedQuote && (this.ID > this.thread.lastPost)) { this.thread.lastPost = this.ID; }
this.board.posts.push(this.ID, this);
this.thread.posts.push(this.ID, this);
g.posts.push(this.fullID, this);
this.isFetchedQuote = false;
this.isClone = false;
}
parseNodes(root: HTMLElement) {
const s = g.SITE.selectors;
const post: HTMLElement = $(s.post, root) || root;
const info: HTMLElement = $(s.infoRoot, post);
interface Node {
root: HTMLElement,
bottom: false | HTMLElement,
post: HTMLElement,
info: HTMLElement,
comment: HTMLElement;
quotelinks: HTMLAnchorElement[],
archivelinks: HTMLAnchorElement[],
embedlinks: HTMLAnchorElement[],
backlinks: HTMLCollectionOf<HTMLAnchorElement>;
uniqueIDRoot: any,
uniqueID: any,
};
const nodes: Node & Partial<Record<keyof Post['info'], HTMLElement>> = {
root,
bottom: this.isReply || !g.SITE.isOPContainerThread ? root : $(s.opBottom, root),
post,
info,
comment: $(s.comment, post),
quotelinks: [],
archivelinks: [],
embedlinks: [],
backlinks: post.getElementsByClassName('backlink') as HTMLCollectionOf<HTMLAnchorElement>,
uniqueIDRoot: undefined as any,
uniqueID: undefined as any,
};
for (var key in s.info) {
var selector = s.info[key];
nodes[key] = $(selector, info);
}
g.SITE.parseNodes?.(this, nodes);
if (!nodes.uniqueIDRoot) { nodes.uniqueIDRoot = nodes.uniqueID; }
return nodes as Node & Record<keyof Post['info'], HTMLElement>;
}
parseComment() {
// Merge text nodes and remove empty ones.
let bq;
this.nodes.comment.normalize();
// Get the comment's text.
// <br> -> \n
// Remove:
// 'Comment too long'...
// EXIF data. (/p/)
this.nodes.commentClean = (bq = this.nodes.comment.cloneNode(true));
g.SITE.cleanComment?.(bq);
return this.info.comment = this.nodesToText(bq);
}
commentDisplay() {
// Get the comment's text for display purposes (e.g. notifications, excerpts).
// In addition to what's done in generating `@info.comment`, remove:
// Spoilers. (filter to '[spoiler]')
// Rolls. (/tg/, /qst/)
// Fortunes. (/s4s/)
// Preceding and following new lines.
// Trailing spaces.
const bq = this.nodes.commentClean.cloneNode(true);
if (!Conf['Remove Spoilers'] && !Conf['Reveal Spoilers']) { this.cleanSpoilers(bq); }
g.SITE.cleanCommentDisplay?.(bq);
return this.nodesToText(bq).trim().replace(/\s+$/gm, '');
}
commentOrig() {
// Get the comment's text for reposting purposes.
const bq = this.nodes.commentClean.cloneNode(true);
g.SITE.insertTags?.(bq);
return this.nodesToText(bq);
}
nodesToText(bq) {
let node;
let text = "";
const nodes = $.X('.//br|.//text()', bq);
let i = 0;
while ((node = nodes.snapshotItem(i++))) {
text += node.data || '\n';
}
return text;
}
cleanSpoilers(bq) {
const spoilers = $$(g.SITE.selectors.spoiler, bq);
for (var node of spoilers) {
$.replace(node, $.tn('[spoiler]'));
}
}
parseQuotes() {
this.quotes = [];
for (var quotelink of $$(g.SITE.selectors.quotelink, this.nodes.comment)) {
this.parseQuote(quotelink);
}
}
parseQuote(quotelink) {
// Only add quotes that link to posts on an imageboard.
// Don't add:
// - board links. (>>>/b/)
// - catalog links. (>>>/b/catalog or >>>/b/search)
// - rules links. (>>>/a/rules)
// - text-board quotelinks. (>>>/img/1234)
const match = quotelink.href.match(g.SITE.regexp.quotelink);
if (!match && (!this.isClone || !quotelink.dataset.postID)) { return; } // normal or resurrected quote
this.nodes.quotelinks.push(quotelink);
if (this.isClone) { return; }
// ES6 Set when?
const fullID = `${match[1]}.${match[3]}`;
if (!this.quotes.includes(fullID)) this.quotes.push(fullID);
}
parseFiles() {
let file;
this.files = [];
const fileRoots = this.fileRoots();
let index = 0;
for (let docIndex = 0; docIndex < fileRoots.length; docIndex++) {
var fileRoot = fileRoots[docIndex];
if (file = this.parseFile(fileRoot)) {
file.index = (index++);
file.docIndex = docIndex;
this.files.push(file);
}
}
if (this.files.length) {
return this.file = this.files[0];
}
}
fileRoots() {
if (g.SITE.selectors.multifile) {
const roots = $$(g.SITE.selectors.multifile, this.nodes.root);
if (roots.length) { return roots; }
}
return [this.nodes.root];
}
parseFile(fileRoot: HTMLElement) {
const file: Partial<File> = { isDead: false };
for (var key in g.SITE.selectors.file) {
var selector = g.SITE.selectors.file[key];
file[key] = $(selector, fileRoot);
}
file.thumbLink = file.thumb?.parentNode as HTMLElement;
if (!(file.text && file.link)) { return; }
if (!g.SITE.parseFile(this, file)) { return; }
$.extend(file, {
url: file.link.href,
isImage: $.isImage(file.link.href),
isVideo: $.isVideo(file.link.href)
}
);
let size = +file.size.match(/[\d.]+/)[0];
let unit = ['B', 'KB', 'MB', 'GB'].indexOf(file.size.match(/\w+$/)[0]);
while (unit-- > 0) { size *= 1024; }
file.sizeInBytes = size;
return file as File;
}
kill(file, index=0) {
let strong;
if (file) {
if (this.isDead || this.files[index].isDead) { return; }
this.files[index].isDead = true;
$.addClass(this.nodes.root, 'deleted-file');
} else {
if (this.isDead) { return; }
this.isDead = true;
$.rmClass(this.nodes.root, 'deleted-file');
$.addClass(this.nodes.root, 'deleted-post');
}
if (!(strong = $('strong.warning', this.nodes.info))) {
strong = $.el('strong',
{className: 'warning'});
$.after($('input', this.nodes.info), strong);
}
strong.textContent = file ? '[File deleted]' : '[Deleted]';
if (this.isClone) { return; }
for (var clone of this.clones) {
clone.kill(file, index);
}
if (file) { return; }
// Get quotelinks/backlinks to this post
// and paint them (Dead).
for (var quotelink of Get.allQuotelinksLinkingTo(this)) {
if (!$.hasClass(quotelink, 'deadlink')) {
$.add(quotelink, Post.deadMark.cloneNode(true));
$.addClass(quotelink, 'deadlink');
}
}
}
// XXX Workaround for 4chan's racing condition
// giving us false-positive dead posts.
resurrect() {
this.isDead = false;
$.rmClass(this.nodes.root, 'deleted-post');
const strong = $('strong.warning', this.nodes.info);
// no false-positive files
if (this.files.some(file => file.isDead)) {
$.addClass(this.nodes.root, 'deleted-file');
strong.textContent = '[File deleted]';
} else {
$.rm(strong);
}
if (this.isClone) { return; }
for (var clone of this.clones) {
clone.resurrect();
}
for (var quotelink of Get.allQuotelinksLinkingTo(this)) {
if ($.hasClass(quotelink, 'deadlink')) {
$.rm($('.qmark-dead', quotelink));
$.rmClass(quotelink, 'deadlink');
}
}
}
collect() {
g.posts.rm(this.fullID);
this.thread.posts.rm(this);
this.board.posts.rm(this);
}
addClone(context, contractThumb) {
// Callbacks may not have been run yet due to anti-browser-lock delay in Main.callbackNodesDB.
Callbacks.Post.execute(this);
return new PostClone(this, context, contractThumb);
}
rmClone(index) {
this.clones.splice(index, 1);
for (var clone of this.clones.slice(index)) {
clone.nodes.root.dataset.clone = index++;
}
}
setCatalogOP(isCatalogOP) {
this.nodes.root.classList.toggle('catalog-container', isCatalogOP);
this.nodes.root.classList.toggle('opContainer', !isCatalogOP);
this.nodes.post.classList.toggle('catalog-post', isCatalogOP);
this.nodes.post.classList.toggle('op', !isCatalogOP);
this.nodes.post.style.left = (this.nodes.post.style.right = null);
}
};
export class PostClone extends Post {
declare origin: Post;
static suffix = 0;
constructor(origin, context, contractThumb) {
super();
this.isClone = true;
let file, fileRoots, key;
this.origin = origin;
this.context = context;
for (key of ['ID', 'postID', 'threadID', 'boardID', 'siteID', 'fullID', 'board', 'thread', 'info', 'quotes', 'isReply']) {
// Copy or point to the origin's key value.
this[key] = this.origin[key];
}
const { nodes } = this.origin;
const root = contractThumb ? this.cloneWithoutVideo(nodes.root) : nodes.root.cloneNode(true);
for (var node of [root, ...$$('[id]', root)]) {
node.id += `_${PostClone.suffix}`;
}
PostClone.suffix++;
// Remove inlined posts inside of this post.
for (var inline of $$('.inline', root)) {
$.rm(inline);
}
for (var inlined of $$('.inlined', root)) {
$.rmClass(inlined, 'inlined');
}
this.nodes = this.parseNodes(root);
root.hidden = false; // post hiding
$.rmClass(root, 'forwarded'); // quote inlining
$.rmClass(this.nodes.post, 'highlight'); // keybind navigation, ID highlighting
// Remove catalog stuff.
if (!this.isReply) {
this.setCatalogOP(false);
$.rm($('.catalog-link', this.nodes.post));
$.rm($('.catalog-stats', this.nodes.post));
$.rm($('.catalog-replies', this.nodes.post));
}
this.parseQuotes();
this.quotes = [...this.origin.quotes];
this.files = [];
if (this.origin.files.length) { fileRoots = this.fileRoots(); }
for (var originFile of this.origin.files) {
// Copy values, point to relevant elements.
file = { ...originFile };
var fileRoot = fileRoots[file.docIndex];
for (key in g.SITE.selectors.file) {
var selector = g.SITE.selectors.file[key];
file[key] = $(selector, fileRoot);
}
file.thumbLink = file.thumb?.parentNode;
if (file.thumbLink) { file.fullImage = $('.full-image', file.thumbLink); }
file.videoControls = $('.video-controls', file.text);
if (file.videoThumb) { file.thumb.muted = true; }
this.files.push(file);
}
if (this.files.length) {
this.file = this.files[0];
// Contract thumbnails in quote preview
if (this.file.thumb && contractThumb) { ImageExpand.contract(this); }
}
if (this.origin.isDead) { this.isDead = true; }
root.dataset.clone = this.origin.clones.push(this) - 1;
return this;
}
cloneWithoutVideo(node) {
if ((node.tagName === 'VIDEO') && !node.dataset.md5) { // (exception for WebM thumbnails)
return [];
} else if ((node.nodeType === Node.ELEMENT_NODE) && $('video', node)) {
const clone = node.cloneNode(false);
for (var child of node.childNodes) { $.add(clone, this.cloneWithoutVideo(child)); }
return clone;
} else {
return node.cloneNode(true);
}
}
};