Adds various features to Holotower
// ==UserScript==
// @name Holotower TS
// @namespace Holotower-TS
// @version 2.0.1
// @author Anonymous
// @license MIT
// @description Adds various features to Holotower
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAVFBMVEVHcEwwP4D1w7eCb344MmLhubU6OGc2RIvp1Njyn4r0dBv7eyKUIgHpmWHhYAjpcB3w7/XvpHD1iT+vKQH5+fvOeFrbhV31soLv4OXBPw72vpdmcK6vNWMgAAAACnRSTlMAQnVHIKlv8dwqvTaiTAAAAQBJREFUeNqFz1uOhCAARNGaHt8KiIqC7n+fgwVDIh31/BluCgQZvBiG12BwL4FnngKaDRKt1nXy9CWYXQqElKrr+h4Bj8mlQJ2BTsEcOWdCwIEJkaGVzMqAF+y4JeJA7tM0zYcBX/iltKNdxrINgcCXxo7e0pxBfoEG0Fo7EoPteq6AovTnyxKCfMAZoKoPu3jjAujsF4VbAfzUR1rIBqRc4RX18b+AC8WAG81obYmclFKAqvq3bZFT0p0B1XWB3CalSkHFIB/gFbeEOjHQkSBEHekQ933vP5Q6VxFMKaDJF0ykAu19oBHtKQHtWwAiHRKFW3zKY8CnTHjAp+BFeNYfKeQVY6F7prQAAAAASUVORK5CYII=
// @match *://boards.holotower.org/*
// @match *://holotower.org/*
// @connect *
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// === Settings ===
const settings = JSON.parse(localStorage.getItem("Thread Settings")) || {};
const showDeletedCounter = settings.showDeletedCounter ?? true;
const showDeletedIcon = settings.showDeletedIcon ?? true;
const showDeletedText = settings.showDeletedText ?? false;
const hideDeleted = settings.hideDeletedPosts ?? false;
const showArchivedMessage = settings.showArchivedMessage ?? true;
const enableFaviconChanges = settings.faviconUpdater ?? true;
const notifyNewPost = settings.notifyNewPost ?? true;
const notifyNewYou = settings.notifyNewYou ?? true;
const changeFaviconOnArchive = settings.changeFaviconOnArchive ?? true;
const showUnreadLine = settings.showUnreadLine ?? true;
const appendQuotes = settings.appendQuotes ?? true;
const appendCrossThread = settings.appendCrossThread ?? false;
const optionThreading = settings.optionThreading ?? true;
const enableThreading = settings.enableThreading ?? false;
const hidePosts = settings.hidePosts ?? true;
const recursiveHiding = settings.recursiveHiding ?? true;
const showStubs = settings.showStubs ?? true;
const filenameChanger = settings.filenameChanger ?? true;
const linkPreview = settings.linkPreview ?? true;
const urlUpload = settings.urlUpload ?? true;
const thumbnailSwap = settings.thumbnailSwap ?? true;
const linkEmbed = settings.linkEmbed ?? true;
const translateAuto = settings.translateAuto ?? false;
const directButton = settings.directButton ?? false;
const videoHover = settings.videoHover ?? true;
const videoScrollVol = settings.videoScrollVol ?? true;
const showSpoilerText = settings.showSpoilerText ?? false;
const showSpoilerMedia = settings.showSpoilerMedia ?? false;
const linkIcon = settings.linkIcon ?? true;
const linkTitle = settings.linkTitle ?? true;
const randomizeClipboard = settings.randomizeClipboard ?? false;
const kbOptions = settings.kbOptions || { ctrl: false, alt: true, shift: false, key: "o" };
const kbThreadToggle = settings.kbThreadToggle || { ctrl: false, alt: false, shift: true, key: "t" };
const kbThreadNew = settings.kbThreadNew || { ctrl: false, alt: false, shift: false, key: "t" };
const kbURL = settings.kbURL || { ctrl: false, alt: true, shift: false, key: "l" };
const persistentEffect = settings.persistentEffect ?? false;
const persistentDecor = settings.persistentDecor ?? false;
const FAVICON_URL = window.location.hostname === 'boards.holotower.org'
? 'https://boards.holotower.org/favicon.gif'
: 'https://holotower.org/favicon.gif';
let alertState = 'none';
const notifyPostColor = settings.notifyPostColor || "white";
const notifyYouColor = settings.notifyYouColor || "red";
const updaterCheckbox = document.getElementById('auto_update_status');
const hiddenSet = new Set();
let lastPostCount = null;
let lastSeenPostId = 0;
let lastLine = 0;
let hasUnreadLine = false;
let currentBoard = null;
let currentThreadId = null;
let lowPostWarningCount = 0;
let isLargeDrop = false;
let isThreadArchived = false;
let updaterDisabled = false;
let currentLastPostId = 0;
let lastThreadedId = 0;
let toggleThread = enableThreading;
function setFavicon(url) {
let link = document.querySelector("link[rel*='icon']") || document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = url;
if (!link.parentNode) document.head.appendChild(link);
}
function updateFavicon(color) {
if (isThreadArchived) return;
if (alertState === 'red' && color === 'white') return;
if (alertState === color) return;
const drawColor = color === 'red' ? notifyYouColor : notifyPostColor;
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
ctx.beginPath();
ctx.arc(canvas.width - 16, 16, 5, 0, 2 * Math.PI);
ctx.fillStyle = drawColor;
ctx.fill();
setFavicon(canvas.toDataURL('image/x-icon'));
alertState = color;
};
img.src = FAVICON_URL;
}
function updateFaviconArchived() {
if (!changeFaviconOnArchive) return;
isThreadArchived = true;
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = 'source-over';
if (alertState === 'red') {
const drawColor = notifyYouColor;
ctx.beginPath();
ctx.arc(canvas.width - 16, 16, 5, 0, 2 * Math.PI);
ctx.fillStyle = drawColor;
ctx.fill();
}
setFavicon(canvas.toDataURL('image/x-icon'));
};
img.src = FAVICON_URL;
}
function revertFavicon() {
if (isThreadArchived) return;
if (alertState !== 'none') {
setFavicon(FAVICON_URL);
alertState = 'none';
}
}
window.addEventListener('scroll', () => {
if (window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2) {
revertFavicon();
removeUnreadLine();
}
});
function hoverQuoteReplies() {
document.body.addEventListener('mouseenter', (e) => {
const link = e.target.closest('a[href*="#"]');
if (!link) return;
const match = link.href.match(/#(\d+)$/);
if (!match) return;
const postId = match[1];
requestAnimationFrame(() => {
const hover = document.getElementById(`post-hover-${postId}`);
if (!hover) return;
const originalPost = document.getElementById(`reply_${postId}`) ||
document.getElementById(`op_${postId}`);
if (!originalPost) return;
const mentioned = originalPost.querySelector('.mentioned.unimportant');
if (!mentioned) return;
const intro = hover.querySelector('p.intro');
if (intro && !intro.querySelector('.mentioned.unimportant')) {
intro.appendChild(mentioned.cloneNode(true));
}
});
}, { capture: true });
}
const appendedPostIds = new Set();
function appendPosts(post) {
const body = post.querySelector('.body');
if (!body) return;
const postId = post.id.split('_')[1];
appendedPostIds.add(postId);
const smallTags = body.querySelectorAll('small');
let visibleCount = 0;
for (let j = 0; j < smallTags.length; j++) {
if (getComputedStyle(smallTags[j]).display !== 'none') visibleCount++;
}
const skipAppendingYou = visibleCount < smallTags.length;
for (let j = 0; j < smallTags.length; j++) {
const small = smallTags[j];
const label = small.textContent.trim();
if (label !== '(You)' && label !== '(OP)') continue;
const isVisible = getComputedStyle(small).display !== 'none';
let target = small.previousSibling;
while (target && (target.nodeType !== 1 || target.tagName !== 'A')) {
target = target.previousSibling;
}
if (target?.tagName !== 'A') continue;
if (isVisible) {
small.setAttribute('style', 'display: none !important;');
if (label === '(You)' && skipAppendingYou) continue;
if (!target.textContent.includes(label)) {
target.textContent += ` ${label}`;
}
} else if (label !== '(You)' && !target.textContent.includes(label)) {
target.textContent += ` ${label}`;
}
}
const links = body.querySelectorAll('a[href*="/res/"]');
for (let j = 0; j < links.length; j++) {
const link = links[j];
const href = link.getAttribute('href');
const match = href?.match(/\/res\/(\d+)\.html#(\d+)/);
if (!match) continue;
const linkThreadId = match[1];
const quotedPostId = match[2];
const hasFollowingSmall = link.nextSibling &&
link.nextSibling.nodeType === 1 &&
link.nextSibling.tagName === 'SMALL';
if (quotedPostId === currentThreadId && !hasFollowingSmall && !link.textContent.includes('(OP)')) {
link.textContent += ' (OP)';
}
if (linkThreadId !== currentThreadId && !hasFollowingSmall && !link.textContent.includes('→') && !link.textContent.includes('(Cross-thread)')) {
link.textContent += appendCrossThread ? ' (Cross-thread)' : ' →';
}
}
}
function addHideButton(post) {
if (!hidePosts || post.dataset.hideButton) return;
post.dataset.hideButton = '1';
const btn = document.createElement('a');
btn.className = 'reply hide-button';
btn.setAttribute('for', post.id);
btn.href = 'javascript:void(0)';
btn.innerHTML = `
<span class="hide-icon">
<svg xmlns="http://www.w3.org/2000/svg" class="minus" viewBox="0 0 448 512">
<path d="M64 80c-8.8 0-16 7.2-16 16V416c0 8.8 7.2 16 16 16H384c8.8 0 16-7.2 16-16V96c0-8.8-7.2-16-16-16H64zM0 96C0 60.7 28.7 32 64 32H384c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM152 232H296c13.3 0 24 10.7 24 24s-10.7 24-24 24H152c-13.3 0-24-10.7-24-24s10.7-24 24-24z" fill="currentColor"/>
</svg>
</span>
`;
btn.addEventListener('click', () => toggleHidePost(post, btn));
post.insertAdjacentElement('beforebegin', btn);
}
function toggleHidePost(post, btn, recursive = false, originPostId = null, suppressSave = false) {
const postId = post.id.split('_')[1];
const isHidden = post.classList.toggle('hidden-post');
if (!showStubs) {
post.classList.toggle('hidden-post', isHidden);
if (btn) btn.classList.toggle('hidden-post', isHidden);
const br = post.nextElementSibling;
if (br?.tagName === 'BR') br.classList.toggle('hidden-post', isHidden);
} else {
if (isHidden) {
btn.className = 'reply show-button';
btn.innerHTML = `
<span class="show-icon">
<svg xmlns="http://www.w3.org/2000/svg" class="plus" viewBox="0 0 448 512">
<path d="M64 80c-8.8 0-16 7.2-16 16V416c0 8.8 7.2 16 16 16H384c8.8 0 16-7.2 16-16V96c0-8.8-7.2-16-16-16H64zM0 96C0 60.7 28.7 32 64 32H384c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 344V280H136c-13.3 0-24-10.7-24-24s10.7-24 24-24h64V168c0-13.3 10.7-24 24-24s24 10.7 24 24v64h64c13.3 0 24 10.7 24 24s-10.7 24-24 24H248v64c0 13.3-10.7 24-24 24s-24-10.7-24-24z" fill="currentColor"/>
</svg>
</span>
`;
const nameSpan = post.querySelector('p.intro span.name');
const name = nameSpan ? nameSpan.textContent.trim() : 'Anonymous';
const hideName = document.createElement('span');
hideName.className = 'hide-name';
hideName.textContent = name;
const hideReason = document.createElement('span');
hideReason.className = 'hide-reason';
hideReason.textContent = recursive
? ` (Hidden recursively from ${originPostId})`
: ' (Hidden manually)';
btn.appendChild(hideName);
btn.appendChild(hideReason);
} else {
btn.className = 'reply hide-button';
btn.innerHTML = `
<span class="hide-icon">
<svg xmlns="http://www.w3.org/2000/svg" class="minus" viewBox="0 0 448 512">
<path d="M64 80c-8.8 0-16 7.2-16 16V416c0 8.8 7.2 16 16 16H384c8.8 0 16-7.2 16-16V96c0-8.8-7.2-16-16-16H64zM0 96C0 60.7 28.7 32 64 32H384c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM152 232H296c13.3 0 24 10.7 24 24s-10.7 24-24 24H152c-13.3 0-24-10.7-24-24s10.7-24 24-24z" fill="currentColor"/>
</svg>
</span>
`;
}
}
if (isHidden) {
hiddenSet.add(postId);
} else {
hiddenSet.delete(postId);
}
if (!recursive && !suppressSave) {
const latest = JSON.parse(localStorage.getItem("Thread Settings") || '{}');
if (!latest.hiddenPosts) latest.hiddenPosts = {};
if (!latest.hiddenPosts[currentBoard]) latest.hiddenPosts[currentBoard] = {};
if (!latest.hiddenPosts[currentBoard][currentThreadId]) latest.hiddenPosts[currentBoard][currentThreadId] = [];
const postList = latest.hiddenPosts[currentBoard][currentThreadId];
const index = postList.indexOf(postId);
if (isHidden) {
if (index === -1) postList.push(postId);
} else {
if (index !== -1) postList.splice(index, 1);
}
localStorage.setItem("Thread Settings", JSON.stringify(latest));
}
if (!recursive) {
toggleRecursive(postId, isHidden ? 'hide' : 'unhide');
}
if (
isHidden &&
postId === lastLine &&
showUnreadLine &&
!hasUnreadLine
) {
removeUnreadLine();
}
}
function toggleRecursive(postId, action = 'hide', visited = new Set()) {
if (visited.has(postId)) return;
visited.add(postId);
const anchors = document.querySelectorAll(`.post.reply .body a[href$="#${postId}"]`);
for (let i = 0; i < anchors.length; i++) {
const link = anchors[i];
const container = link.closest('.post.reply');
if (!container) continue;
const replyId = container.id;
const btn = document.querySelector(`a.reply[for="${replyId}"].hide-button, a.reply[for="${replyId}"].show-button`);
const alreadyHidden = container.classList.contains('hidden-post');
if (recursiveHiding) {
if (action === 'hide' && !alreadyHidden) {
toggleHidePost(container, btn, true, postId);
toggleRecursive(replyId.split('_')[1], 'hide', visited);
} else if (action === 'unhide' && alreadyHidden) {
toggleHidePost(container, btn, true, postId);
toggleRecursive(replyId.split('_')[1], 'unhide', visited);
}
}
if (action === 'hide') {
link.style.textDecoration = 'underline line-through';
} else {
link.style.textDecoration = '';
}
}
}
const threadedPostIds = new Set();
let threadButton = null;
const iconStreamable = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAOwgAADsIBFShKgAAAAFdQTFRFR3BMD5D6F5T7DpD6DpD6DpD6D5D6EZH6DY79DpD6D5D8D5D6DpD7TqD7D5D6M5v7MZr7DpD6/f7/PZ378Pj/Wqn7ebT84e7+jr/8IpX7xt7+oMb8l8L8dvSONQAAABF0Uk5TAIArtvqSzvIGiCPWHpKm1dj2KFEKAAAAkUlEQVR42k3PbRLBMBSG0ee++bpRjO5/lfxApUkNhnF2cHjZWy4l1x0fbnF+i9V5Oc4/wQGb/1S0D4AciA5NZieimtRjU1xYlUDk3Euv4SEkI7ax3MJd15XEXQAG5o0MoI2oMeW+FBmNatVJY0hbh7yw2u5xwNcxUOgDzlDnPwmxXfi5BACv31xyPpSmUqYkgCeZritCO6onSAAAAABJRU5ErkJggg==';
const iconPixiv = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAOwwAADsMBx2+oZAAAAC1QTFRFAJb6AJb6AJb6AJb6////8Pj/5PL+2Or+xeD+qtH9kcP8dLb7Q6X7Epr6AJX6FLjp4QAAAAR0Uk5T/wJx52ZYi2kAAABZSURBVHjaYxAyYAACZkUGBQYwYGIwgDCYgXjP7TVgNl9ZiksNmFHi3uoOYTTwpGwAMzYwTJkAZixgaFkAZkSfdD0AZri4eEEUT8/YAGa0HQBRcCsQlsKcAQAM1xU5J7LrcwAAAABJRU5ErkJggg==';
const iconVocaroo = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAh1BMVEVHcEw9aRGOrVvH63RjrCAzUxGgxFUhQQNjiC09ZRJfnSMOKACSuktPdiRBYxx3mESEp0N4nD6v0WaQtFGgwlxXhyFEgQ5mqCSqwIat0GLV+X6+4WuTwkmpz1202GOasWTM8HizwGfjOVO/1XjEoGFbY0nW9JGAj1vNzc2pt4d9hmf19fXNYlSGMR5dAAAAGnRSTlMAbW3+/CGfE2ZG3wnukDBDnsPr4cS5j+Wz2uQ4lkcAAACuSURBVHjaBcEFYsMwAATBNUpmCLUnMCbF/78vMwAAppvK0gIAphquwTkXYwUASXdkkuTcCADpS5Ik+fwGwIckrV+HQl4DNJf4u2n5firzCUDvl7+n1p9FChWAqdclZG6VdDFAUzSz5KLkygJI27oZPmMI/pZUAEWf3zHj5H30vgLA5l29vbQd4ZFbAOb2bs99/3fltQWgLyi2fT/VPhIAAGZ3Kpu8BQAgLdPBMFreBWQOpOs51woAAAAASUVORK5CYII=';
const iconTwitch = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAuIgAALiIBquLdkgAAAB5QTFRFR3BMZEGlZEGlZEGlZEGlZEGlZEGlZEGlZEGlZEGlVumfBAAAAAl0Uk5TABAlQFV/o8vvnShU+QAAAFRJREFUeNpjYJkJBhMY2KeFAkH4BAbOyQxAwApmKDMYQRiRDFMJMoCADcbILGDgnGosaGw6XYCBc+ZM95kzCxlAjM6ZQAEGZuPMGc6GDCAQ2cSADAD/Qx99Dm+XMQAAAABJRU5ErkJggg==';
const iconTwitter = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAuIgAALiIBquLdkgAAAC1QTFRFR3BMHaHyHaHyHaHyHaHyHaHyHaHyHaHyHaHyHaHyHaHyHaHyHaHyHaHyHaHy+JamXQAAAA50Uk5TAAQQHDVMXnaIn8DS4vLdwynJAAAAYUlEQVR42o3OQQ6AMAhE0V8s2NbC/Y9rImqauPHtGIYEPmQjtYguWjHPpMfF0AgFtpxdEY84jJrBDiW7/lygsarAWOZZAFmSBqyV/IA6PJKRyt3Zc9+P96eLtOnuc1jhpxP+pwcBWPTPAQAAAABJRU5ErkJggg==';
const iconX = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAuIgAALiIBquLdkgAAAJ9JREFUeNqFkcENhCAUROdoC5ZhLGUr2AJswAZswg6sgwaILXDn6mmWDE5icKOZAxPyCA8+MCB2xJ90RMQARPAhEV2lletp7wMEJx7KSihL6ZvhuqxMnJk5nXggHJfAXdiXubT+DoxM3Bh4MJWOOwCdnYWhBZzAbJMWsHuSSebYAnbvT5O9BT52V1uuJn6B3H2VUk381c2YFLX3Yb2N+wd00i149msMYgAAAABJRU5ErkJggg==';
(function addStyle() {
const style = document.createElement('style');
style.id = 'htsu-style';
let css = `
.threadingContainer {
margin-left: 20px;
background: inherit !important;
border-color: inherit !important;
border-width: inherit !important;
border-style: inherit !important;
border-left: 1px solid rgba(128, 128, 128, 0.3) !important;
}
.reply.hide-button {
float: left;
padding: 2px;
background: inherit !important;
border-color: inherit !important;
border-width: inherit !important;
border-style: inherit !important;
border-left: none !important;
}
.reply.hide-button:not(:hover) {
opacity: 0.4;
background: inherit !important;
border-color: inherit !important;
border-width: inherit !important;
border-style: inherit !important;
border-left: none !important;
}
.reply.show-button {
padding: 2px;
text-decoration: initial;
background: inherit !important;
border-color: inherit !important;
border-width: inherit !important;
border-style: inherit !important;
border-left: none !important;
}
.threadingContainer .reply.hide-button {
margin-left: 2px !important;
position: relative;
left: 1px;
}
.threadingContainer .reply.show-button {
margin-left: 2px !important;
position: relative;
left: 1px;
}
.threadingContainer.post-hover {
display: none !important;
}
svg.plus {
height: 1em;
width: 1em;
display: inline-flex;
vertical-align: -.150em;
margin-right: 0.5ch;
}
svg.minus {
height: 1em;
width: 1em;
display: inline-flex;
vertical-align: -.500em;
}
.hidden-post {
display: none !important;
}
.hidden-post.post-hover {
display: inline-block !important;
}
.hidden-post.inline-cloned-post {
display: inline-block !important;
}
div.qp[id^="iq-preview-"] > .post {
display: inline-block !important;
}
:root.reply-fit-width .post.reply.hidden-post {
display: none !important;
}
:root.reply-fit-width .post.reply.hidden-post.post-hover {
display: inline-block !important;
}
:root.reply-fit-width .post.reply.hidden-post.inline-cloned-post {
display: inline-block !important;
}
:root.reply-fit-width div.qp[id^="iq-preview-"] > .post {
display: inline-block !important;
}
.post-hover .mentioned {
word-break: break-word;
}
html.mobile-style-new.desktop-floating-mode #mbDesktopFloat #mbRandomizerBtn.active {
border: 3px solid #ff511c;
background: rgba(255,81,28,.15)
}
html.mobile-style-new #mobileReplyDrawer #mbRandomizerBtn {
border: 3px solid rgba(255,255,255,.25);
box-sizing: border-box;
transition: background-color .15s,border-color .15s,box-shadow .15s;
padding: .45rem .55rem
}
html.mobile-style-new #mobileReplyDrawer #mbRandomizerBtn.active {
border-color: #ff511c;
background: rgba(255,81,28,.15)
}
#conversion_notification {
position: fixed;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
width: 100%;
height: 100%;
text-align: center;
z-index: 9901;
background: inherit;
visibility: hidden;
}
#conversion_div {
border: 1px solid black;
display: inline-block;
position: relative;
margin-top: 20px;
background: inherit;
visibility: visible;
}
#conversion_close {
top: 0px;
right: 0px;
position: absolute;
margin-right: 3px;
z-index: 100;
}
html:is(.mobile-style, .mobile-style-new):not(.desktop-floating-mode) .reply.hide-button,
html:is(.mobile-style, .mobile-style-new):not(.desktop-floating-mode) .reply.show-button {
position: absolute;
transform: translate(-6px, -10px);
}
html:is(.mobile-style, .mobile-style-new):not(.desktop-floating-mode) .threadingContainer .reply.hide-button,
html:is(.mobile-style, .mobile-style-new):not(.desktop-floating-mode) .threadingContainer .reply.show-button {
transform: translate(0px, 0px);
}
.twt-embed,
.twt-threadwrapper {
margin: 6px 0;
background: inherit;
max-width: 550px;
}
html:is(.mobile-style, .mobile-style-new):not(.desktop-floating-mode) .twt-embed,
html:is(.mobile-style, .mobile-style-new):not(.desktop-floating-mode) .twt-threadwrapper {
max-width: 100%;
}
.twt-header {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 6px;
justify-self: start;
color: inherit !important;
text-decoration: inherit !important;
}
.twt-header img.twt-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex: 0 0 auto;
}
.twt-meta {
font-size: 13px;
}
.twt-meta .handle {
opacity: 0.5;
display: block;
}
.twt-text {
margin-bottom: 6px;
}
.twt-translate {
font-size: 12px;
margin-bottom: 6px;
display: flex;
align-items: center;
}
.twt-translate span {
opacity: 0.4;
}
.photos-grid {
display: grid;
gap: 2px;
grid-template-columns: 1fr 1fr;
}
.photos-grid.single {
grid-template-columns: 1fr;
}
.photo-link {
justify-self: start;
display: inline-block;
pointer-events: none;
}
.photo-img {
display: block;
max-width: 100%;
max-height: 550px;
pointer-events: auto;
}
.photo-img.expanded {
max-width: 100%;
max-height: 100%;
}
html:is(.mobile-style, .mobile-style-new):not(.desktop-floating-mode) .photo-img {
max-height: 100%;
}
.twt-video {
max-width: 100%;
max-height: 100vh;
display: block;
}
.twt-footer {
margin-top: 6px;
font-size: 12px;
opacity: 0.6;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.twt-footer i.fa {
opacity: 0.7;
margin-top: -2px !important;
}
.twt-footer span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.twt-embed a {
text-decoration: none;
color: inherit;
}
.twt-quote {
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
border-radius: 12px;
max-width: 100%;
padding: 6px;
}
.twt-threadwrapper {
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
border-radius: 12px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 12px;
position: relative;
}
.twt-threadwrapper .twt-replychain {
border: none !important;
background: inherit;
position: relative;
}
.twt-threadwrapper .twt-replychain > *:not(.twt-header) {
margin-left: 45px;
}
.twt-replychain::before {
content: '';
position: absolute;
top: 38px;
left: 16px;
width: 4px;
height: calc(100% - 16px);
background-color: currentColor;
opacity: 0.2;
z-index: 0;
}
.twt-threadwrapper > .twt-replychain:last-child::before {
height: 0px;
}
.twt-poll {
border-radius: 6px;
}
.twt-poll-choices {
display: flex;
flex-direction: column;
gap: 4px;
}
.twt-poll-choice {
position: relative;
}
.twt-poll-bar-bg {
position: relative;
background: rgba(0, 0, 0, 0.06);
padding: 6px 50px 6px 8px;
overflow: hidden;
}
.twt-poll-bar {
position: absolute;
inset: 0;
background: linear-gradient(90deg, color-mix(in srgb, currentColor 50%, transparent), color-mix(in srgb, currentColor 40%, transparent));
border-radius: 6px 0 0 6px;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 0;
}
.twt-poll-label {
position: relative;
z-index: 1;
color: currentColor;
cursor: default;
}
.twt-poll-pct {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
color: currentColor;
}
.twt-poll-footer {
display: flex;
flex-direction: row;
margin-top: 6px;
font-size: 12px;
opacity: 0.6;
gap: 8px;
}
.embed-active {
font-weight: bolder;
text-decoration: underline dotted;
}
div.post i.fa-youtube-play,
div.post i.fa-streamable,
div.post i.fa-pixiv,
div.post i.fa-twt,
div.post i.fa-x,
div.post i.fa-vocaroo,
div.post i.fa-twitchtv,
div.post i.fa-image,
div.post i.fa-music,
div.post i.fa-film {
position: relative;
display: inline;
top: 2px !important;
margin-right: 4px;
}
div.post i.fa-youtube-play {
color: #ff0033;
}
.fa-streamable::before {
content: "";
background: transparent url('data:image/png;base64,${iconStreamable}') center left no-repeat !important;
padding-left: 16px;
}
.fa-pixiv::before {
content: "";
background: transparent url('data:image/png;base64,${iconPixiv}') center left no-repeat !important;
padding-left: 16px;
}
.fa-vocaroo::before {
content: "";
background: transparent url('data:image/png;base64,${iconVocaroo}') center left no-repeat !important;
padding-left: 16px;
}
.fa-twitchtv::before {
content: "";
background: transparent url('data:image/png;base64,${iconTwitch}') center left no-repeat !important;
padding-left: 16px;
}
.fa-twt::before {
content: "";
background: transparent url('data:image/png;base64,${iconTwitter}') center left no-repeat !important;
padding-left: 16px;
}
.fa-x::before {
content: "";
background: transparent url('data:image/png;base64,${iconX}') center left no-repeat !important;
padding-left: 16px;
}
div.post i.fa-music {
margin-right: 6px;
}
div.post i.fa-film {
top: 1px !important;
}
span.spoiler:hover span.embed-button {
color: inherit !important;
}
span.spoiler span.embed-button {
color: inherit !important;
}
`;
if (showSpoilerText) {
css += `
span.spoiler {
color: white !important;
}
`;
} else {
css += `
span.spoiler i.fa {
visibility: hidden;
}
span.spoiler:hover i.fa {
visibility: visible;
}
`;
}
const holiday = checkDate();
if (holiday === 'christmas') {
if (persistentEffect) {
snowEffect();
}
css += `
.twt-embed a:has(> .twt-avatar)::before {
content: "";
position: absolute;
transform: translate(-16%, 9%);
width: 54px;
height: 64px;
pointer-events: none;
z-index: 2;
background: url("https://files.fatbox.moe/4nt2ne.png") no-repeat center/contain;
}
`;
if (persistentDecor) {
css += `
.file a:has(> .post-image) {
position: relative;
}
.file a:has(> .post-image)::before {
content: "";
position: absolute;
width: 138px;
height: 163px;
margin-top: -5px;
margin-left: -138px;
background: url("https://files.fatbox.moe/4nt2ne.png") no-repeat center/contain;
pointer-events: none;
z-index: 2;
}
`;
}
}
style.textContent = css;
document.head.appendChild(style);
})();
function findOverallParentPost(container) {
let topContainer = container;
while (topContainer.parentElement?.classList?.contains('threadingContainer')) {
topContainer = topContainer.parentElement;
}
let node = topContainer.previousSibling;
while (node) {
if (node.nodeType === 1 && node.tagName === 'BR') {
let prev = node.previousSibling;
while (prev) {
if (
prev.nodeType === 1 &&
prev.tagName === 'DIV' &&
prev.classList.contains('post') &&
prev.classList.contains('reply')
) {
return prev;
}
prev = prev.previousSibling;
}
}
node = node.previousSibling;
}
return null;
}
function getThreadTargetContainer(post) {
const body = post.querySelector('.body');
if (!body) return null;
const postId = post.id.split('_')[1];
threadedPostIds.add(postId);
const links = body.querySelectorAll('a[href*="/res/"]');
const parentPosts = new Map();
const containerByPostId = new Map();
for (let i = 0; i < links.length; i++) {
const link = links[i];
const href = link.getAttribute('href');
const match = href?.match(/\/res\/(\d+)\.html#(\d+)/);
if (!match) continue;
const threadId = match[1];
const targetPostId = match[2];
if (threadId !== currentThreadId) continue;
const targetPost = document.getElementById(`reply_${targetPostId}`);
if (!targetPost) continue;
let sibling = targetPost.nextSibling;
while (sibling && !(sibling.nodeType === 1 && sibling.tagName === 'BR')) {
sibling = sibling.nextSibling;
}
if (!sibling) continue;
let container = sibling.nextSibling;
if (!(container instanceof HTMLElement) || !container.classList.contains('threadingContainer')) {
container = document.createElement('div');
container.className = 'reply threadingContainer';
const threadWrapper = targetPost.closest('.thread');
if (threadWrapper) {
const threadId = threadWrapper.getAttribute('id');
const board = threadWrapper.getAttribute('data-board');
if (threadId) container.id = threadId;
if (board) container.setAttribute('data-board', board);
}
sibling.parentNode.insertBefore(container, sibling.nextSibling);
}
const parentPost = findOverallParentPost(container);
if (parentPost) {
parentPosts.set(targetPostId, parentPost);
containerByPostId.set(targetPostId, container);
}
}
if (parentPosts.size === 0) return null;
const uniqueParents = new Set();
const parentValues = Array.from(parentPosts.values());
for (let i = 0; i < parentValues.length; i++) {
uniqueParents.add(parentValues[i]);
}
if (uniqueParents.size > 1) return null;
let highestId = 0;
const keys = Array.from(containerByPostId.keys());
for (let i = 0; i < keys.length; i++) {
const num = parseInt(keys[i], 10);
if (num > highestId) highestId = num;
}
return containerByPostId.get(String(highestId));
}
function threadPosts(post) {
const container = getThreadTargetContainer(post);
if (!container) return;
const postId = post.id.split('_')[1];
const wasLastPost = postId === currentLastPostId;
const prev = post.previousElementSibling;
const isHideButton = prev && (prev.classList.contains('hide-button') || prev.classList.contains('show-button'));
const next = post.nextSibling;
const hasBr = next && next.nodeType === 1 && next.tagName === 'BR';
if (isHideButton) container.appendChild(prev);
container.appendChild(post);
if (hasBr) container.appendChild(next);
if (wasLastPost && showUnreadLine) {
removeUnreadLine();
}
}
function checkThreadable(post) {
const container = getThreadTargetContainer(post);
if (!container) return;
showThreadButton();
}
function showThreadButton() {
if (threadButton) return;
const container = document.querySelector('#thread-links');
if (!container) return;
threadButton = document.createElement('a');
threadButton.href = 'javascript:void(0)';
threadButton.className = 'threading-new';
threadButton.textContent = '[Thread New Posts]';
threadButton.onclick = () => {
const posts = document.querySelectorAll('div.post.reply');
let maxThreaded = lastThreadedId;
for (let i = 0; i < posts.length; i++) {
const post = posts[i];
const postId = post.id.split('_')[1];
const postNum = parseInt(postId, 10);
if (postNum > lastThreadedId) {
threadPosts(post);
if (postNum > maxThreaded) maxThreaded = postNum;
}
}
lastThreadedId = maxThreaded;
threadButton.remove();
threadButton = null;
};
container.appendChild(threadButton);
}
function initializeThreadingToggle() {
if (!optionThreading) return;
waitForElement("#watch-thread", (expandImages) => {
const createToggle = (container, floatDir) => {
const label = document.createElement('label');
label.style.cssText = `float: ${floatDir};`;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.style.verticalAlign = 'middle';
checkbox.checked = enableThreading;
const linkText = document.createElement('a');
linkText.href = 'javascript:void(0)';
linkText.className = 'threading-toggle';
linkText.textContent = '[Threading]';
linkText.onclick = () => {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change'));
};
label.appendChild(checkbox);
label.appendChild(linkText);
container.appendChild(label);
return checkbox;
};
const bottomContainer = document.querySelector('#thread-interactions');
if (!bottomContainer) return;
const topWrapper = document.createElement('div');
expandImages.parentNode.insertBefore(topWrapper, expandImages);
const bottomCheckbox = createToggle(bottomContainer, 'right');
const topCheckbox = createToggle(topWrapper, 'left');
const onToggle = (checked) => {
const latest = JSON.parse(localStorage.getItem("Thread Settings") || '{}');
toggleThread = checked;
latest.enableThreading = checked;
localStorage.setItem('Thread Settings', JSON.stringify(latest));
bottomCheckbox.checked = checked;
topCheckbox.checked = checked;
const posts = document.querySelectorAll('div.post.reply');
if (checked) {
for (let i = 0; i < posts.length; i++) {
threadPosts(posts[i]);
}
} else {
unthreadPosts(posts);
}
};
bottomCheckbox.addEventListener('change', () => onToggle(bottomCheckbox.checked));
topCheckbox.addEventListener('change', () => onToggle(topCheckbox.checked));
});
}
function unthreadPosts(posts) {
const threadEl = document.getElementById('thread_' + currentThreadId);
if (!threadEl) return;
const opPost = threadEl.querySelector('.post.op');
if (!opPost) return;
const sortedPosts = Array.from(posts)
.filter(post => {
const postThread = post.closest('.thread');
return postThread && postThread.id === 'thread_' + currentThreadId;
})
.map(post => ({
id: parseInt(post.id.split('_')[1], 10),
element: document.getElementById(post.id)
}))
.filter(p => p.element)
.sort((a, b) => a.id - b.id);
let insertAfter = opPost;
for (let i = 0; i < sortedPosts.length; i++) {
const postEl = sortedPosts[i].element;
const br = (postEl.nextSibling && postEl.nextSibling.tagName === 'BR') ? postEl.nextSibling : null;
const hideBtn = postEl.previousElementSibling;
const isHideButton = hideBtn && (hideBtn.classList.contains('hide-button') || hideBtn.classList.contains('show-button'));
if (isHideButton) {
threadEl.insertBefore(hideBtn, insertAfter.nextSibling);
insertAfter = hideBtn;
}
threadEl.insertBefore(postEl, insertAfter.nextSibling);
insertAfter = postEl;
if (br) {
threadEl.insertBefore(br, insertAfter.nextSibling);
insertAfter = br;
}
}
if (threadButton) {
threadButton.remove();
threadButton = null;
}
const containers = document.querySelectorAll('.threadingContainer');
for (let i = 0; i < containers.length; i++) {
const container = containers[i];
if (!container.querySelector('.post')) {
container.remove();
}
}
if (showUnreadLine) {
removeUnreadLine();
}
}
function getYouTubeThumbnail(url) {
const id = url.match(
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/live\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/
);
return id ? `https://img.youtube.com/vi/${id[1]}/0.jpg` : null;
}
function getStreamableThumbnail(url) {
const id = url.match(/streamable\.com(?:\/e)?\/([a-zA-Z0-9]+)/);
return id ? `https://thumbs-east.streamable.com/image/${id[1]}.jpg?width=480` : null;
}
function waitForDimensions(media, callback) {
if (media.videoWidth || media.naturalWidth) {
callback();
return;
}
const check = () => {
if (media.videoWidth || media.naturalWidth) {
callback();
} else {
requestAnimationFrame(check);
}
};
requestAnimationFrame(check);
}
function handleLinkHover(e, allowImages = true) {
const link = e.currentTarget;
const href = link.href;
let src = null;
let isVideo = false;
if (allowImages && /\.(jpg|jpeg|png|gif|webp|jfif|bmp|avif|jxl)$/i.test(href)) {
src = href;
} else if (videoHover && /\.(mp4|webm)$/i.test(href)) {
src = href;
isVideo = true;
} else if (/youtu(?:\.be|be\.com)/.test(href)) {
const playlistMatch = href.match(/youtube\.com\/playlist\?list=([a-zA-Z0-9_-]+)/);
if (playlistMatch) {
if (link.dataset.videoId) {
src = `https://img.youtube.com/vi/${link.dataset.videoId}/0.jpg`;
} else {
const apiUrl = `https://www.youtube.com/oembed?url=https%3A//www.youtube.com/playlist%3Flist%3D${playlistMatch[1]}&format=json`;
fetch(apiUrl)
.then(r => r.json())
.then(data => {
if (data.thumbnail_url) {
const vidMatch = data.thumbnail_url.match(/\/vi\/([a-zA-Z0-9_-]{11})\//);
if (vidMatch) {
link.dataset.videoId = vidMatch[1];
const thumb = `https://img.youtube.com/vi/${vidMatch[1]}/0.jpg`;
link.dispatchEvent(new MouseEvent('mouseenter'));
}
}
})
.catch(() => {});
return;
}
} else {
src = getYouTubeThumbnail(href);
}
} else if (/streamable\.com/.test(href)) {
src = getStreamableThumbnail(href);
} else if (/^https?:\/\/pbs\.twimg\.com\/media\//.test(href)) {
src = href;
}
if (!src) return;
let media;
if (isVideo) {
media = document.createElement('video');
media.src = src;
media.loop = true;
media.autoplay = true;
media.id = 'chx_hoverVideo';
media.style.maxWidth = '100vw';
media.style.maxHeight = '100vh';
media.style.position = 'absolute';
media.style.zIndex = '101';
media.style.pointerEvents = 'none';
const savedTime = parseFloat(link.dataset.hoverTime);
if (!isNaN(savedTime) && savedTime > 0) {
media.addEventListener('loadedmetadata', () => {
media.currentTime = savedTime;
}, { once: true });
}
const savedVolume = parseFloat(localStorage.getItem('videovolume'));
media.volume = !isNaN(savedVolume) ? Math.min(Math.max(savedVolume, 0), 1) : 0.5;
media.addEventListener('volumechange', () => {
localStorage.setItem('videovolume', media.volume);
});
} else {
media = document.createElement('img');
media.src = src;
media.id = 'chx_hoverImage';
media.style.position = 'absolute';
media.style.zIndex = '101';
media.style.pointerEvents = 'none';
media.style.maxWidth = '100vw';
media.style.maxHeight = '100vh';
}
document.body.appendChild(media);
const updatePos = (ev) => {
const marginBottom = 15;
const cursorOffsetX = 20;
const cursorOffsetY = 20;
const vw = window.innerWidth;
const vh = window.innerHeight;
const w = media.videoWidth || media.naturalWidth;
const h = media.videoHeight || media.naturalHeight;
const scale = Math.min(1, (vw - 30) / w, (vh - marginBottom) / h);
const displayWidth = w * scale;
const displayHeight = h * scale;
media.style.width = displayWidth + 'px';
media.style.height = displayHeight + 'px';
let left = ev.pageX + cursorOffsetX;
let top = ev.pageY + cursorOffsetY;
left = Math.min(left, vw - displayWidth - cursorOffsetX);
left = Math.max(0, left);
top = Math.min(top, window.scrollY + vh - displayHeight - marginBottom);
top = Math.max(0, top);
media.style.left = left + 'px';
media.style.top = top + 'px';
};
waitForDimensions(media, () => {
updatePos(e);
});
document.addEventListener('mousemove', updatePos);
link.hoverMove = updatePos;
link.hoverMedia = media;
if (isVideo && videoScrollVol) {
const onWheel = (ev) => {
ev.preventDefault();
const delta = ev.deltaY < 0 ? 0.05 : -0.05;
media.volume = Math.min(1, Math.max(0, media.volume + delta));
localStorage.setItem('videovolume', media.volume);
};
document.addEventListener('wheel', onWheel, { passive: false });
link.hoverWheel = onWheel;
}
}
function handleLinkOut(e) {
const link = e.currentTarget;
const media = link.hoverMedia;
if (media && media.tagName === 'VIDEO') {
const time = media.currentTime;
if (time > 0.1) {
link.dataset.hoverTime = time;
}
}
if (media) media.remove();
if (link.hoverMove) {
document.removeEventListener('mousemove', link.hoverMove);
delete link.hoverMove;
}
if (link.hoverWheel) {
document.removeEventListener('wheel', link.hoverWheel);
delete link.hoverWheel;
}
delete link.hoverMedia;
}
function bindLinkPreviews(context, allowImages = true) {
const links = context.querySelectorAll('.post.reply .body a, .post.op .body a');
for (let i = 0; i < links.length; i++) {
const link = links[i];
if (link.hoverBound) continue;
link.addEventListener('mouseenter', (e) => handleLinkHover(e, allowImages));
link.addEventListener('mouseleave', handleLinkOut);
link.hoverBound = true;
}
}
const fetchTasks = [];
let fetchProcessing = false;
async function fetchQueue(limit = 5) {
if (fetchProcessing) return;
fetchProcessing = true;
async function worker() {
while (fetchTasks.length > 0) {
const task = fetchTasks.shift();
try {
await task();
} catch (e) {
}
}
}
const workers = Array.from({ length: limit }, worker);
await Promise.all(workers);
fetchProcessing = false;
}
const thumbCanvas = document.createElement('canvas');
const thumbCtx = thumbCanvas.getContext('2d', { willReadFrequently: true });
let canvasMutex = Promise.resolve();
async function withCanvasLock(callback) {
const prior = canvasMutex;
let release;
canvasMutex = new Promise(resolve => { release = resolve; });
await prior;
try {
return await callback();
} finally {
release();
}
}
async function checkThumb(imgEl, skip = 4) {
if (!imgEl.complete) {
await new Promise(res => {
imgEl.addEventListener('load', res, { once: true });
imgEl.addEventListener('error', res, { once: true });
});
} else if (typeof imgEl.decode === 'function') {
try { await imgEl.decode(); } catch (e) {}
}
return withCanvasLock(() => {
try {
const w = imgEl.naturalWidth || imgEl.width;
const h = imgEl.naturalHeight || imgEl.height;
if (!w || !h) return true;
thumbCanvas.width = w;
thumbCanvas.height = h;
thumbCtx.clearRect(0, 0, w, h);
thumbCtx.drawImage(imgEl, 0, 0, w, h);
const data = thumbCtx.getImageData(0, 0, w, h).data;
for (let i = 0; i < data.length; i += 4 * skip) {
if (data[i] === 0 && data[i + 1] === 0 && data[i + 2] === 0) {
return true;
}
}
return false;
} catch {
return true;
}
});
}
function thumbSwap(post, queue = false) {
if (!thumbnailSwap) return;
const fileLink = post.querySelector('.file a[target="_blank"]');
if (!fileLink) return;
const href = fileLink.getAttribute('href');
if (!href) return;
const isPng = href.endsWith('.png');
const isGif = href.endsWith('.gif');
const isWebp = href.endsWith('.webp');
if (!isPng && !isGif && !isWebp) return;
const img = fileLink.querySelector('img.post-image');
if (!img || img.src.includes('/static/spoiler')) return;
function getThumbContainer() {
let container = document.getElementById('hidden-thumbs');
if (!container) {
container = document.createElement('div');
container.id = 'hidden-thumbs';
container.style.display = 'none';
document.body.appendChild(container);
}
return container;
}
function preserveThumbnail(imgEl) {
const src = imgEl.getAttribute('src');
if (!src) return;
const container = getThumbContainer();
if (!container.querySelector(`img[data-thumb="${src}"]`)) {
const hidden = document.createElement('img');
hidden.src = src;
hidden.setAttribute('data-thumb', src);
container.appendChild(hidden);
}
}
const processThumb = async () => {
try {
const thumbHasAlpha = await checkThumb(img, 4);
if (!thumbHasAlpha) return;
if (isPng || isWebp) {
preserveThumbnail(img);
img.src = href;
return;
}
if (isGif) {
try {
const resp = await fetch(href);
const blob = await resp.blob();
const bitmap = await createImageBitmap(blob, { imageOrientation: 'none' });
try {
const cv = document.createElement('canvas');
const w = img.naturalWidth || img.width || bitmap.width;
const h = img.naturalHeight || img.height || bitmap.height;
cv.width = w;
cv.height = h;
const cctx = cv.getContext('2d');
cctx.drawImage(bitmap, 0, 0, w, h);
preserveThumbnail(img);
img.src = cv.toDataURL('image/png');
} finally {
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
}
} catch (e) {}
}
} catch (e) {}
};
function enqueueOrRun(task) {
if (queue) {
if (img.complete) {
fetchTasks.push(task);
if (!fetchProcessing) fetchQueue(5);
} else {
img.addEventListener('load', () => {
fetchTasks.push(task);
if (!fetchProcessing) fetchQueue(5);
}, { once: true });
}
} else {
task();
}
}
enqueueOrRun(processThumb);
}
function handleSpoilerMedia(post) {
if (!showSpoilerMedia) return;
const unimportant = post.querySelector('.fileinfo .unimportant');
if (!unimportant || !unimportant.textContent.includes('Spoiler Image')) return;
const fileLink = post.querySelector('.file a[target="_blank"]');
if (!fileLink) return;
const spoilerImg = fileLink.querySelector('img.post-image');
if (!spoilerImg) return;
spoilerImg.removeAttribute('style');
spoilerImg.style.maxWidth = '125px';
spoilerImg.style.maxHeight = '125px';
const href = fileLink.getAttribute('href');
if (!href) return;
const isGif = href.endsWith('.gif');
const isVideo = href.includes('/player.php?v=');
if (isGif) {
fetch(href)
.then(resp => resp.blob())
.then(async blob => {
try {
const bitmap = await createImageBitmap(blob, { imageOrientation: 'none' });
const cv = document.createElement('canvas');
cv.width = bitmap.width;
cv.height = bitmap.height;
const ctx = cv.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
spoilerImg.src = cv.toDataURL('image/png');
if (typeof bitmap.close === 'function') bitmap.close();
} catch (e) {}
})
.catch(() => {});
return;
}
if (isVideo) {
const match = href.match(/\/player\.php\?v=([^&]+)/);
if (match && match[1]) {
let videoPath = decodeURIComponent(match[1]);
const thumbPath = videoPath.replace('/src/', '/thumb/').replace(/\.\w+$/, '.jpg');
spoilerImg.src = thumbPath;
}
return;
}
spoilerImg.src = href;
}
const TWEET_LINK_RE = /^https?:\/\/(?:\w+\.)?(?:twitter|x)\.com\/([^\/]+)\/status\/(\d+)/i;
const API_PREFIX = 'https://api.fxtwitter.com/';
const YT_LINK_RE = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/live\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/;
const YT_PLAYLIST_RE = /youtube\.com\/playlist\?list=([a-zA-Z0-9_-]+)/i;
const STREAMABLE_LINK_RE = /streamable\.com(?:\/e)?\/([a-zA-Z0-9]+)/i;
const VOCAROO_LINK_RE = /(?:vocaroo\.com|voca\.ro)\/([a-zA-Z0-9]+)/i;
const TWITCH_LINK_RE = /(?:www\.)?twitch\.tv\/(?:videos\/(\d+)(?:\?t(?:ime)?=([\dhms]+))?|([a-zA-Z0-9_]+))(?:$|[?#])/i;
const TWITCH_CLIP_RE = /(?:www\.)?twitch\.tv\/(?:[a-zA-Z0-9_]+\/clip\/|clips\/)([A-Za-z0-9\-]+)/i;
const IMAGE_RE = /\.(?:jpg|jpeg|png|gif|webp|jfif|bmp|avif|jxl)(?:[?#].*)?$/i;
const MEDIA_FILE_RE = /\.(?:ogg|mp3|wav|webm|mp4)(?:[?#].*)?$/i;
const TWIMG_RE = /^https?:\/\/pbs\.twimg\.com\/media\/[^\s]+/i;
const PIXIV_LINK_RE = /https?:\/\/(?:www\.)?pixiv\.net\/(?:en\/)?artworks\/(\d+)/;
const LINKIFY_REGEX = /(https?:\/\/[^\s]+)|([@#][^\s]+)/g;
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const translateFrom = settings.translateFrom || "ja, id";
const isMobileTheme = document.documentElement.matches(
'html.mobile-style:not(.desktop-floating-mode), html.mobile-style-new:not(.desktop-floating-mode)'
);
const gmRequest = (typeof GM !== 'undefined' && GM?.xmlHttpRequest) || (typeof GM_xmlhttpRequest !== 'undefined' && GM_xmlhttpRequest);
function makeEmbedButton(originalUrl) {
const wrapper = document.createElement('span');
wrapper.className = 'embed-container';
const btn = document.createElement('span');
btn.className = 'embed-button';
btn.style.fontWeight = 'bold';
btn.style.textDecoration = 'underline';
btn.style.color = 'rgb(221, 0, 0)';
btn.style.cursor = 'pointer';
btn.setAttribute('data-embedurl', originalUrl);
btn.textContent = 'Embed';
wrapper.appendChild(document.createTextNode(' ['));
wrapper.appendChild(btn);
wrapper.appendChild(document.createTextNode('] '));
return wrapper;
}
function revokeObjectURLs(node) {
if (!node) return;
const imgs = node.querySelectorAll('img[data-objurl]');
for (let i = 0; i < imgs.length; i++) {
const u = imgs[i].dataset.objurl;
if (u) {
try { URL.revokeObjectURL(u); } catch {}
delete imgs[i].dataset.objurl;
}
}
const vids = node.querySelectorAll('video[data-objurl]');
for (let i = 0; i < vids.length; i++) {
const u = vids[i].dataset.objurl;
if (u) {
try { URL.revokeObjectURL(u); } catch {}
delete vids[i].dataset.objurl;
}
try { vids[i].removeAttribute('src'); vids[i].load(); } catch {}
}
}
function removeEmbedThread(btn) {
const container = btn.closest('.embed-container') || btn;
if (!container) return;
let next = container.nextSibling;
while (next && next.classList && (next.classList.contains('twt-embed') || next.classList.contains('media-embed'))) {
const toRemove = next;
next = next.nextSibling;
revokeObjectURLs(toRemove);
toRemove.remove();
}
btn.classList.remove('embed-active');
if (btn.tagName.toLowerCase() !== 'a') {
btn.textContent = 'Embed';
}
}
function formatTimestamp(unix) {
if (!unix) return '';
const date = new Date(unix * 1000);
const yy = String(date.getFullYear()).slice(-2);
const mm = String(date.getMonth() + 1).padStart(2,'0');
const dd = String(date.getDate()).padStart(2,'0');
const day = DAYS[date.getDay()];
const hh = String(date.getHours()).padStart(2,'0');
const min = String(date.getMinutes()).padStart(2,'0');
const ss = String(date.getSeconds()).padStart(2,'0');
return `${mm}/${dd}/${yy} (${day}) ${hh}:${min}:${ss}`;
}
function linkifyText(inputText) {
const fragment = document.createDocumentFragment();
if (!inputText) return fragment;
let lastIndex = 0;
let match;
LINKIFY_REGEX.lastIndex = 0;
while ((match = LINKIFY_REGEX.exec(inputText)) !== null) {
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(inputText.slice(lastIndex, match.index)));
}
const a = document.createElement('a');
a.target = '_blank';
a.referrerPolicy = 'no-referrer';
a.style.textDecoration = 'none';
a.style.color = 'inherit';
if (match[1]) {
a.href = match[1];
a.textContent = match[1];
} else {
const tag = match[2];
if (tag.startsWith('#')) a.href = `https://x.com/hashtag/${encodeURIComponent(tag.slice(1))}`;
else a.href = `https://x.com/${tag.slice(1)}`;
a.textContent = tag;
}
fragment.appendChild(a);
lastIndex = LINKIFY_REGEX.lastIndex;
}
if (lastIndex < inputText.length) {
fragment.appendChild(document.createTextNode(inputText.slice(lastIndex)));
}
return fragment;
}
async function fetchTweetData(apiUrl) {
const path = apiUrl.replace(/^https?:\/\/(?:\w+\.)?api\.fxtwitter\.com\//i, '');
const resp = await fetch(API_PREFIX + path + '/en', { credentials: 'omit' });
if (!resp.ok) throw new Error('API ' + resp.status);
const data = await resp.json();
if (!data || !data.tweet) throw new Error('Invalid API response');
return data.tweet;
}
function makeFailEmbed(replyContext) {
const embed = document.createElement('div');
embed.className = 'twt-embed';
const failText = document.createElement('div');
failText.style.color = 'red';
failText.textContent = 'Embed failed';
embed.appendChild(failText);
return embed;
}
async function fetchGMBlob(url) {
if (!gmRequest) throw new Error('GM.xmlHttpRequest not available');
return new Promise((resolve, reject) => {
gmRequest({
method: 'GET',
url,
responseType: 'blob',
onload: (resp) => {
if (resp.status >= 200 && resp.status < 300 && resp.response) resolve(resp.response);
else reject(new Error('HTTP ' + resp.status));
},
onerror: () => reject(new Error('HTTP network error')),
ontimeout: () => reject(new Error('HTTP request timed out'))
});
});
}
async function mediaGM(el) {
if (!el) return;
const tag = (el.tagName || '').toLowerCase();
if (tag !== 'img' && tag !== 'video') return;
if (el.dataset.gmtry === '1') return;
el.dataset.gmtry = '1';
if (!gmRequest) {
try { el.onerror = null; } catch {}
return;
}
const origVisibility = el.style.visibility || '';
const origSrc = el.getAttribute('src') || '';
const origPoster = tag === 'video' ? el.getAttribute('poster') || '' : '';
const origOnerror = el.onerror;
try { el.onerror = null; } catch {}
el.style.visibility = 'hidden';
try {
const blob = await fetchGMBlob(origSrc || (tag === 'video' && el.currentSrc) || '');
if (!blob) throw new Error('no blob');
const objUrl = URL.createObjectURL(blob);
el.dataset.objurl = objUrl;
if (tag === 'img') {
el.removeAttribute('src');
el.src = objUrl;
await new Promise((res) => {
el.onload = el.onerror = function () { el.onload = el.onerror = null; res(); };
});
el.style.visibility = origVisibility || '';
} else {
try { el.pause(); } catch (e) {}
const sources = el.querySelectorAll('source');
for (let i = 0; i < sources.length; i++) sources[i].remove();
el.removeAttribute('src');
el.src = objUrl;
el.load();
await new Promise((res) => {
el.onloadeddata = el.onerror = function () { el.onloadeddata = el.onerror = null; res(); };
});
el.style.visibility = origVisibility || '';
if (el.paused && !el.controls) el.controls = true;
}
} catch (e) {
el.style.visibility = origVisibility || '';
try { el.onerror = null; } catch {}
} finally {
try {} catch {}
}
}
function renderTweetEmbed(tweet, replyContext) {
const embed = document.createElement('div');
embed.className = 'twt-embed';
const fragment = document.createDocumentFragment();
const headerLink = document.createElement('a');
headerLink.className = 'twt-header';
headerLink.href = tweet.author?.url || '#';
headerLink.title = tweet.author?.description || '';
headerLink.target = '_blank';
headerLink.referrerPolicy = 'no-referrer';
const avatar = document.createElement('img');
avatar.className = 'twt-avatar';
avatar.src = tweet.author?.avatar_url || '';
avatar.onerror = () => mediaGM(avatar);
avatar.alt = '';
headerLink.appendChild(avatar);
const meta = document.createElement('div');
meta.className = 'twt-meta';
meta.innerHTML = `<strong>${escapeHtml(tweet.author?.name || '')}</strong><br><span class="handle">@${escapeHtml(tweet.author?.screen_name || '')}</span>`;
headerLink.appendChild(meta);
fragment.appendChild(headerLink);
const textBox = document.createElement('div');
textBox.className = 'twt-text';
const originalFragment = linkifyText(tweet.text || '');
const translatedFragment = tweet.translation?.text ? linkifyText(tweet.translation.text) : null;
textBox.appendChild(originalFragment.cloneNode(true));
fragment.appendChild(textBox);
const translation = tweet.translation;
function normalizeText(text) {
return (text || '')
.replace(/@\w+/g, '')
.replace(/[\p{P}\p{S}]/gu, '')
.replace(/\s+/g, '')
.toLowerCase()
.trim();
}
const normalizedOriginal = normalizeText(tweet.text);
const normalizedTranslation = normalizeText(translation?.text);
if (
translation &&
translation.text &&
translation.source_lang &&
translation.target_lang &&
translation.source_lang !== translation.target_lang &&
translation.text !== tweet.text &&
/^[a-z]{2}$/i.test(translation.source_lang) &&
normalizedOriginal !== normalizedTranslation
) {
const translateContainer = document.createElement('div');
translateContainer.className = 'twt-translate';
const sourceLangText = document.createElement('span');
sourceLangText.textContent = '';
const translateLink = document.createElement('a');
translateLink.href = 'javascript:void(0)';
translateLink.textContent = 'Translate post';
let isTranslated = false;
let srcLang = translation.source_lang?.toLowerCase();
if (srcLang === 'in') srcLang = 'id';
let srcLangEn = translation.source_lang_en;
if (!srcLangEn) {
if (srcLang === 'ja') {
srcLangEn = 'Japanese';
} else if (srcLang === 'id') {
srcLangEn = 'Indonesian';
}
} else {
if (srcLangEn === 'language_in' || srcLangEn === 'language_id') {
srcLangEn = 'Indonesian';
} else if (srcLangEn === 'language_ja') {
srcLangEn = 'Japanese';
}
}
const sourceLang = srcLangEn || srcLang.toUpperCase();
function applyTranslation(state) {
textBox.innerHTML = '';
if (state) {
textBox.appendChild(translatedFragment.cloneNode(true));
translateLink.textContent = 'Show original';
sourceLangText.textContent = `Translated from ${sourceLang} `;
isTranslated = true;
} else {
textBox.appendChild(originalFragment.cloneNode(true));
translateLink.textContent = 'Translate post';
sourceLangText.textContent = '';
isTranslated = false;
}
}
translateLink.addEventListener('click', () => applyTranslation(!isTranslated));
if (translateAuto && translation.source_lang) {
const fromList = translateFrom.toLowerCase().split(/[\s,]+/).filter(Boolean);
if (fromList.includes('all') || fromList.includes('any') || fromList.includes(srcLang)) {
applyTranslation(true);
}
}
translateContainer.appendChild(sourceLangText);
translateContainer.appendChild(translateLink);
fragment.appendChild(translateContainer);
}
if (tweet.poll) {
const p = tweet.poll;
const pollEl = document.createElement('div');
pollEl.className = 'twt-poll';
const choicesWrap = document.createElement('div');
choicesWrap.className = 'twt-poll-choices';
const choices = Array.isArray(p.choices) ? p.choices : [];
if (!choices.length) return;
let maxPct = -1;
for (let i = 0; i < choices.length; i++) {
const val = Number(choices[i].percentage) || 0;
if (val > maxPct) maxPct = val;
}
for (let i = 0; i < choices.length; i++) {
const ch = choices[i];
const pct = Math.max(0, Math.min(100, Number(ch.percentage) || 0));
const pctText = (Math.abs(pct - Math.round(pct)) >= 0.05) ? pct.toFixed(1) + '%' : Math.round(pct) + '%';
const choiceEl = document.createElement('div');
choiceEl.className = 'twt-poll-choice';
const barBg = document.createElement('div');
barBg.className = 'twt-poll-bar-bg';
const bar = document.createElement('div');
bar.className = 'twt-poll-bar';
bar.style.width = '0%';
barBg.appendChild(bar);
const labelSpan = document.createElement('span');
labelSpan.className = 'twt-poll-label';
labelSpan.textContent = ch.label || '';
barBg.appendChild(labelSpan);
const pctSpan = document.createElement('span');
pctSpan.className = 'twt-poll-pct';
pctSpan.textContent = pctText;
barBg.appendChild(pctSpan);
if (pct === maxPct) {
const linkColor = getComputedStyle(document.querySelector('a')).color;
bar.style.background = `linear-gradient(90deg,
color-mix(in srgb, ${linkColor} 50%, transparent),
color-mix(in srgb, ${linkColor} 40%, transparent)
)`;
labelSpan.style.fontWeight = 'bolder';
pctSpan.style.fontWeight = 'bolder';
}
choiceEl.appendChild(barBg);
choicesWrap.appendChild(choiceEl);
requestAnimationFrame(() => {
setTimeout(() => {
bar.style.width = pct + '%';
}, 10);
});
}
pollEl.appendChild(choicesWrap);
const footer = document.createElement('div');
footer.className = 'twt-poll-footer';
if (typeof p.total_votes === 'number') {
const votesSpan = document.createElement('span');
votesSpan.className = 'twt-poll-total';
votesSpan.textContent = `${p.total_votes} vote${p.total_votes === 1 ? '' : 's'}`;
footer.appendChild(votesSpan);
}
if (p.time_left_en) {
const timeSpan = document.createElement('span');
timeSpan.className = 'twt-poll-timeleft';
timeSpan.textContent = p.time_left_en;
footer.appendChild(timeSpan);
}
pollEl.appendChild(footer);
fragment.appendChild(pollEl);
}
const media = tweet.media || {};
if (media.all?.length) {
const items = media.all;
const photos = [];
const others = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.type === 'photo') photos.push(it);
else others.push(it);
}
if (photos.length) {
const grid = document.createElement('div');
grid.className = 'photos-grid ' + (photos.length === 1 ? 'single' : '');
for (let i = 0; i < photos.length; i++) {
const p = photos[i];
const a = document.createElement('a');
a.className = 'photo-link';
a.href = p.url;
a.target = '_blank';
const img = document.createElement('img');
img.className = 'photo-img';
img.src = p.url;
img.onerror = () => mediaGM(img);
img.dataset.expanded = '0';
a.appendChild(img);
grid.appendChild(a);
}
const allImagesArray = Array.from(grid.querySelectorAll('img.photo-img'));
grid.addEventListener('click', (e) => {
const t = e.target;
const img = t && (t.matches && t.matches('img.photo-img') ? t : t.closest && t.closest('img.photo-img'));
if (!img || !grid.contains(img)) return;
e.preventDefault();
const expanded = img.dataset.expanded === '1';
if (!expanded) {
img.classList.add('expanded');
img.dataset.expanded = '1';
for (let j = 0; j < allImagesArray.length; j++) {
const iel = allImagesArray[j];
if (iel !== img) iel.style.display = 'none';
}
grid.style.gridTemplateColumns = '1fr';
} else {
img.classList.remove('expanded');
img.dataset.expanded = '0';
for (let j = 0; j < allImagesArray.length; j++) {
const iel = allImagesArray[j];
iel.style.removeProperty('display');
}
grid.style.removeProperty('grid-template-columns');
}
});
fragment.appendChild(grid);
}
for (let k = 0; k < others.length; k++) {
const item = others[k];
if ((item.type === 'video' || item.type === 'gif') && item.variants?.length) {
const videoWrapper = document.createElement('div');
videoWrapper.className = 'videos-single';
const video = document.createElement('video');
video.className = 'twt-video';
video.controls = true;
video.loop = item.type === 'gif';
video.autoplay = item.type === 'gif';
let best = null;
const variants = item.variants;
for (let vi = 0; vi < variants.length; vi++) {
const v = variants[vi];
if (v.content_type !== 'video/mp4') continue;
if (!best || (v.bitrate || 0) > (best.bitrate || 0)) best = v;
}
video.src = (best && best.url) || item.url;
video.onerror = () => mediaGM(video);
const savedVolume = parseFloat(localStorage.getItem('videovolume'));
video.volume = !isNaN(savedVolume) ? Math.min(Math.max(savedVolume, 0), 1) : 0.5;
video.addEventListener('volumechange', () => localStorage.setItem('videovolume', video.volume));
if (item.thumbnail_url) video.poster = item.thumbnail_url;
videoWrapper.appendChild(video);
fragment.appendChild(videoWrapper);
}
}
}
if (tweet.quote) {
const quoted = renderTweetEmbed(tweet.quote, replyContext);
quoted.classList.add('twt-quote');
fragment.appendChild(quoted);
}
const footer = document.createElement('div');
footer.className = 'twt-footer';
const formattedTime = formatTimestamp(tweet.created_timestamp);
const timeEl = document.createElement('span');
timeEl.textContent = formattedTime;
footer.appendChild(timeEl);
if (tweet.replies != null) {
const repliesEl = document.createElement('span');
repliesEl.innerHTML = `<i class="fa fa-commenting"></i> ${tweet.replies}`;
footer.appendChild(repliesEl);
}
if (tweet.retweets != null) {
const retweetsEl = document.createElement('span');
retweetsEl.innerHTML = `<i class="fa fa-retweet"></i> ${tweet.retweets}`;
footer.appendChild(retweetsEl);
}
if (tweet.likes != null) {
const likesEl = document.createElement('span');
likesEl.innerHTML = `<i class="fa fa-heart"></i> ${tweet.likes}`;
footer.appendChild(likesEl);
}
fragment.appendChild(footer);
embed.appendChild(fragment);
return embed;
}
async function loadThreadAndInsert(initialApiPath, replyContext, insertionPoint, maxDepth = 5) {
const wrapper = document.createElement('div');
wrapper.className = 'twt-embed twt-threadwrapper';
if (!isMobileTheme) {
const margin = computeEmbedMargin(replyContext);
if (margin) wrapper.style.marginLeft = margin;
}
async function loadReplies(fromTweet, depthLimit) {
const parents = [];
let current = fromTweet;
for (let depth = 0; depth < depthLimit - 1; depth++) {
const parentUser = current.replying_to || current.replying_to_user || current.replying_to_screen_name || null;
const parentId = current.replying_to_status || null;
if (!parentUser || !parentId) break;
const parentApiPath = encodeURIComponent(parentUser) + '/status/' + encodeURIComponent(parentId);
try {
const parentTweet = await fetchTweetData(parentApiPath);
parents.push(parentTweet);
current = parentTweet;
} catch (e) {
parents.push({ __failed: true });
break;
}
}
return { parents, lastParent: current };
}
let initialTweet;
try {
initialTweet = await fetchTweetData(initialApiPath);
} catch (e) {
insertionPoint.parentNode.insertBefore(makeFailEmbed(replyContext), insertionPoint.nextSibling);
return;
}
const { parents, lastParent } = await loadReplies(initialTweet, maxDepth);
const ordered = parents.slice().reverse();
ordered.push(initialTweet);
for (let i = 0; i < ordered.length; i++) {
const item = ordered[i];
const embedEl = item && !item.__failed
? renderTweetEmbed(item, replyContext)
: makeFailEmbed(replyContext);
if (i < ordered.length - 1) embedEl.classList.add('twt-replychain');
wrapper.appendChild(embedEl);
}
const hasMore = !!(lastParent.replying_to || lastParent.replying_to_user || lastParent.replying_to_screen_name);
if (hasMore) {
const loadMore = document.createElement('a');
loadMore.href = 'javascript:void(0)';
loadMore.textContent = 'Load more replies';
loadMore.className = 'twt-loadmore';
loadMore.style.textAlign = 'center';
loadMore.style.marginTop = '6px';
loadMore.style.marginBottom = '-12px';
loadMore.style.cursor = 'pointer';
wrapper.insertBefore(loadMore, wrapper.firstChild);
loadMore.addEventListener('click', async () => {
loadMore.textContent = 'Loading…';
loadMore.style.pointerEvents = 'none';
const { parents: moreParents, lastParent: newLast } = await loadReplies(lastParent, 5);
const newOrdered = moreParents.slice();
for (const parent of newOrdered) {
const el = parent && !parent.__failed
? renderTweetEmbed(parent, replyContext)
: makeFailEmbed(replyContext);
el.classList.add('twt-replychain');
wrapper.insertBefore(el, wrapper.firstChild.nextSibling);
}
Object.assign(lastParent, newLast);
const stillHasMore = !!(newLast.replying_to || newLast.replying_to_user || newLast.replying_to_screen_name);
if (stillHasMore) {
loadMore.textContent = 'Load more replies';
loadMore.style.pointerEvents = '';
} else {
loadMore.remove();
}
});
}
insertionPoint.parentNode.insertBefore(wrapper, insertionPoint.nextSibling);
}
function computeEmbedMargin(container) {
const replyContext = container?.closest?.('.post.reply');
if (!replyContext || isMobileTheme) return '';
const thumb = replyContext.querySelector?.('.files .file .post-image');
if (!thumb) return '';
const w = thumb.style.width;
if (!w) return '131px';
const base = parseFloat(w) || 0;
return (base + 6) + 'px';
}
function makeEmbedWrapper(container) {
const wrap = document.createElement('div');
wrap.className = 'media-embed';
wrap.style.marginTop = '6px';
wrap.style.marginBottom = '6px';
const ml = computeEmbedMargin(container);
if (ml) wrap.style.marginLeft = ml;
return wrap;
}
function createImageEmbed(container, src) {
const wrap = makeEmbedWrapper(container);
const img = document.createElement('img');
img.src = src;
img.alt = '';
img.style.maxWidth = '100%';
img.style.display = 'block';
img.loading = 'lazy';
img.addEventListener('error', () => {
const replyContext = container.closest('.post.reply');
const failEmbed = makeFailEmbed(replyContext);
wrap.replaceWith(failEmbed);
});
wrap.appendChild(img);
container.parentNode.insertBefore(wrap, container.nextSibling);
return wrap;
}
function createMediaEmbed(container, src, isAudio = false) {
const wrap = makeEmbedWrapper(container);
const tag = isAudio ? 'audio' : 'video';
const media = document.createElement(tag);
media.controls = true;
media.preload = 'metadata';
if (!isAudio) {
media.width = 360;
media.height = 270;
media.style.maxWidth = '100%';
media.style.display = 'block';
} else {
media.style.display = 'block';
}
media.src = src;
wrap.appendChild(media);
container.parentNode.insertBefore(wrap, container.nextSibling);
return wrap;
}
async function onEmbedClick(ev) {
ev.preventDefault?.();
const btn = ev.currentTarget;
if (!btn) return;
const url = btn.getAttribute('data-embedurl');
if (!url) return;
const container = btn.closest('.embed-container') || btn;
const nextEmbed = container?.nextElementSibling;
const isLink = btn.tagName.toLowerCase() === 'a';
if (nextEmbed && nextEmbed.classList && (nextEmbed.classList.contains('twt-embed') || nextEmbed.classList.contains('media-embed'))) {
removeEmbedThread(btn);
return;
}
if (btn.dataset.loading === '1') return;
btn.dataset.loading = '1';
btn.classList.add('embed-active');
if (!isLink) btn.textContent = 'Loading…';
try {
const replyContext = btn.closest('.post.reply');
const insertionPoint = btn.closest('.embed-container') || btn;
const tweetMatch = url.match(TWEET_LINK_RE);
if (tweetMatch) {
const user = tweetMatch[1];
const id = tweetMatch[2];
const apiUrl = API_PREFIX + encodeURIComponent(user) + '/status/' + encodeURIComponent(id);
await loadThreadAndInsert(apiUrl, replyContext, insertionPoint, 5);
if (!isLink) btn.textContent = 'Remove';
delete btn.dataset.loading;
return;
}
if (IMAGE_RE.test(url) || TWIMG_RE.test(url)) {
createImageEmbed(container, url);
if (!isLink) btn.textContent = 'Remove';
delete btn.dataset.loading;
return;
}
if (MEDIA_FILE_RE.test(url) || /video\.twimg\.com/i.test(url)) {
const isAudio = /\.(?:mp3|wav|ogg)(?:[?#].*)?$/i.test(url);
createMediaEmbed(container, url, isAudio);
if (!isLink) btn.textContent = 'Remove';
delete btn.dataset.loading;
return;
}
const embedWrapper = makeEmbedWrapper(container);
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.width = 360;
iframe.height = 270;
iframe.frameBorder = 0;
iframe.style.margin = '10px 0 0 0';
iframe.allowFullscreen = true;
if (url.includes('vocaroo.com')) {
iframe.height = 75;
iframe.allowFullscreen = false;
} else if (url.includes('pixiv.net')) {
iframe.width = 400;
iframe.height = 210;
}
embedWrapper.appendChild(iframe);
container.parentNode.insertBefore(embedWrapper, container.nextSibling);
if (!isLink) btn.textContent = 'Remove';
} catch (e) {
const replyContext = btn.closest('.post.reply');
const insertionPoint = container || btn;
insertionPoint.parentNode.insertBefore(makeFailEmbed(replyContext), insertionPoint.nextSibling);
if (!isLink) btn.textContent = 'Remove';
} finally {
delete btn.dataset.loading;
}
}
function escapeHtml(s) {
if (!s) return '';
return String(s).replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, '"');
}
function processLinks(context, queue = false) {
if (!linkEmbed && !linkIcon && !linkTitle) return;
const links = context.querySelectorAll('.post.reply .body a, .post.op .body a');
const oembedCache = new Map();
function bindEmbed(a, embedUrl) {
if (!linkEmbed) return;
if (a.dataset.embedBound) return;
if (directButton) {
a.classList.add('embed-button');
a.dataset.embedurl = embedUrl;
a.addEventListener('click', onEmbedClick);
a.dataset.embedBound = '1';
} else {
const btnWrap = makeEmbedButton(embedUrl);
const btn = btnWrap.querySelector('.embed-button');
if (btn) btn.addEventListener('click', onEmbedClick);
a.parentNode?.insertBefore(btnWrap, a.nextSibling);
a.dataset.embedBound = '1';
}
}
function enqueueOembed(link, apiUrl, label, iconName) {
let task;
if (oembedCache.has(apiUrl)) {
task = () => oembedCache.get(apiUrl).then(data => applyOembed(link, data, label, iconName));
} else {
const promise = fetch(apiUrl)
.then(r => (r.ok ? r.json() : Promise.reject(r.status)))
.catch(() => null);
oembedCache.set(apiUrl, promise);
task = async () => applyOembed(link, await promise, label, iconName);
}
if (queue) {
fetchTasks.push(task);
if (!fetchProcessing) fetchQueue(5);
} else {
task();
}
}
function applyOembed(link, data, label, iconName) {
if (!data || !data.title) return;
const iconHTML = linkIcon ? `<i class="fa fa-${iconName}"></i>` : "";
link.innerHTML = `${iconHTML}${label} ${data.title}`;
link.style.textDecoration = "none";
link.dataset.decorated = '1';
}
function prependIcon(link, faName) {
if (!linkIcon) return;
if (link.dataset.iconAdded) return;
link.dataset.iconAdded = "1";
const icon = document.createElement('i');
icon.className = `fa fa-${faName}`;
link.prepend(icon);
link.dataset.decorated = link.dataset.decorated || '1';
}
for (let i = 0; i < links.length; i++) {
const a = links[i];
if (!a.href || !a.href.startsWith('http')) continue;
const host = a.hostname;
let match;
if ((linkEmbed && a.dataset.embedBound) && ((linkIcon || linkTitle) && a.dataset.decorated)) continue;
const maybeNext = a.nextElementSibling;
if (linkEmbed && maybeNext && maybeNext.classList?.contains('embed-container')) {
const existingBtn = maybeNext.querySelector('.embed-button');
if (existingBtn && (IMAGE_RE.test(a.href) || TWIMG_RE.test(a.href))) {
if (directButton) {
const embedUrl = existingBtn.getAttribute('data-embedurl') || a.href;
a.classList.add('embed-button');
a.setAttribute('data-embedurl', embedUrl);
a.addEventListener('click', onEmbedClick);
a.dataset.embedBound = '1';
maybeNext.remove();
} else {
const newBtnWrap = makeEmbedButton(a.href);
const newBtn = newBtnWrap.querySelector('.embed-button');
if (newBtn && !newBtn.getAttribute('data-embedurl')) newBtn.setAttribute('data-embedurl', a.href);
try {
existingBtn.parentNode.replaceChild(newBtn, existingBtn);
} catch {
a.parentNode?.insertBefore(newBtnWrap, a.nextSibling);
maybeNext.remove();
}
newBtn?.addEventListener('click', onEmbedClick);
a.dataset.embedBound = '1';
newBtn && (newBtn.dataset.embedBound = '1');
}
} else if (existingBtn) {
if (directButton) {
const embedUrl = existingBtn.getAttribute('data-embedurl') || a.href;
a.classList.add('embed-button');
a.dataset.embedurl = embedUrl;
a.addEventListener('click', onEmbedClick);
a.dataset.embedBound = '1';
maybeNext.remove();
} else {
a.dataset.embedBound = '1';
}
}
}
if (linkEmbed && a.classList.contains('embed-button')) {
a.dataset.embedBound = '1';
}
if ((host.includes('youtube.com') || host === 'youtu.be')) {
if ((match = a.href.match(YT_LINK_RE))) {
const videoId = match[1];
let startSeconds = 0;
const tMatch = a.href.match(/[?&#](?:t|start)=([\dhms]+)/i);
if (tMatch) {
const raw = tMatch[1];
if (/^\d+$/.test(raw)) {
startSeconds = +raw;
} else {
const hh = raw.match(/(\d+)h/);
const mm = raw.match(/(\d+)m/);
const ss = raw.match(/(\d+)s/);
startSeconds = (hh ? +hh[1] * 3600 : 0) + (mm ? +mm[1] * 60 : 0) + (ss ? +ss[1] : 0);
}
}
if (linkEmbed && !a.dataset.embedBound) {
let embedUrl = `https://www.youtube-nocookie.com/embed/${videoId}`;
if (startSeconds > 0) embedUrl += `?start=${startSeconds}`;
bindEmbed(a, embedUrl);
}
if (linkIcon || linkTitle) {
if (linkIcon) prependIcon(a, 'youtube-play');
if (linkTitle) {
const timeLabel = startSeconds > 0
? (Math.floor(startSeconds / 3600) > 0
? ` <b>[${Math.floor(startSeconds / 3600)}:${String(Math.floor((startSeconds % 3600) / 60)).padStart(2, "0")}:${String(startSeconds % 60).padStart(2, "0")}]</b>`
: ` <b>[${Math.floor(startSeconds / 60)}:${String(startSeconds % 60).padStart(2, "0")}]</b>`)
: "";
const apiUrl = `https://www.youtube.com/oembed?url=https%3A//www.youtube.com/watch%3Fv%3D${videoId}&format=json`;
enqueueOembed(a, apiUrl, `[YouTube]${timeLabel}`, 'youtube-play');
}
}
if ((linkEmbed && a.dataset.embedBound) || (linkIcon || linkTitle)) {
a.dataset.decorated = a.dataset.decorated || '1';
}
continue;
}
if ((match = a.href.match(YT_PLAYLIST_RE))) {
const playlistId = match[1];
if (linkEmbed && !a.dataset.embedBound) {
bindEmbed(a, `https://www.youtube-nocookie.com/embed/videoseries?list=${playlistId}`);
}
if (linkIcon || linkTitle) {
if (linkIcon) prependIcon(a, 'youtube-play');
if (linkTitle) {
const apiUrl = `https://www.youtube.com/oembed?url=https%3A//www.youtube.com/playlist%3Flist%3D${playlistId}&format=json`;
enqueueOembed(a, apiUrl, `[Playlist]`, 'youtube-play');
}
}
a.dataset.decorated = a.dataset.decorated || '1';
continue;
}
continue;
}
if ((host === 'x.com' || host.includes('twitter.com')) && TWEET_LINK_RE.test(a.href)) {
if (linkEmbed && !a.dataset.embedBound) bindEmbed(a, a.href);
if (linkIcon) prependIcon(a, host === 'x.com' ? 'x' : 'twt');
a.dataset.decorated = a.dataset.decorated || '1';
continue;
}
if (host.includes('streamable.com') && (match = a.href.match(STREAMABLE_LINK_RE))) {
const id = match[1];
if (linkEmbed && !a.dataset.embedBound) bindEmbed(a, `https://streamable.com/e/${id}`);
if (linkIcon || linkTitle) {
if (linkIcon) prependIcon(a, 'streamable');
if (linkTitle) enqueueOembed(a, `https://api.streamable.com/oembed?url=https://streamable.com/${id}`, '[Streamable]', 'streamable');
}
a.dataset.decorated = a.dataset.decorated || '1';
continue;
}
if ((host.includes('vocaroo.com') || host === 'voca.ro') && (match = a.href.match(VOCAROO_LINK_RE))) {
const id = match[1];
if (linkEmbed && !a.dataset.embedBound) bindEmbed(a, `https://vocaroo.com/embed/${id}`);
if (linkIcon) prependIcon(a, 'vocaroo');
a.dataset.decorated = a.dataset.decorated || '1';
continue;
}
if (host.includes('twitch.tv')) {
if ((match = a.href.match(TWITCH_CLIP_RE))) {
const clipId = match[1];
if (linkEmbed && !a.dataset.embedBound) bindEmbed(a, `https://clips.twitch.tv/embed?clip=${clipId}&parent=${location.hostname}`);
if (linkIcon) prependIcon(a, 'twitchtv');
a.dataset.decorated = a.dataset.decorated || '1';
continue;
}
if ((match = a.href.match(TWITCH_LINK_RE))) {
const vodId = match[1];
const vodTime = match[2];
const channel = match[3];
if (linkEmbed && !a.dataset.embedBound) {
let embedUrl;
if (vodId) {
embedUrl = `https://player.twitch.tv/?video=${vodId}`;
if (vodTime) embedUrl += `&time=${vodTime}`;
} else if (channel) {
embedUrl = `https://player.twitch.tv/?channel=${channel}`;
}
embedUrl += `&parent=${location.hostname}`;
bindEmbed(a, embedUrl);
}
if (linkIcon) prependIcon(a, 'twitchtv');
a.dataset.decorated = a.dataset.decorated || '1';
continue;
}
}
if (IMAGE_RE.test(a.href) || TWIMG_RE.test(a.href)) {
if (linkEmbed && !a.dataset.embedBound) bindEmbed(a, a.href);
if (linkIcon) prependIcon(a, 'image');
a.dataset.decorated = a.dataset.decorated || '1';
continue;
}
if (MEDIA_FILE_RE.test(a.href) || host === 'video.twimg.com') {
if (linkIcon) {
const ext = a.href.split('.').pop().split(/[?#]/)[0].toLowerCase();
const isAudio = ext === 'ogg' || ext === 'mp3' || ext === 'wav';
prependIcon(a, isAudio ? 'music' : 'film');
}
if (linkEmbed && !a.dataset.embedBound) bindEmbed(a, a.href);
a.dataset.decorated = a.dataset.decorated || '1';
continue;
}
}
}
function initializePosts() {
const lastPostCountEl = document.getElementById("thread_stats_posts");
if (lastPostCountEl) {
lastPostCount = parseInt(lastPostCountEl.textContent, 10);
}
const opPost = document.querySelector('.post.op');
const opImage = opPost ? opPost.previousElementSibling : null;
if (linkPreview && opPost) {
bindLinkPreviews(opPost);
}
if (thumbnailSwap && opImage) {
thumbSwap(opImage, true);
}
if (showSpoilerMedia && opImage) {
handleSpoilerMedia(opImage);
}
if (linkEmbed || linkIcon || linkTitle) {
processLinks(document, true);
}
const posts = document.querySelectorAll('div.post.reply');
if (posts.length) {
const lastPost = posts[posts.length - 1];
const lastPostId = lastPost.id.split('_')[1];
lastSeenPostId = lastPostId;
lastLine = lastPostId;
lastThreadedId = lastPostId;
currentLastPostId = lastPostId;
}
const hiddenList = settings.hiddenPosts?.[currentBoard]?.[currentThreadId] || [];
hiddenSet.clear();
for (let i = 0; i < hiddenList.length; i++) {
hiddenSet.add(hiddenList[i]);
}
if (appendQuotes || (optionThreading && enableThreading) || hidePosts || linkPreview || thumbnailSwap || showSpoilerMedia) {
for (let i = 0; i < posts.length; i++) {
const post = posts[i];
const postId = post.id.split('_')[1];
if (appendQuotes) {
appendPosts(post);
}
if (optionThreading && enableThreading) {
setTimeout(() => {
threadPosts(post);
}, 0);
}
if (hidePosts) {
addHideButton(post);
if (hiddenList.includes(postId)) {
const btn = document.querySelector(`a.reply[for="reply_${postId}"].hide-button, a.reply[for="reply_${postId}"].show-button`);
if (btn) {
setTimeout(() => {
const alreadyHidden = post.classList.contains('hidden-post');
if (!alreadyHidden) {
toggleHidePost(post, btn, false, null, true);
}
}, 0);
}
}
}
if (linkPreview) {
bindLinkPreviews(post);
}
if (thumbnailSwap) {
thumbSwap(post, true);
}
if (showSpoilerMedia) {
handleSpoilerMedia(post);
}
}
}
if (optionThreading) {
document.addEventListener('click', function (e) {
const img = e.target;
if (!img.classList.contains('full-image')) return;
const anchor = img.closest('a');
if (!anchor) return;
const threadingContainer = anchor.closest('.threadingContainer');
if (!threadingContainer) return;
const thumb = anchor.querySelector('.post-image');
if (!thumb) return;
thumb.style.display = '';
thumb.style.opacity = '';
thumb.style.filter = '';
img.remove();
$(anchor).removeData('expanded');
const post_body = $(thumb).closest('.post.reply');
const still_open = post_body.find('.post-image').filter(function () {
return $(this).parent().data('expanded') === 'true';
}).length;
let padding = 5;
const boardlist = document.querySelector('.boardlist');
if (boardlist && getComputedStyle(boardlist).position === 'fixed') {
padding += boardlist.getBoundingClientRect().height;
}
const headerBar = document.querySelector('#header-bar.dialog');
if (headerBar) {
const classList = headerBar.classList;
const hasOnlyAutohide = classList.length === 2 &&
classList.contains('dialog') &&
classList.contains('autohide');
if (!hasOnlyAutohide) {
const style = getComputedStyle(headerBar);
const hasBottom = parseFloat(style.bottom) < 1;
if (style.position === 'fixed' && !hasBottom) {
padding += headerBar.getBoundingClientRect().height;
}
}
}
if (still_open > 0) {
if (thumb.getBoundingClientRect().top - padding < 0) {
$(document).scrollTop($(thumb).parent().parent().offset().top - padding);
}
} else {
if (post_body[0].getBoundingClientRect().top - padding < 0) {
$(document).scrollTop(post_body.offset().top - padding);
}
}
e.preventDefault();
e.stopImmediatePropagation();
}, true);
}
}
function addUnreadLine() {
if (!showUnreadLine) return;
const lastPost = document.querySelector(`#reply_${lastLine}`);
if (lastPost && !hasUnreadLine) {
lastPost.style.boxShadow = '0 3px red';
hasUnreadLine = true;
}
}
function removeUnreadLine() {
if (!showUnreadLine) return;
const lastPost = document.querySelector(`#reply_${lastLine}`);
if (lastPost && hasUnreadLine) {
lastPost.style.boxShadow = '';
hasUnreadLine = false;
}
const posts = document.querySelectorAll('div.post.reply');
for (let i = posts.length - 1; i >= 0; i--) {
const post = posts[i];
if (
!post.classList.contains('post-hover') &&
!post.classList.contains('hidden-post') &&
!post.classList.contains('deleted-post') &&
!post.classList.contains('inline-cloned-post')
) {
lastLine = post.id.split('_')[1];
break;
}
}
}
function updateThreadStatsActual() {
if (lastPostCount !== null) {
const oldElement = document.getElementById('thread_stats_posts');
if (oldElement) {
oldElement.style.display = 'none';
let newElement = document.getElementById('thread_stats_posts_actual');
if (!newElement) {
newElement = document.createElement('span');
newElement.id = 'thread_stats_posts_actual';
oldElement.parentNode.insertBefore(newElement, oldElement.nextSibling);
}
newElement.textContent = lastPostCount;
const postsDeleted = parseInt(oldElement.textContent) - lastPostCount;
if (showDeletedCounter) {
let deletedElement = document.getElementById('thread_stats_posts_deleted');
if (postsDeleted >= 1) {
if (!deletedElement) {
deletedElement = document.createElement('span');
deletedElement.id = 'thread_stats_posts_deleted';
const imagesElement = document.getElementById('thread_stats_images');
if (imagesElement) {
imagesElement.parentNode.insertBefore(deletedElement, imagesElement);
}
}
deletedElement.textContent = postsDeleted;
deletedElement.insertAdjacentHTML('beforeend', ' deleted | ');
} else if (deletedElement) {
deletedElement.remove();
}
}
if (lastPostCount >= 1500) {
if (enableFaviconChanges && changeFaviconOnArchive) {
updateFaviconArchived();
}
if (showArchivedMessage && !document.getElementById('archived-msg')) {
addArchivedMessage();
}
if (updaterCheckbox?.checked && !updaterDisabled) {
updaterCheckbox.click();
updaterDisabled = true;
}
} else {
if (isThreadArchived || updaterDisabled) {
if (isThreadArchived) {
isThreadArchived = false;
setFavicon(FAVICON_URL);
}
updaterDisabled = false;
}
}
}
}
}
function snowEffect(duration) {
if (document.getElementById('snow-container')) return;
const styleId = 'htsu-snow-style';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
@keyframes fall {
from { transform: translateY(-10vh) translateX(0); }
to { transform: translateY(110vh) translateX(var(--wind-shift)); }
}
@keyframes spin-only {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.snow-flake {
position: absolute;
top: -5vh;
pointer-events: none;
will-change: transform, opacity;
}
.snow-flake > .inner {
display: inline-block;
transform-origin: center;
}
`;
document.head.appendChild(style);
}
const layers = [
{
weight: 0.50, z: 9995, size: 0.6,
fall: [14, 20], blur: [1.0, 1.0], op: [0.45, 0.85], wind: 1.4,
simple: true
},
{
weight: 0.33, z: 9998, size: 1.0,
fall: [11, 16], blur: [0.9, 1.8], op: [0.55, 0.95], wind: 2.2,
simple: false
},
{
weight: 0.17, z: 10001, size: 1.4,
fall: [6, 12], blur: [0.9, 1.7], op: [0.70, 1.0], wind: 2.6,
simple: false
}
];
const glyphs = ['✻', '❆', '❅'];
const rand = (a, b) => a + Math.random() * (b - a);
function chooseLayer() {
let r = Math.random();
for (let i = 0; i < layers.length; i++) {
r -= layers[i].weight;
if (r <= 0) return layers[i];
}
return layers[2];
}
const snowContainer = document.createElement('div');
snowContainer.id = 'snow-container';
Object.assign(snowContainer.style, {
position: 'fixed',
inset: 0,
pointerEvents: 'none',
overflow: 'hidden',
color: '#f5f5f5',
zIndex: 9999
});
document.body.appendChild(snowContainer);
let snowActive = true;
const spawnTimer = setInterval(() => snowActive && spawnFlake(), 150);
if (duration > 0) {
setTimeout(() => {
snowActive = false;
clearInterval(spawnTimer);
const cleaner = setInterval(() => {
if (!snowContainer.hasChildNodes()) {
snowContainer.remove();
clearInterval(cleaner);
}
}, 500);
}, duration);
}
function spawnFlake() {
const layer = chooseLayer();
const fallDur = rand(layer.fall[0], layer.fall[1]);
const size = (rand(8, 26) * layer.size) | 0;
const opacity = rand(layer.op[0], layer.op[1]);
const blur = rand(layer.blur[0], layer.blur[1]);
const wind = (rand(3, 12) * layer.wind) * (Math.random() < 0.5 ? -1 : 1);
const windShift = (wind * fallDur) + 'px';
const flake = document.createElement('div');
flake.className = 'snow-flake';
flake.style.left = Math.random() * 100 + '%';
flake.style.fontSize = size + 'px';
flake.style.opacity = opacity;
flake.style.filter = `blur(${blur}px)`;
flake.style.zIndex = layer.z;
flake.style.animation = `fall ${fallDur}s linear forwards`;
flake.style.setProperty('--wind-shift', windShift);
const inner = document.createElement('span');
inner.className = 'inner';
if (layer.simple) {
inner.textContent = '❆';
inner.style.transform = 'scale(1)';
} else {
inner.textContent = glyphs[Math.floor(Math.random() * 3)];
inner.style.transform = `scale(${rand(0.85, 1.25)})`;
inner.style.animation = `spin-only ${rand(1.6, 3.8)}s linear infinite`;
inner.style.animationDirection = Math.random() < 0.5 ? 'normal' : 'reverse';
}
flake.appendChild(inner);
snowContainer.appendChild(flake);
setTimeout(() => {
flake.style.opacity = '0';
setTimeout(() => flake.remove(), 450);
}, (fallDur + 0.25) * 1000);
}
}
function checkDate() {
const nowLocal = new Date();
const nowJST = new Date(nowLocal.toLocaleString("en-US", { timeZone: "Asia/Tokyo" }));
const isMD = (d, m, day) =>
d.getMonth() === m && d.getDate() === day;
if (isMD(nowJST, 11, 25) || isMD(nowLocal, 11, 25)) {
return "christmas";
}
const isLocalNYE = isMD(nowLocal, 11, 31);
const isLocalNY = isMD(nowLocal, 0, 1);
const isJSTNYE = isMD(nowJST, 11, 31);
const isJSTNY = isMD(nowJST, 0, 1);
if (isLocalNYE || isLocalNY || isJSTNYE || isJSTNY) {
return "newyear";
}
return null;
}
function addArchivedMessage() {
const postControlsForm = document.forms["postcontrols"];
if (!postControlsForm) return;
function scaleLength(value, factor) {
if (typeof value !== 'string') return value;
const match = value.trim().match(/^([\d.]+)([a-z%]+)$/i);
if (!match) return value;
const num = parseFloat(match[1]);
const unit = match[2];
return (num * factor) + unit;
}
const holiday = checkDate();
const isChristmas = holiday === "christmas";
const archivedMsg = document.createElement('div');
archivedMsg.id = 'archived-msg';
archivedMsg.style.marginTop = '-25px';
archivedMsg.style.marginBottom = '20px';
const messageText = settings.archivedMessageText || "THREAD ARCHIVED";
const imageURL = settings.archivedImageURL || "https://i.imgur.com/LQHVLil.png";
const fontSize = settings.archivedMessageFontSize || "14px";
const imageSize = settings.archivedImageSize || "7%";
const useHeight = settings.archivedImageUseHeight;
const christmasURL = "https://files.fatbox.moe/4nt2ne.png";
const christmasSize = scaleLength(imageSize, 1.3);
archivedMsg.innerHTML = `
<strong style="color: red; font-size: ${fontSize};">${messageText}</strong><br>
<img src="${imageURL}" alt="Archived Image" style="margin-top: 5px; ${useHeight ? `height` : `width`}: ${imageSize};">
${isChristmas ? `
<img src="${christmasURL}" alt="" class="archived-christmas" style="position: absolute; ${useHeight ? 'height' : 'width'}: ${christmasSize}; transform: translateY(-5%) translateX(-82%); pointer-events: none;">
` : ''}
`;
postControlsForm.parentNode.insertBefore(archivedMsg, postControlsForm.nextSibling);
if (!holiday) return;
if (holiday === "christmas") {
snowEffect(25000);
} else if (holiday === "newyear") {
// fireworksEffect(25000);
return;
}
const cloned = archivedMsg.cloneNode(true);
cloned.id = 'archived-msg-clone';
Object.assign(cloned.style, {
position: 'fixed',
bottom: '1%',
left: '1%',
zIndex: 10000,
opacity: 0,
transition: 'opacity 0.5s ease-in-out',
pointerEvents: 'none'
});
document.body.appendChild(cloned);
requestAnimationFrame(() => {
cloned.style.opacity = 1;
});
let cloneRemoved = false;
function removeClone() {
if (cloneRemoved) return;
cloneRemoved = true;
cloned.style.opacity = 0;
setTimeout(() => cloned.remove(), 500);
observer.disconnect();
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
removeClone();
}
},
{
threshold: 0,
rootMargin: "20% 0px"
}
);
observer.observe(archivedMsg);
setTimeout(removeClone, 25000);
}
const justPostedIds = new Set();
function threadMonitoring() {
(function(open) {
XMLHttpRequest.prototype.open = function(method, url) {
this._url = url;
if (currentThreadId) {
const target = `/res/${currentThreadId}.html`;
this._monitored = url.includes(target) && url.indexOf(target) + target.length === url.length;
} else {
this._monitored = false;
}
this._isThreadsJson = monitorThreadsJson && url.includes("/threads.json");
return open.apply(this, arguments);
};
})(XMLHttpRequest.prototype.open);
(function(send) {
XMLHttpRequest.prototype.send = function() {
this.addEventListener('load', () => {
if (this._monitored) {
if (this.status === 200) {
const lastModified = this.getResponseHeader("Last-Modified");
if (checkSync(lastModified)) {
syncPostStatus(this.responseText);
}
} else if (this.status === 404) {
if (enableFaviconChanges && changeFaviconOnArchive) {
updateFaviconArchived();
}
if (showArchivedMessage && !document.getElementById('archived-msg')) {
addArchivedMessage();
}
const pageEl = document.getElementById('thread_stats_page');
if (pageEl?.textContent.trim() === '???' && updaterCheckbox?.checked) {
updaterCheckbox.click();
}
} else if (this.status === 403) {
loadChallengeOverlay?.();
}
}
if (this._isThreadsJson && this.status === 200) {
cleanThreadStorage(this.responseText);
}
});
return send.apply(this, arguments);
};
})(XMLHttpRequest.prototype.send);
let lastModifiedPrevious = null;
function checkSync(lastModified) {
if (!lastModified) {
return false;
}
if (lastModified === lastModifiedPrevious) {
return false;
}
lastModifiedPrevious = lastModified;
return true;
}
$(document).on('ajax_after_post', function(e, post_response) {
if (post_response && post_response.id) {
const idStr = String(post_response.id);
justPostedIds.add(idStr);
setTimeout(() => justPostedIds.delete(idStr), 10000);
}
});
$(document).on('new_post', (e, post) => {
const postId = post.id.split('_')[1];
if (appendQuotes && !appendedPostIds.has(postId)) {
appendPosts(post);
}
if (hidePosts) {
addHideButton(post);
const links = post.querySelectorAll('div.body a:not([rel="nofollow"])');
for (let i = 0; i < links.length; i++) {
const link = links[i];
const match = link.textContent.match(/^>>(\d+)/);
if (match) {
const quotedId = match[1];
if (hiddenSet.has(quotedId)) {
link.style.textDecoration = 'underline line-through';
if (recursiveHiding) {
const btn = post.previousElementSibling;
if (btn && (btn.classList.contains('hide-button') || btn.classList.contains('show-button'))) {
toggleHidePost(post, btn, true, quotedId, true);
}
return;
}
}
}
}
}
if (linkPreview) {
bindLinkPreviews(post);
}
if (thumbnailSwap) {
thumbSwap(post);
}
if (linkEmbed || linkIcon || linkTitle) {
processLinks(post);
}
if (showSpoilerMedia) {
handleSpoilerMedia(post);
}
});
}
let sitekey = null;
function extractSitekey(callback) {
const script = document.createElement('script');
script.textContent = `
(() => {
let key = null;
if (typeof window.loadChallenge === "function") {
const match = window.loadChallenge.toString().match(/sitekey\\s*=\\s*['"]([^'"]+)['"]/);
key = match ? match[1] : null;
}
window.dispatchEvent(new CustomEvent("cf-sitekey", { detail: key }));
})();
`;
document.documentElement.appendChild(script);
script.remove();
window.addEventListener("cf-sitekey", (e) => {
callback(e.detail);
}, { once: true });
}
function initializeSiteKey() {
if (sitekey) return;
extractSitekey((result) => {
if (result) {
sitekey = result;
}
});
}
function loadChallengeOverlay() {
if (!sitekey) {
initializeSiteKey();
}
if (!sitekey) {
return;
}
if (document.getElementById('cf-challenge-overlay')) return;
const dimmer = document.createElement('div');
dimmer.id = 'cf-challenge-overlay';
dimmer.style.position = 'fixed';
dimmer.style.top = '0';
dimmer.style.left = '0';
dimmer.style.width = '100vw';
dimmer.style.height = '100vh';
dimmer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
dimmer.style.zIndex = '9999';
dimmer.style.display = 'flex';
dimmer.style.alignItems = 'center';
dimmer.style.justifyContent = 'center';
const box = document.createElement('div');
const siteBg = getComputedStyle(document.body).background || '#fff';
box.style.background = siteBg;
box.style.position = 'relative';
box.style.padding = '20px';
box.style.borderRadius = '10px';
box.style.boxShadow = '0 0 20px rgba(0, 0, 0, 0.5)';
box.style.textAlign = 'center';
const closeBtn = document.createElement('a');
closeBtn.id = 'alert_close';
closeBtn.href = 'javascript:void(0)';
closeBtn.style.display = 'none';
closeBtn.innerHTML = '<i class="fa fa-times"></i>';
closeBtn.addEventListener('click', () => {
dimmer.remove();
});
const challengeDiv = document.createElement('div');
challengeDiv.id = 'cf-turnstile';
box.appendChild(closeBtn);
box.appendChild(challengeDiv);
dimmer.appendChild(box);
document.body.appendChild(dimmer);
const fallbackTimer = setTimeout(() => {
closeBtn.style.display = 'inline-block';
}, 10000);
turnstile.render('#cf-turnstile', {
sitekey,
callback: () => {
clearTimeout(fallbackTimer);
dimmer.remove();
},
'error-callback': () => {
clearTimeout(fallbackTimer);
closeBtn.style.display = 'inline-block';
},
});
}
function syncPostStatus(responseText) {
if (!currentThreadId) return;
const parser = new DOMParser();
const doc = parser.parseFromString(responseText, "text/html");
const currentPosts = document.querySelectorAll('div.post.reply');
const updatePosts = doc.querySelectorAll('div.post.reply');
const postCount = updatePosts.length;
const lastPost = currentPosts[currentPosts.length - 1];
if (lastPost) {
currentLastPostId = lastPost.id.split('_')[1];
}
if (postCount > 0) {
const postDrop = lastPostCount - postCount;
isLargeDrop = lastPostCount > 200 ? postDrop >= 100 : (postDrop / lastPostCount) >= 0.3;
if (isLargeDrop && lastPostCount >= 10) {
lowPostWarningCount++;
if (lowPostWarningCount >= 2) {
lastPostCount = postCount;
updateThreadStatsActual();
}
} else {
lowPostWarningCount = 0;
lastPostCount = postCount;
updateThreadStatsActual();
}
}
if (postCount === 0) return;
const updatePostIds = new Set();
let seenRed = false;
let seenWhite = false;
let ownPostIds = new Set();
if (enableFaviconChanges && notifyNewYou && alertState !== 'red') {
try {
const board = document.querySelector('input[name="board"]')?.value;
const ownPosts = JSON.parse(localStorage.own_posts || '{}');
ownPostIds = board && ownPosts[board] ? new Set(ownPosts[board]) : new Set();
} catch (e) {
}
}
let lastInsertedNode = null;
for (let i = 0; i < updatePosts.length; i++) {
const post = updatePosts[i];
const postId = post.id.split('_')[1];
updatePostIds.add(postId);
if (Number(postId) > Number(lastSeenPostId)) {
lastSeenPostId = postId;
if (!hasUnreadLine) addUnreadLine();
if (enableFaviconChanges) {
if (notifyNewYou && alertState !== 'red') {
const bodyLinks = post.querySelectorAll('div.body a:not([rel="nofollow"])');
for (let j = 0; j < bodyLinks.length; j++) {
const match = bodyLinks[j].textContent.match(/^>>(\d+)$/);
if (match && ownPostIds.has(match[1])) {
seenRed = true;
break;
}
}
}
if (!seenRed && notifyNewPost) {
seenWhite = true;
}
}
}
let currentPost = null;
const updateBans = post.querySelector('span.public_ban');
if (updateBans) {
currentPost = currentPost || document.getElementById('reply_' + postId);
if (currentPost && !currentPost.querySelector('span.public_ban')) {
const currentBody = currentPost.querySelector('div.body');
if (currentBody) {
currentBody.appendChild(updateBans.cloneNode(true));
}
}
}
if (optionThreading && toggleThread && !threadedPostIds.has(postId)) {
const currentPost = document.getElementById('reply_' + postId);
if (currentPost) {
const container = currentPost.closest('.threadingContainer');
if (container) {
let topContainer = container;
while (topContainer.parentElement?.classList?.contains('threadingContainer')) {
topContainer = topContainer.parentElement;
}
const parent = topContainer.parentNode;
const prev = currentPost.previousElementSibling;
const isHideButton = prev && (prev.classList.contains('hide-button') || prev.classList.contains('show-button'));
const br = currentPost.nextSibling;
const hasBr = br && br.nodeType === 1 && br.tagName === 'BR';
const insertAfter = lastInsertedNode || topContainer;
let insertPoint = insertAfter.nextSibling;
if (isHideButton) {
parent.insertBefore(prev, insertPoint);
lastInsertedNode = prev;
insertPoint = lastInsertedNode.nextSibling;
}
parent.insertBefore(currentPost, insertPoint);
lastInsertedNode = currentPost;
insertPoint = lastInsertedNode.nextSibling;
if (hasBr) {
parent.insertBefore(br, insertPoint);
lastInsertedNode = br;
}
}
if (!threadButton) {
checkThreadable(currentPost);
}
}
}
}
if (seenRed) {
updateFavicon('red');
} else if (seenWhite) {
updateFavicon('white');
}
if ((hideDeleted || showDeletedIcon) && (!isLargeDrop || lowPostWarningCount >= 2)) {
for (let i = 0; i < currentPosts.length; i++) {
const post = currentPosts[i];
if (post.closest('.post.qp.reply') || post.classList.contains('post-hover')) continue;
const postId = post.id.split('_')[1];
const threadEl = post.closest('.thread');
if (!threadEl) continue;
const xthreadId = threadEl.id.split('_')[1];
if (xthreadId !== currentThreadId) continue;
const inlineQuoteContainer = post.closest('.inline-quote-container');
const refId = inlineQuoteContainer?.getAttribute('data-inlined-id') || postId;
const isDeleted = !updatePostIds.has(refId) && !justPostedIds.has(refId);
let btn = null;
const prev = post.previousElementSibling;
if (prev && (prev.classList.contains('hide-button') || prev.classList.contains('show-button'))) {
btn = prev;
}
if (isDeleted) {
if (hideDeleted) {
if (!inlineQuoteContainer) {
post.classList.add('deleted-post');
post.style.setProperty("display", "none", "important");
const br = post.nextElementSibling;
if (br?.tagName === "BR") {
br.style.setProperty("display", "none", "important");
}
}
if (btn) {
btn.style.setProperty("display", "none", "important");
}
} else {
addDeletedIcon(post);
}
} else {
post.classList.remove('deleted-post');
post.style.removeProperty("display");
const br = post.nextElementSibling;
if (br?.tagName === "BR") {
br.style.removeProperty("display");
}
if (btn) {
btn.style.removeProperty("display");
}
if (!hideDeleted && post.querySelector('.deleted-post')) {
removeDeletedIcon(post);
}
}
}
}
}
function addDeletedIcon(post) {
if (!showDeletedIcon) return;
if (!post.querySelector('.post-btn')) return;
const postNoLink = post.querySelector('.post_no');
if (!postNoLink || post.querySelector('.deleted-post')) return;
if (showDeletedText) {
const span = document.createElement('span');
span.textContent = ' [Deleted]';
span.className = 'deleted-post';
span.style.color = 'red';
span.style.fontWeight = 'bolder';
postNoLink.parentNode.insertBefore(span, postNoLink.nextSibling?.nextSibling || null);
} else {
const icon = document.createElement('i');
icon.classList.add('fa', 'fa-trash', 'deleted-post');
icon.title = 'Deleted';
icon.style.opacity = '0.5';
icon.style.marginRight = '0px';
postNoLink.parentNode.insertBefore(icon, postNoLink.nextSibling?.nextSibling || null);
}
}
function removeDeletedIcon(post) {
if (!showDeletedIcon) return;
const postNoLink = post.querySelector('.post_no');
const deletedPost = postNoLink?.nextSibling?.nextSibling;
if (deletedPost?.classList.contains('deleted-post')) {
deletedPost.remove();
}
}
let firstThreadSet = null;
let waitingForSecondCheck = false;
let monitorThreadsJson = true;
function cleanThreadStorage(responseText) {
if (!monitorThreadsJson) return;
try {
const parsed = JSON.parse(responseText);
const currentThreads = new Set();
for (const page of parsed) {
for (const thread of page.threads) {
currentThreads.add(thread.no);
}
}
const latestSettings = JSON.parse(localStorage.getItem("Thread Settings") || '{}');
const board = currentBoard;
const hidden = latestSettings.hiddenPosts?.[board] || {};
const missing = [];
for (const threadId in hidden) {
if (!currentThreads.has(parseInt(threadId, 10))) {
missing.push(threadId);
}
}
if (!firstThreadSet) {
if (missing.length === 0) {
monitorThreadsJson = false;
return;
}
firstThreadSet = currentThreads;
waitingForSecondCheck = true;
} else if (waitingForSecondCheck) {
waitingForSecondCheck = false;
const latest = JSON.parse(localStorage.getItem("Thread Settings") || '{}');
const freshHidden = latest.hiddenPosts?.[board] || {};
let changed = false;
for (const threadId in freshHidden) {
if (!currentThreads.has(parseInt(threadId, 10))) {
delete freshHidden[threadId];
changed = true;
}
}
if (changed) {
latest.hiddenPosts[board] = freshHidden;
localStorage.setItem("Thread Settings", JSON.stringify(latest));
}
firstThreadSet = null;
monitorThreadsJson = false;
}
} catch (e) {
}
}
const DAY = 24 * 60 * 60 * 1000;
let originalFilenameBase = '';
let clipboardPasteActive = false;
let clipboardPasteTimer = null;
function markClipboardPaste() {
clipboardPasteActive = true;
clearTimeout(clipboardPasteTimer);
clipboardPasteTimer = setTimeout(() => {
clipboardPasteActive = false;
}, 10);
}
function isClipboardFile(file) {
return (
randomizeClipboard &&
clipboardPasteActive &&
file &&
file.name === 'ClipboardImage.png'
);
}
function sanitizeFilenameInput(name) {
return String(name || '').replace(/[\\\/:\*\?"<>\|]/g, '');
}
function sanitizeFilename(name) {
return sanitizeFilenameInput(name).trim();
}
function syncFilename(value) {
const clean = sanitizeFilenameInput(value);
const inputs = document.querySelectorAll('input[name="filename"]');
for (let i = 0; i < inputs.length; i++) {
inputs[i].value = clean;
}
}
function randomizeFilename(formData) {
const textbox = document.querySelector('input[name="filename"]');
let inputName = sanitizeFilename(textbox?.value || '');
const entries = Array.from(formData.entries());
const toRename = [];
for (let i = 0; i < entries.length; i++) {
const [key, val] = entries[i];
if (val instanceof File) {
toRename.push({ key, file: val });
}
}
if (toRename.length === 0) return;
for (let i = 0; i < toRename.length; i++) {
formData.delete(toRename[i].key);
}
for (let i = 0; i < toRename.length; i++) {
const { key, file } = toRename[i];
const parts = file.name.split('.');
const ext = parts.length > 1 ? '.' + parts.pop() : '';
const base = sanitizeFilename(inputName || parts.join('.'));
const finalName = base || originalFilenameBase || 'file';
const newFile = new File([file], finalName + ext, { type: file.type });
formData.append(key, newFile);
}
}
function initializeRandomizerToggle() {
if (!filenameChanger) return;
document.addEventListener('paste', () => {
markClipboardPaste();
}, true);
const row = document.getElementById('upload_settings');
if (!row) return;
const td = row.querySelector('td');
if (!td) return;
const filenameRow = document.createElement('tr');
filenameRow.id = 'upload_filename';
filenameRow.innerHTML = `
<th>Filename</th>
<td><input type="text" name="filename" size="30" maxlength="255" autocomplete="off"></td>
`;
row.parentNode.insertBefore(filenameRow, row.nextSibling);
const filenameInput = filenameRow.querySelector('input[name="filename"]');
const spoilerInput = td.querySelector('input');
const spoilerLabel = td.querySelector('label');
spoilerLabel.removeAttribute('for');
spoilerLabel.innerHTML = ' Spoiler Image';
spoilerLabel.prepend(spoilerInput);
const input = document.createElement('input');
input.type = 'checkbox';
input.name = 'randfn';
input.id = 'randfn';
const label = document.createElement('label');
label.appendChild(input);
label.appendChild(document.createTextNode(' Randomize Filename'));
td.appendChild(label);
document.addEventListener('change', (e) => {
const target = e.target;
if (target.tagName === 'INPUT' && target.type === 'file') {
const file = target.files?.[0];
if (file) {
const parts = file.name.split('.');
const ext = parts.length > 1 ? parts.pop() : '';
originalFilenameBase = sanitizeFilename(parts.join('.'));
filenameInput.maxLength = 255 - (ext.length > 0 ? ext.length + 1 : 0);
const soundpost = originalFilenameBase.includes('[sound=');
if (input.checked && !soundpost) {
const timestamp = '' + (Date.now() - Math.floor(Math.random() * 365 * DAY));
syncFilename(timestamp);
} else {
syncFilename(originalFilenameBase);
}
}
}
if (target.name === 'spoiler') {
const checked = target.checked;
const boxes = document.querySelectorAll('input[name="spoiler"]');
for (let i = 0; i < boxes.length; i++) {
boxes[i].checked = checked;
}
}
if (target.name === 'randfn') {
const checked = target.checked;
const boxes = document.querySelectorAll('input[name="randfn"]');
for (let i = 0; i < boxes.length; i++) {
boxes[i].checked = checked;
}
if (checked) {
const timestamp = '' + (Date.now() - Math.floor(Math.random() * 365 * DAY));
syncFilename(timestamp);
} else {
syncFilename(originalFilenameBase || '');
}
}
});
document.addEventListener('input', (e) => {
if (e.target.name === 'filename') {
let val = e.target.value || '';
if (val.length > filenameInput.maxLength) {
val = val.substring(0, filenameInput.maxLength);
}
syncFilename(val);
}
});
$(document).on('ajax_before_post', (e, formData) => {
randomizeFilename(formData);
});
const thumbs = document.querySelector('.file-thumbs');
if (thumbs) {
const observer = new MutationObserver((mutations) => {
for (let i = 0; i < mutations.length; i++) {
const { addedNodes, removedNodes } = mutations[i];
for (let j = 0; j < addedNodes.length; j++) {
const node = addedNodes[j];
if (node.nodeType !== 1 || !node.classList.contains('tmb-container')) continue;
const file = $(node).data('file-ref');
if (file) {
const parts = file.name.split('.');
const ext = parts.length > 1 ? parts.pop() : '';
originalFilenameBase = sanitizeFilename(parts.join('.'));
filenameInput.maxLength = 255 - (ext.length > 0 ? ext.length + 1 : 0);
const soundpost = originalFilenameBase.includes('[sound=');
const clipboardImage = randomizeClipboard && isClipboardFile(file);
if (!soundpost && (input.checked || clipboardImage)) {
const timestamp = '' + (Date.now() - Math.floor(Math.random() * 365 * DAY));
syncFilename(timestamp);
} else {
syncFilename(originalFilenameBase);
}
}
}
if (removedNodes.length > 0) {
const remainingFiles = thumbs.querySelectorAll('.tmb-container');
if (remainingFiles.length === 0) {
syncFilename('');
originalFilenameBase = '';
}
}
}
});
observer.observe(thumbs, { childList: true });
}
}
let mobileURLBtn = false;
function checkMobile() {
const mobileHtml = document.documentElement.matches('html.mobile-style, html.mobile-style-new');
const forceMobileTheme = localStorage.getItem('forceMobileTheme') === 'on';
const mobileStyleNew = localStorage.getItem('mobileStyle') === 'new';
if (!mobileStyleNew) return false;
return mobileHtml || forceMobileTheme;
}
function waitForElement(selector, callback, timeout = 5000) {
const el = document.querySelector(selector);
if (el) {
callback(el);
return;
}
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
cleanup();
callback(el);
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
let timer = null;
if (timeout) {
timer = setTimeout(cleanup, timeout);
}
function cleanup() {
observer.disconnect();
if (timer) {
clearTimeout(timer);
timer = null;
}
}
}
function initializeMobileRandomizer() {
if (!filenameChanger) return;
if (!checkMobile()) return;
waitForElement('#mbReplyForm', (mbForm) => {
if (mbForm.querySelector('#mbRandomizerBtn')) return;
const embedInput = mbForm.querySelector('input[name="embed"]');
if (!embedInput) return;
const mbFilenameInput = document.createElement('input');
mbFilenameInput.name = 'filename';
mbFilenameInput.placeholder = 'Filename';
mbFilenameInput.maxLength = 255;
embedInput.insertAdjacentElement('afterend', mbFilenameInput);
const mbSpoilerBtn = document.getElementById('mbSpoilerBtn');
if (!mbSpoilerBtn) return;
const mbRandBtn = document.createElement('button');
mbRandBtn.type = 'button';
mbRandBtn.id = 'mbRandomizerBtn';
mbRandBtn.className = 'outline';
mbRandBtn.setAttribute('aria-pressed', 'false');
mbRandBtn.textContent = 'Randomize';
mbSpoilerBtn.insertAdjacentElement('beforebegin', mbRandBtn);
const mbFileInput = document.getElementById('mbFileInput');
mbFileInput.addEventListener('change', () => {
const file = mbFileInput.files?.[0];
if (file) {
const parts = file.name.split('.');
const ext = parts.length > 1 ? parts.pop() : '';
originalFilenameBase = sanitizeFilename(parts.join('.'));
mbFilenameInput.maxLength = 255 - (ext ? ext.length + 1 : 0);
const soundpost = originalFilenameBase.includes('[sound=');
const clipboardImage = randomizeClipboard && isClipboardFile(file);
if (!soundpost && (mbRandBtn.getAttribute('aria-pressed') === 'true' || clipboardImage)) {
const timestamp = '' + (Date.now() - Math.floor(Math.random() * 365 * DAY));
setTimeout(() => {
mbFilenameInput.value = timestamp;
}, 0);
} else {
setTimeout(() => {
mbFilenameInput.value = originalFilenameBase;
}, 0);
}
}
});
mbRandBtn.addEventListener('click', () => {
const isOn = mbRandBtn.getAttribute('aria-pressed') === 'true';
if (isOn) {
mbRandBtn.setAttribute('aria-pressed', 'false');
mbRandBtn.classList.remove('active');
mbFilenameInput.value = originalFilenameBase || '';
} else {
mbRandBtn.setAttribute('aria-pressed', 'true');
mbRandBtn.classList.add('active');
const timestamp = '' + (Date.now() - Math.floor(Math.random() * 365 * DAY));
mbFilenameInput.value = timestamp;
}
});
mbFilenameInput.addEventListener('input', () => {
let val = mbFilenameInput.value || '';
if (val.length > mbFilenameInput.maxLength) {
val = val.substring(0, mbFilenameInput.maxLength);
}
mbFilenameInput.value = sanitizeFilenameInput(val);
});
mbForm.addEventListener('submit', (e) => {
const mbFileInput = document.getElementById('mbFileInput');
const file = mbFileInput?.files?.[0];
if (!file) return;
const parts = file.name.split('.');
const ext = parts.length > 1 ? '.' + parts.pop() : '';
const base = sanitizeFilename(mbFilenameInput.value || '');
const finalName = base || originalFilenameBase || 'file';
const newFile = new File([file], finalName + ext, { type: file.type });
const dt = new DataTransfer();
dt.items.add(newFile);
mbFileInput.files = dt.files;
}, true);
const mbFileStatus = document.getElementById("mbFileStatus");
if (mbFileStatus) {
const observer = new MutationObserver(() => {
if (!mbFileStatus.textContent.trim()) {
mbFilenameInput.value = '';
originalFilenameBase = '';
}
});
observer.observe(mbFileStatus, { childList: true, characterData: true, subtree: true });
}
});
}
function initializeURLUpload() {
if (!urlUpload) return;
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const CONVERTIBLE_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp']);
const MAX_DIMENSION = 10000;
async function fetchFile(url) {
function httpStatusText(status) {
const codes = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
408: 'Request Timeout',
429: 'Too Many Requests',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout'
};
return codes[status] || 'Error';
}
async function fetchNormal(url) {
const resp = await fetch(url, { mode: 'cors' });
if (!resp.ok) throw new Error(`HTTP ${resp.status} (${httpStatusText(resp.status)})`);
const blob = await resp.blob();
return { blob, finalUrl: resp.url || url };
}
async function fetchGM(url) {
if (!gmRequest) throw new Error('GM API not detected. Use a userscript manager for best compatibility.');
return new Promise((resolve, reject) => {
gmRequest({
method: 'GET',
url,
responseType: 'blob',
onload: (resp) => {
if (resp.status >= 200 && resp.status < 300 && resp.response) {
resolve({ blob: resp.response, finalUrl: resp.finalUrl || resp.responseURL || url });
} else {
reject(new Error(`HTTP ${resp.status} (${httpStatusText(resp.status)})`));
}
},
onerror: () => reject(new Error('HTTP network error')),
ontimeout: () => reject(new Error('HTTP request timed out'))
});
});
}
async function tryFetch(url, yt = false) {
try {
return await fetchNormal(url);
} catch (e) {
try {
return await fetchGM(url);
} catch (gmErr) {
if (yt && /maxresdefault/.test(url) && (/HTTP 404/.test(e.message) || /HTTP 404/.test(gmErr.message))) {
return tryFetch(url.replace('maxresdefault', 'hqdefault'), true);
}
if (!gmRequest) throw new Error(`${e.message}<br>${gmErr.message}`);
throw gmErr;
}
}
}
const ytMatch = url.match(
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/live\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/
);
const isYouTube = !!ytMatch;
if (isYouTube) {
const id = ytMatch[1];
url = `https://img.youtube.com/vi/${id}/maxresdefault.jpg`;
}
url = url.replace(
/^https?:\/\/(?:\w+\.)?(?:twitter|x)\.com\//i,
'https://d.fxtwitter.com/'
);
const { blob, finalUrl = url } = await tryFetch(url, isYouTube);
let filename = 'file';
try {
const p = new URL(finalUrl, location.href).pathname;
const parts = p.split('/');
const last = parts.pop() || parts.pop();
if (last) filename = decodeURIComponent(last);
} catch (e) {}
if (!/\.[a-z0-9]+$/i.test(filename) && blob.type) {
const ext = blob.type.split('/')[1] || '';
if (ext) filename += '.' + ext;
}
return new File([blob], filename, { type: blob.type || 'application/octet-stream' });
}
async function convertToJpeg(file, bitmapOrNull = null, targetBytes = MAX_FILE_SIZE, minQuality = 0.6) {
const bitmap = bitmapOrNull || await createImageBitmap(file);
const shouldClose = !bitmapOrNull;
let srcW = bitmap.width;
let srcH = bitmap.height;
let scale = 1;
if (srcW > MAX_DIMENSION || srcH > MAX_DIMENSION) {
scale = MAX_DIMENSION / Math.max(srcW, srcH);
srcW = Math.max(1, Math.round(srcW * scale));
srcH = Math.max(1, Math.round(srcH * scale));
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let bestBlob = null;
let bestQ = null;
let bestW = srcW, bestH = srcH;
const toBlob = (c, q) => new Promise(r => c.toBlob(r, 'image/jpeg', q));
async function tryExport(width, height) {
canvas.width = width;
canvas.height = height;
ctx.clearRect(0, 0, width, height);
ctx.drawImage(bitmap, 0, 0, width, height);
const startPercent = 95;
const stepPercent = 5;
const minPercent = Math.round(minQuality * 100);
for (let p = startPercent; p >= minPercent; p -= stepPercent) {
const q = p / 100;
const blob = await toBlob(canvas, q);
if (!blob) continue;
if (!bestBlob || blob.size < bestBlob.size) {
bestBlob = blob;
bestQ = q;
bestW = width;
bestH = height;
}
if (blob.size <= targetBytes) {
const newName = (file.name || 'file').replace(/\.[^.]+$/, '') + '.jpg';
if (shouldClose && bitmap.close) bitmap.close();
return {
file: new File([blob], newName, { type: 'image/jpeg' }),
origSize: file.size,
newSize: blob.size,
qualityUsed: q,
succeeded: true,
finalWidth: width,
finalHeight: height
};
}
}
return null;
}
let attempt = await tryExport(srcW, srcH);
if (attempt) return attempt;
let currentW = srcW;
let currentH = srcH;
const MIN_DIM = 64;
let passes = 0;
while (passes < 10 && Math.max(currentW, currentH) >= MIN_DIM) {
passes++;
currentW = Math.max(MIN_DIM, Math.round(currentW * 0.9));
currentH = Math.max(MIN_DIM, Math.round(currentH * 0.9));
const res = await tryExport(currentW, currentH);
if (res) return res;
}
if (shouldClose && bitmap.close) bitmap.close();
if (bestBlob) {
const newName = (file.name || 'file').replace(/\.[^.]+$/, '') + '.jpg';
const newFile = new File([bestBlob], newName, { type: 'image/jpeg' });
return {
file: newFile,
origSize: file.size,
newSize: newFile.size,
qualityUsed: bestQ,
succeeded: newFile.size <= targetBytes,
finalWidth: bestW,
finalHeight: bestH
};
}
throw new Error('Conversion produced no blob');
}
function simDragDrop(dropzoneEl, file) {
const dt = new DataTransfer();
dt.items.add(file);
try {
const dragEvent = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt });
dropzoneEl.dispatchEvent(dragEvent);
return true;
} catch (e) {}
try {
const evt = document.createEvent('Event');
evt.initEvent('drop', true, true);
try { Object.defineProperty(evt, 'dataTransfer', { value: dt }); } catch (defineErr) { evt.dataTransfer = dt; }
dropzoneEl.dispatchEvent(evt);
return true;
} catch (e) {
return false;
}
}
function setFileInputFiles(inputEl, file) {
try {
const dt = new DataTransfer();
dt.items.add(file);
inputEl.files = dt.files;
inputEl.dispatchEvent(new Event('change', { bubbles: true }));
return true;
} catch (e) {
return false;
}
}
function addFileToContainer(container, file) {
if (!container) return false;
const dropzone = container.querySelector('.dropzone') || container.querySelector('.dropzone-wrap .dropzone');
if (dropzone && simDragDrop(dropzone, file)) return true;
const fileInput = container.querySelector('input[type="file"]');
if (fileInput && setFileInputFiles(fileInput, file)) return true;
const mbFileInput = container.querySelector('#mbFileInput');
if (mbFileInput && setFileInputFiles(mbFileInput, file)) return true;
return false;
}
function showConversionNotification(message) {
const alert = $(`
<div id="conversion_notification">
<div id="conversion_div">
<a id="conversion_close" href="javascript:void(0)">
<i class="fa fa-times"></i>
</a>
<div id="alert_message"></div>
</div>
</div>
`);
alert.find('#alert_message').html(message.replace(/\n/g, '<br>'));
alert.hide();
$('body').append(alert);
alert.fadeIn(600);
alert.on('click', '#conversion_close', function () {
alert.stop(true).fadeOut(200, function () { alert.remove(); });
});
setTimeout(() => {
alert.stop(true).fadeOut(400, function () { alert.remove(); });
}, 15000);
}
function showUrlAlert(callback) {
const alert = $(`
<div id="alert_handler">
<div id="alert_background"></div>
<div id="alert_div" style="max-width:90%;">
<a id="alert_close" href="javascript:void(0)">
<i class="fa fa-times"></i>
</a>
<form id="url_form" style="margin-bottom:0;">
<div id="alert_message">
<strong><i class="fa fa-external-link"></i> URL Upload</strong><br><br>
Enter a URL:<br><br>
<input type="text" id="url_input_field" style="width:100%;box-sizing:border-box;padding:6px;">
</div>
<div style="margin:0 13px;display:flex;justify-content:flex-end;gap:.2rem;">
<button type="submit" class="button alert_button" id="urlOk">Upload</button>
<button type="button" class="button alert_button" id="urlCancel">Cancel</button>
</div>
</form>
</div>
</div>
`);
$('body').append(alert);
const urlField = $('#url_input_field');
urlField.focus();
$('#url_form').on('submit', function (e) {
e.preventDefault();
const url = urlField.val().trim();
$('#alert_handler').remove();
callback(url || null);
});
$('#urlCancel, #alert_close, #alert_background').on('click', function () {
$('#alert_handler').remove();
callback(null);
});
urlField.on('keydown', function (e) {
if (e.key === 'Escape') {
$('#alert_handler').remove();
callback(null);
}
});
}
function createURLButton(noMargin = false) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'outline url-upload';
btn.textContent = 'URL';
if (!noMargin) {
btn.style.marginLeft = '2px';
btn.style.marginRight = '2px';
btn.style.padding = '0 2px';
}
return btn;
}
if (!document.querySelector('.url-upload')) {
const uploadTd = document.querySelector('#upload_settings td');
const filenameRow = document.getElementById('upload_filename');
if (filenameRow && uploadTd) {
const td = filenameRow.querySelector('td');
const filenameInput = td && td.querySelector('input[name="filename"]');
if (td && filenameInput && !td.querySelector('.url-upload')) {
let wrapper = td.querySelector('.upload-filename-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'upload-filename-wrapper';
wrapper.style.display = 'flex';
filenameInput.style.flex = '1 1 auto';
td.appendChild(wrapper);
wrapper.appendChild(filenameInput);
}
const btn = createURLButton(false);
wrapper.appendChild(btn);
} else if (uploadTd && !uploadTd.querySelector('.url-upload')) {
const btn = createURLButton(false);
btn.style.float = 'right';
uploadTd.appendChild(btn);
}
} else if (uploadTd && !uploadTd.querySelector('.url-upload')) {
const btn = createURLButton(false);
btn.style.float = 'right';
uploadTd.appendChild(btn);
}
}
if (checkMobile()) {
waitForElement('#mbReplyForm', (mbForm) => {
const mbSend = mbForm.querySelector('#mbSend');
const existing = mbForm.querySelector('.url-upload');
if (!existing && mbSend) {
const btn = createURLButton(true);
mbSend.parentNode.insertBefore(btn, mbSend);
mobileURLBtn = true;
} else if (existing) {
mobileURLBtn = true;
}
});
}
document.addEventListener('click', async (ev) => {
const btn = ev.target.closest && ev.target.closest('.url-upload');
if (!btn) return;
ev.preventDefault();
const container = btn.closest('form') || btn.closest('.dropzone-wrap') || document.getElementById('mbReplyForm') || document.querySelector('#upload_settings td') || document.body;
showUrlAlert(async (url) => {
if (!url) return;
const dropzoneWrap = document.querySelector('.dropzone-wrap');
if (dropzoneWrap) {
const removeBtn = dropzoneWrap.querySelector('.remove-btn');
if (removeBtn) removeBtn.click();
}
btn.disabled = true;
const oldText = btn.textContent;
btn.textContent = 'Fetching…';
try {
let file = await fetchFile(url);
const isImage = file.type && file.type.startsWith('image/');
const fileExtMatch = (file.name || '').match(/\.([a-z0-9]+)$/i);
const ext = fileExtMatch ? fileExtMatch[1].toLowerCase() : (file.type ? file.type.split('/')[1].toLowerCase() : '');
let bitmap = null;
let origW = null, origH = null;
let shouldConvert = false;
if (isImage) {
try {
bitmap = await createImageBitmap(file);
origW = bitmap.width;
origH = bitmap.height;
if (file.size > MAX_FILE_SIZE || bitmap.width > MAX_DIMENSION || bitmap.height > MAX_DIMENSION) {
shouldConvert = true;
}
} catch (e) {
if (file.size > MAX_FILE_SIZE) shouldConvert = true;
}
}
if (isImage && shouldConvert && CONVERTIBLE_EXTS.has(ext)) {
try {
const result = await convertToJpeg(file, bitmap, MAX_FILE_SIZE, 0.6);
if (result && result.file) {
const origRes = (origW && origH) ? `${origW}×${origH}` : 'unknown';
const convRes = (result.finalWidth && result.finalHeight) ? `${result.finalWidth}×${result.finalHeight}` : 'unknown';
const msg = `Image was converted to jpg to fit upload limits. It might have lost transparency.<br><br>Original: ${origRes} - ${(result.origSize / 1048576).toFixed(2)} MB\nConverted: ${convRes} - ${(result.newSize / 1048576).toFixed(2)} MB\nQuality ≈ ${Math.round(result.qualityUsed * 100)}%`;
showConversionNotification(msg);
file = result.file;
}
} catch (e) {
showConversionNotification('Image conversion failed; using original file.');
} finally {
if (bitmap && bitmap.close) bitmap.close();
}
} else if (bitmap && bitmap.close) {
bitmap.close();
}
if (!addFileToContainer(container, file)) {
alert('Could not add file to uploader.');
}
} catch (e) {
alert('Failed to fetch file: ' + (e.message || e));
} finally {
btn.disabled = false;
btn.textContent = oldText;
}
});
});
}
function checkStyles() {
if (!(linkEmbed || linkPreview)) return;
function checkHolidayAvatar() {
try {
let mainSheet = null;
const sheets = document.styleSheets;
for (let i = 0; i < sheets.length; i++) {
const sheet = sheets[i];
if (sheet.href && sheet.href.includes('/stylesheets/style.css')) {
mainSheet = sheet;
break;
}
}
if (!mainSheet) return;
function getAllRules(sheet, collected) {
if (!collected) collected = [];
try {
const rules = sheet.cssRules || [];
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
if (rule instanceof CSSImportRule && rule.styleSheet) {
getAllRules(rule.styleSheet, collected);
} else {
collected.push(rule);
}
}
} catch (e) {}
return collected;
}
const allRules = getAllRules(mainSheet);
let beforeBg = '';
let afterBg = '';
for (let i = 0; i < allRules.length; i++) {
const rule = allRules[i];
if (rule.selectorText && rule.selectorText.includes('.file a:has(> .post-image)::before')) {
beforeBg = rule.style.background || '';
} else if (rule.selectorText && rule.selectorText.includes('.file a:has(> .post-image)::after')) {
afterBg = rule.style.background || '';
}
}
if (!beforeBg && !afterBg) return;
let styleEl = document.getElementById('twt-avatar-holiday-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'twt-avatar-holiday-style';
document.head.appendChild(styleEl);
}
let css = '';
if (beforeBg) {
css += `
.twt-embed a:has(> .twt-avatar)::before {
content: "";
position: absolute;
transform: translate(-5%, -50%);
width: 40px;
height: 31px;
pointer-events: none;
z-index: 2;
background: ${beforeBg};
}
`;
}
if (afterBg) {
css += `
.twt-embed a:has(> .twt-avatar)::after {
content: "";
position: absolute;
transform: translateX(100%);
width: 27px;
height: 36px;
pointer-events: none;
z-index: 2;
background: ${afterBg};
}
`;
}
styleEl.textContent = css;
} catch (e) {}
}
let retries = 0;
function checkInlineQuote() {
let found = false;
const styleTags = document.querySelectorAll('style');
for (let i = 0; i < styleTags.length; i++) {
const text = styleTags[i].textContent;
if (text && text.includes('.inline-quote-container')) {
found = true;
break;
}
}
if (found) {
document.addEventListener('click', function (e) {
const link = e.target.closest('a');
if (!link) return;
if (!/^>>(\d+)/.test(link.textContent?.trim() || '')) return;
setTimeout(() => {
let node = link.parentNode;
while (node && node !== document.body) {
const container = node.querySelector(':scope > .inline-quote-container');
if (container) {
if (linkPreview) bindLinkPreviews(container, false);
if (linkEmbed) {
const btns = container.querySelectorAll('.embed-button[data-embedurl]');
for (let i = 0; i < btns.length; i++) {
const btn = btns[i];
const url = btn.getAttribute('data-embedurl');
if (url) {
btn.addEventListener('click', onEmbedClick);
}
}
}
break;
}
node = node.parentNode;
}
}, 0);
}, true);
} else if (++retries < 3) {
setTimeout(checkInlineQuote, 3000);
}
}
if (linkEmbed) {
checkHolidayAvatar();
}
checkInlineQuote();
}
function catalogLastReply() {
const sortSelect = document.querySelector('#sort_by');
if (!sortSelect) return;
const holiday = checkDate();
if (holiday === 'christmas') {
snowEffect();
} else if (holiday === 'newyear') {
// fireworksEffect();
}
let lastOption = sortSelect.querySelector('option[value="last:desc"]');
if (!lastOption) {
lastOption = document.createElement('option');
lastOption.value = 'last:desc';
lastOption.textContent = 'Last reply';
const timeOption = sortSelect.querySelector('option[value="time:desc"]');
sortSelect.insertBefore(lastOption, timeOption);
}
let dataLoaded = false;
let loadingInterval = null;
const mixEls = Array.from(document.querySelectorAll('.mix[data-id]'));
const visibleThreadIds = new Set(mixEls.map(el => el.dataset.id));
async function loadReplyData() {
if (dataLoaded) return;
dataLoaded = true;
const baseText = 'Last reply';
let dots = 0;
let completed = 0;
let totalTasks = 0;
function updateLoadingText() {
const dotStr = '.'.repeat(dots) + '\u00A0'.repeat(3 - dots);
const pct = totalTasks > 0 ? Math.min(100, Math.floor((completed / totalTasks) * 100)) : 0;
lastOption.textContent = `Loading${dotStr} ${pct}%`;
}
loadingInterval = setInterval(() => {
dots = (dots + 1) % 4;
const renderDots = dots === 0 ? 0 : dots;
dots = renderDots;
updateLoadingText();
}, 400);
const latest = JSON.parse(localStorage.getItem("Thread Settings") || '{}');
if (!latest.lastPosts) latest.lastPosts = {};
if (!latest.lastPosts[currentBoard]) latest.lastPosts[currentBoard] = {};
const stored = latest.lastPosts[currentBoard];
const threadTimes = new Map();
for (let i = 0; i < mixEls.length; i++) {
const mixEl = mixEls[i];
const threadId = mixEl.dataset.id;
const replies = Number(mixEl.dataset.reply);
const bump = Number(mixEl.dataset.bump);
if (replies <= 749) {
threadTimes.set(threadId, bump);
} else {
if (stored[threadId] && stored[threadId].replies === replies) {
threadTimes.set(threadId, stored[threadId].lastModified);
} else {
totalTasks++;
fetchTasks.push(async () => {
try {
const res = await fetch(`/${currentBoard}/res/${threadId}.json`, {
headers: { "Range": "bytes=-1024" },
cache: "reload"
});
if (!res.ok && res.status !== 206) throw new Error("Failed tail fetch");
const text = await res.text();
const matches = Array.from(text.matchAll(/"last_modified"\s*:\s*(\d+)/g));
if (matches.length > 0) {
const lastModified = Number(matches[matches.length - 1][1]);
threadTimes.set(threadId, lastModified);
stored[threadId] = { replies, lastModified };
} else if (stored[threadId]) {
threadTimes.set(threadId, stored[threadId].lastModified);
} else {
threadTimes.set(threadId, bump);
}
} catch (e) {
if (stored[threadId]) {
threadTimes.set(threadId, stored[threadId].lastModified);
} else {
threadTimes.set(threadId, bump);
}
} finally {
completed++;
updateLoadingText();
}
});
}
}
}
updateLoadingText();
await fetchQueue(5);
for (let i = 0; i < mixEls.length; i++) {
const mixEl = mixEls[i];
const threadId = mixEl.dataset.id;
if (threadTimes.has(threadId)) {
mixEl.setAttribute('data-last', threadTimes.get(threadId));
}
}
latest.lastPosts[currentBoard] = stored;
localStorage.setItem("Thread Settings", JSON.stringify(latest));
clearInterval(loadingInterval);
lastOption.textContent = baseText;
sortSelect.dispatchEvent(new Event('change'));
}
(function cleanup() {
const latest = JSON.parse(localStorage.getItem("Thread Settings") || '{}');
if (!latest.lastPosts || !latest.lastPosts[currentBoard]) return;
const stored = latest.lastPosts[currentBoard];
for (const storedId of Object.keys(stored)) {
if (!visibleThreadIds.has(storedId)) {
delete stored[storedId];
}
}
latest.lastPosts[currentBoard] = stored;
localStorage.setItem("Thread Settings", JSON.stringify(latest));
})();
sortSelect.addEventListener('change', () => {
if (sortSelect.value === 'last:desc') {
loadReplyData();
}
});
let autoselectLast = false;
try {
const catalog = JSON.parse(localStorage.catalog || '{}');
const sort = catalog.sort_by;
if (sort === 'last:desc' || sort === '') {
autoselectLast = true;
catalog.sort_by = 'last:desc';
localStorage.catalog = JSON.stringify(catalog);
}
} catch (e) {}
if (autoselectLast) {
sortSelect.value = 'last:desc';
sortSelect.dispatchEvent(new Event('change'));
}
}
function matchKeybind(e, keybind) {
if (!keybind || typeof keybind !== "object") return false;
if (!!keybind.ctrl !== e.ctrlKey) return false;
if (!!keybind.alt !== e.altKey) return false;
if (!!keybind.shift !== e.shiftKey) return false;
const key = e.key === " " ? " " : e.key.toLowerCase();
return key === keybind.key.toLowerCase();
}
function initializeKeybinds(optionsOnly = false) {
waitForElement("a[title='Options']", (optionsBtn) => {
let optionsChecked = false;
document.addEventListener("keydown", function (ev) {
const targetTag = ev.target.tagName;
const inInput = targetTag === "INPUT" || targetTag === "TEXTAREA";
if (matchKeybind(ev, kbOptions)) {
const handler = document.getElementById("options_handler");
if (!handler) return;
const isHidden = handler.style.display === "none";
if (inInput && isHidden) return;
ev.preventDefault();
if (isHidden) {
if (!optionsChecked) {
optionsChecked = true;
const tabList = handler.querySelector("#options_tablist");
const hasActive = !!(tabList && tabList.querySelector(".options_tab_icon.active"));
if (!hasActive) {
Options.select_tab("thread-status");
}
}
optionsBtn.click();
} else {
Options.hide();
}
return;
}
if (optionsOnly) return;
if (urlUpload && matchKeybind(ev, kbURL)) {
const cancelBtn = document.getElementById("urlCancel");
ev.preventDefault();
if (cancelBtn) {
cancelBtn.click();
return;
}
let btn;
if (mobileURLBtn) {
const mbForm = document.querySelector('#mbReplyForm');
btn = mbForm && mbForm.querySelector('.url-upload');
} else {
btn = document.querySelector('.url-upload');
}
if (btn) {
btn.click();
return;
}
}
if (inInput) return;
if (optionThreading && matchKeybind(ev, kbThreadToggle)) {
const btn = document.querySelector(".threading-toggle");
if (btn) {
ev.preventDefault();
btn.click();
return;
}
}
if (optionThreading && toggleThread && matchKeybind(ev, kbThreadNew)) {
const btn = document.querySelector(".threading-new");
if (btn) {
ev.preventDefault();
btn.click();
return;
}
}
});
});
}
$(function () {
if (typeof Options === "undefined") return;
const SETTINGS_KEY = "Thread Settings";
const settingsList = [
{ key: "filenameChanger", label: "Filename Changer", description: "Enable filename changer and randomizer" },
{ key: "linkPreview", label: "Media Hover", description: "Show images and videos of supported links on mouseover" },
{ key: "showDeletedCounter", label: "Deleted Count", description: "Show deleted post counter" },
{ key: "hideDeletedPosts", label: "Hide Deleted Posts", description: "Hide posts upon deletion" },
{ key: "showDeletedIcon", label: "Deleted Icon", description: 'Add an icon <i class="fa fa-trash deleted-post"title="Deleted"style="opacity:0.5;margin-right:0px;"></i> next to deleted posts' },
{ key: "faviconUpdater", label: "Favicon Updater", description: "Enable favicon changes" },
{ key: "showUnreadLine", label: "Unread Line", description: "Show a line below the last post" },
{ key: "showArchivedMessage", label: "Archived Message", description: "Show archived message on post limit" },
{ key: "appendQuotes", label: "Append Quotes", description: "Append '(OP)' '(You)' '→' to applicable quotes" },
{ key: "urlUpload", label: "URL Upload", description: "Add button to upload files via URL" },
{ key: "linkTitle", label: "Link Title", description: "Replace supported links with their actual titles" },
{ key: "linkIcon", label: "Link Icon", description: "Add icons next to supported links" },
{ key: "linkEmbed", label: "Link Embedding", description: "Add embed buttons to supported links" },
{ key: "thumbnailSwap", label: "Transparent Thumbnails", description: "Images with transparency replace their thumbnails" },
{ key: "showSpoilerText", label: "Reveal Spoilers", description: "Show all spoilers in text" },
{ key: "showSpoilerMedia", label: "Reveal Spoiler Thumbnails", description: "Replace spoiler thumbnails with the original image" },
{ key: "optionThreading", label: "Quote Threading", description: "Add option to thread conversations" },
{ key: "hidePosts", label: "Post Hiding Buttons", description: "Add buttons to hide posts" }
];
const subSettings = [
{ key: "showDeletedText", label: "Deleted Text", description: 'Add <span style="color:red;font-weight:bolder">[Deleted]</span> text instead', parentKey: "showDeletedIcon" },
{ key: "notifyNewPost", label: "New Post", description: "White circle in favicon on new posts", parentKey: "faviconUpdater" },
{ key: "notifyNewYou", label: "New (You)", description: "Red circle in favicon on new (You) quotes", parentKey: "faviconUpdater" },
{ key: "changeFaviconOnArchive", label: "Archived", description: "Favicon turns red on post limit", parentKey: "faviconUpdater" },
{ key: "appendCrossThread", label: "Append (Cross-thread)", description: "Use (Cross-thread) instead", parentKey: "appendQuotes" },
{ key: "archivedMessageText", label: "Text", parentKey: "showArchivedMessage", type: "text" },
{ key: "archivedImageURL", label: "Image URL", parentKey: "showArchivedMessage", type: "text" },
{ key: "archivedMessageFontSize", label: "Font Size", description: "Use with 'px' or '%'", parentKey: "showArchivedMessage", type: "text", size: "1", after: "archivedMessageText" },
{ key: "archivedImageSize", label: "Image Width", description: "Use with 'px' or '%'", parentKey: "showArchivedMessage", type: "text", size: "1", after: "archivedImageURL" },
{ key: "archivedImageUseHeight", label: "Use Height", description: "Instead of width for image", parentKey: "showArchivedMessage", after: "archivedImageSize" },
{ key: "showStubs", label: "Stubs", description: "Show stubs of hidden posts", parentKey: "hidePosts" },
{ key: "recursiveHiding", label: "Recursive Hiding", description: "Hide replies of hidden posts, recursively", parentKey: "hidePosts" },
{ key: "translateAuto", label: "Auto Translate", description: "If original language is:", parentKey: "linkEmbed" },
{ key: "translateFrom", label: "", parentKey: "linkEmbed", type: "text", size: "3", after: "translateAuto" },
{ key: "notifyPostColor", label: "", parentKey: "faviconUpdater", type: "text", size: "3", after: "notifyNewPost" },
{ key: "notifyYouColor", label: "", parentKey: "faviconUpdater", type: "text", size: "3", after: "notifyNewYou" },
{ key: "directButton", label: "Direct Embed", description: "Use the link itself as an embed button", parentKey: "linkEmbed" },
{ key: "videoScrollVol", label: "Scroll Volume", description: "Adjust hover video volume with the scroll wheel", parentKey: "linkPreview" },
{ key: "randomizeClipboard", label: "Randomize Clipboard", description: "Always randomize pasted image filenames", parentKey: "filenameChanger" },
{ key: "kbThreadNew", label: "[Thread New Posts]", description: "Keybind", parentKey: "optionThreading", type: "keybind", size: "6" },
{ key: "kbThreadToggle", label: "", parentKey: "optionThreading", type: "keybind", size: "6" },
{ key: "kbURL", label: "", parentKey: "urlUpload", type: "keybind", size: "6" }
];
const defaultSettings = {
showDeletedCounter: true,
showDeletedIcon: true,
hideDeletedPosts: false,
showArchivedMessage: true,
faviconUpdater: true,
optionThreading: true,
showUnreadLine: true,
appendQuotes: true,
notifyNewPost: true,
notifyNewYou: true,
changeFaviconOnArchive: true,
showDeletedText: false,
appendCrossThread: false,
archivedMessageText: "THREAD ARCHIVED",
archivedImageURL: "https://i.imgur.com/LQHVLil.png",
archivedMessageFontSize: "14px",
archivedImageSize: "7%",
archivedImageUseHeight: false,
appendQuotesWarning: false,
hidePosts: true,
recursiveHiding: true,
showStubs: true,
filenameChanger: true,
linkPreview: true,
urlUpload: true,
linkEmbed: true,
translateAuto: false,
translateFrom: "ja, id",
thumbnailSwap: true,
notifyPostColor: "white",
notifyYouColor: "red",
directButton: false,
videoScrollVol: true,
showSpoilerText: false,
showSpoilerMedia: false,
linkIcon: true,
linkTitle: true,
randomizeClipboard: false,
kbOptions: { ctrl: false, alt: true, shift: false, key: "o" },
kbThreadToggle: { ctrl: false, alt: false, shift: true, key: "t" },
kbThreadNew: { ctrl: false, alt: false, shift: false, key: "t" },
kbURL: { ctrl: false, alt: true, shift: false, key: "l" },
persistentEffect: false,
persistentDecor: false,
};
let threadSettings = {};
try {
threadSettings = JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {};
} catch {
threadSettings = {};
}
for (const key in defaultSettings) {
if (!(key in threadSettings)) {
threadSettings[key] = defaultSettings[key];
}
}
const saveSettings = () => {
let latest = {};
try {
latest = JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {};
} catch {
latest = {};
}
const toSave = {
...threadSettings,
hiddenPosts: latest.hiddenPosts,
enableThreading: latest.enableThreading,
lastPosts: latest.lastPosts,
};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(toSave));
};
const content = $("<div></div>");
settingsList.forEach(({ key, label, description }) => {
const isChecked = threadSettings[key];
const descSpan = description ? `<span class="description">: ${description}</span>` : "";
let style = "";
if (key === "urlUpload" || key === "optionThreading") {
style = "display:inline-block;";
}
const checkbox = $(`
<div id="${key}-container" style="${style}">
<label style="text-decoration: underline; cursor: pointer;">
<input type="checkbox" id="${key}" ${isChecked ? "checked" : ""} name="${label}">${label}</label>${descSpan}
</div>
`);
content.append(checkbox);
});
subSettings.forEach((s) => {
const { key, label, description, type = "checkbox" } = s;
const value = threadSettings[key];
const descSpan = description ? `<span class="description">: ${description}</span>` : "";
let input;
if (type === "keybind") {
const displayValue = formatKeybind(value);
input = `<input type="text" id="${key}" value="${displayValue}" size="${s.size || 6}" class="keybind-box" style="margin-right:3px;">`;
} else if (type === "text") {
const inputSize = s.size || "20";
input = `<input type="text" id="${key}" value="${value}" size="${inputSize}" style="margin-right: 3px;">`;
} else {
input = `<input type="checkbox" id="${key}" ${value ? "checked" : ""}>`;
}
let style = "margin-left: 1.5em;";
if (key === "translateAuto" || key === "notifyNewPost" || key === "notifyNewYou") style += " display:inline-block;";
if (key === "translateFrom" || key === "notifyPostColor" || key === "notifyYouColor" || key === "kbThreadToggle" || key === "kbURL") style += " display:inline-block; margin-left:0.5em;";
const container = $(`
<div id="${key}-container" style="${style}">
<label style="text-decoration: underline; cursor: pointer;">
${input}${label}</label>${descSpan}
</div>
`);
content.append(container);
});
const previewWrapper = $(`
<div id="archived-preview" style="
position: absolute;
top: 287px;
right: 0px;
width: 220px;
height: 90px;
overflow: hidden;
pointer-events: none;
transform-origin: top left;
">
</div>
`);
content.css("position", "relative");
content.append(previewWrapper);
function renderArchivedPreview() {
const rawFontSize = threadSettings.archivedMessageFontSize || defaultSettings.archivedMessageFontSize || "14px";
const rawImageSize = threadSettings.archivedImageSize || defaultSettings.archivedImageSize || "7%";
const useHeight = threadSettings.archivedImageUseHeight;
const msg = threadSettings.archivedMessageText || defaultSettings.archivedMessageText || "THREAD ARCHIVED";
const img = threadSettings.archivedImageURL || defaultSettings.archivedImageURL || "https://i.imgur.com/LQHVLil.png";
const convertSize = (raw, isHeight = false) => {
if (raw.endsWith('%')) {
const percent = parseFloat(raw);
const base = isHeight ? window.innerHeight : window.innerWidth;
return `${(percent / 100) * base}px`;
}
return raw;
};
const fontSize = convertSize(rawFontSize);
const imageSize = convertSize(rawImageSize, useHeight);
const previewContent = $(`
<div style="display: inline-block; white-space: nowrap;">
<div style="display: inline-block;">
<strong style="color: red; font-size: ${fontSize}; display: inline-block; white-space: nowrap;">${msg}</strong><br>
<img src="${img}" style="margin-top: 5px; display: inline-block; ${useHeight ? 'height' : 'width'}: ${imageSize};">
</div>
</div>
`);
previewWrapper.empty().append(previewContent);
const imageEl = previewContent.find('img')[0];
const applyScale = (scale) => {
previewContent.css({
transform: `scale(${scale})`,
transformOrigin: "top left"
});
};
const tryScale = () => {
const bounds = previewContent[0].getBoundingClientRect();
const width = bounds.width;
const height = bounds.height;
if (width && height) {
const scaleX = 220 / width;
const scaleY = 90 / height;
const scale = Math.min(scaleX, scaleY, 1);
applyScale(scale);
threadSettings.archivedPreviewScale = scale.toFixed(6);
} else {
const fallbackScale = parseFloat(threadSettings.archivedPreviewScale);
if (fallbackScale && isFinite(fallbackScale)) {
applyScale(fallbackScale);
}
}
};
if (imageEl.complete) {
tryScale();
} else {
imageEl.onload = tryScale;
}
}
function positionArchivedPreview() {
const target = document.getElementById("archivedImageUseHeight-container");
const preview = document.getElementById("archived-preview");
const container = content[0];
if (!target || !preview || !container) return;
const targetRect = target.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const top = targetRect.top - containerRect.top + targetRect.height / 2 - preview.offsetHeight / 2;
preview.style.top = `${Math.round(top)}px`;
preview.style.right = `0px`;
}
function getTotalHiddenCount() {
const settings = JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {};
const hp = settings.hiddenPosts || {};
let total = 0;
const boards = Object.keys(hp);
for (let i = 0; i < boards.length; i++) {
const board = boards[i];
const threads = Object.keys(hp[board]);
for (let j = 0; j < threads.length; j++) {
const thread = threads[j];
const arr = hp[board][thread];
if (Array.isArray(arr)) total += arr.length;
}
}
return total;
}
function updateHiddenCountDisplay() {
const count = getTotalHiddenCount();
$('#clear-hidden').text(`Hidden: ${count}`);
}
const clearBtn = $(`
<div id="clear-hidden-container">
<button id="clear-hidden">Hidden: ${getTotalHiddenCount()}</button>
<span class="description">: Clear manually-hidden posts</span>
</div>
`);
content.append(clearBtn);
Options.add_tab("thread-status", "cog", "Thread Options", content);
$('#clear-hidden').on('click', () => {
const latest = JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {};
latest.hiddenPosts = {};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(latest));
updateHiddenCountDisplay();
});
subSettings.forEach(({ key, parentKey, after }) => {
const parent = $(`#${parentKey}-container`);
const insertAfter = after ? $(`#${after}-container`) : parent;
$(`#${key}-container`).insertAfter(insertAfter);
});
function settingsStatus() {
subSettings.forEach(({ key, parentKey, after }) => {
const parentChecked = parentKey ? !!threadSettings[parentKey] : true;
const afterChecked = after ? !!threadSettings[after] : true;
const disabled = !(parentChecked && afterChecked);
const $input = $(`#${key}`);
const $label = $input.closest("label");
const $desc = $label.next(".description");
$input.prop("disabled", disabled);
$label.css("opacity", disabled ? 0.5 : 1);
$desc.css("opacity", disabled ? 0.5 : 1);
});
$("#archived-preview").toggle(!!threadSettings.showArchivedMessage);
}
subSettings.forEach(({ key, type = "checkbox" }) => {
const $input = $(`#${key}`);
$input.on("change input", function () {
if (type === "text") {
const trimmed = this.value.trim();
if (key === "archivedImageSize" && !trimmed) {
threadSettings[key] = defaultSettings[key];
threadSettings.archivedImageUseHeight = defaultSettings.archivedImageUseHeight;
$(`#archivedImageUseHeight`).prop("checked", defaultSettings.archivedImageUseHeight);
} else {
threadSettings[key] = trimmed || defaultSettings[key];
}
} else {
threadSettings[key] = this.checked;
}
saveSettings();
renderArchivedPreview();
});
});
const kbOptionsBox = $(`
<div id="kbOptions-container" style="position: absolute; top: -22px; right: 0px;">
<label>Open Settings <input type="text" id="kbOptions" class="keybind-box" size="6">
</label>
<div style="font-size: 0.75em; text-align: right;">Backspace resets keybind</div>
</div>
`);
content.css("position", "relative");
content.append(kbOptionsBox);
$("#kbOptions").val(formatKeybind(threadSettings.kbOptions));
const holiday = checkDate();
if (holiday === 'christmas') {
content.css('position', 'relative');
const controls = $(`
<div id="persistent-controls" style="
position: absolute;
top: -22px;
left: 0;
display: flex;
gap: 12px;
align-items: center;
">
<label for="persistentEffect" style="text-decoration: underline; cursor: pointer;">
<input type="checkbox" id="persistentEffect" name="persistentEffect">Effect</label>
<label for="persistentDecor" style="text-decoration: underline; cursor: pointer;">
<input type="checkbox" id="persistentDecor" name="persistentDecor">Decor</label>
</div>
`);
$('#persistentEffect', controls).prop('checked', !!threadSettings.persistentEffect);
$('#persistentDecor', controls).prop('checked', !!threadSettings.persistentDecor);
content.append(controls);
}
function formatKeybind(obj) {
if (!obj || typeof obj !== "object") return "";
const parts = [];
if (obj.ctrl) parts.push("Ctrl");
if (obj.alt) parts.push("Alt");
if (obj.shift) parts.push("Shift");
if (obj.key) {
const main = obj.key === " " ? "Space" :
obj.key.length === 1 ? obj.key.toUpperCase() : obj.key;
parts.push(main);
}
return parts.join("+");
}
$(".keybind-box").each(function () {
const $box = $(this);
const key = $box.attr("id");
const def = defaultSettings[key];
$box.on("keydown", function (e) {
e.preventDefault();
e.stopPropagation();
if (e.key === "Backspace") {
threadSettings[key] = defaultSettings[key];
$box.val(formatKeybind(defaultSettings[key]));
saveSettings();
return;
}
if (["Alt", "Control", "Shift", "Meta"].includes(e.key)) return;
const keybind = {
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
key: e.key === " " ? " " : e.key
};
threadSettings[key] = keybind;
$box.val(formatKeybind(keybind));
saveSettings();
});
});
const $brTarget = $('#notifyNewPost-container');
if ($brTarget.length) {
$('<br>').insertBefore($brTarget);
}
$("#appendQuotes").on("change", function () {
const checked = $(this).is(":checked");
if (checked && !threadSettings.appendQuotesWarning) {
const alert = $(`
<div id="alert_handler">
<div id="alert_background"></div>
<div id="alert_div">
<a id="alert_close" href="javascript:void(0)">
<i class="fa fa-times"></i>
</a>
<div id="alert_message">
<strong>⚠ Compatibility Notice</strong><br><br>
This feature is compatible with the latest version of the <em>Inline Quoting</em> script.<br><br>
Update to <strong>version 2.1</strong> or later if you are experiencing issues with inline quoting appended replies.
</div>
<div style="margin: 13px;">
<label><input type="checkbox" id="appendQuotesWarning">Don't show this message again</label>
</div>
<div>
<button class="button alert_button" id="appendQuotesOk">OK</button>
</div>
</div>
</div>
`);
$("body").append(alert);
$("#appendQuotesOk").on("click", function () {
if ($("#appendQuotesWarning").is(":checked")) {
threadSettings.appendQuotesWarning = true;
saveSettings();
}
$("#alert_handler").remove();
});
$("#alert_close").on("click", function () {
$("#appendQuotes").prop("checked", false);
threadSettings.appendQuotes = false;
saveSettings();
$("#alert_handler").remove();
});
}
});
content.on("change input", "input", function () {
const key = this.id;
if (!key) return;
const isCheckbox = this.type === "checkbox";
if (isCheckbox) {
threadSettings[key] = this.checked;
} else {
const trimmed = this.value.trim();
threadSettings[key] = trimmed || defaultSettings[key];
}
saveSettings();
settingsStatus();
renderArchivedPreview();
});
$("a[title='Options'], .options_tab_icon:has(.fa-cog)").on("click", () => {
setTimeout(updateHiddenCountDisplay, 0);
renderArchivedPreview();
positionArchivedPreview();
});
settingsStatus();
saveSettings();
});
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', onReady);
} else {
onReady();
}
function onReady() {
const path = window.location.pathname;
const mobileNewTheme = localStorage.getItem('forceMobileTheme') === 'on' && localStorage.getItem('mobileStyle') === 'new';
if (mobileNewTheme) {
waitForElement('#mbDesktopClose', (closeBtn) => {
closeBtn.click();
});
}
const threadMatch = path.match(/^\/([^/]+)\/res\/(\d+)\.html/);
if (threadMatch) {
currentBoard = threadMatch[1];
currentThreadId = threadMatch[2];
initializePosts();
hoverQuoteReplies();
threadMonitoring();
initializeThreadingToggle();
initializeRandomizerToggle();
initializeMobileRandomizer();
initializeSiteKey();
initializeURLUpload();
initializeKeybinds();
checkStyles();
return;
} else {
initializeKeybinds(true);
}
const catalogMatch = path.match(/^\/([^/]+)\/catalog\.html$/);
if (catalogMatch) {
currentBoard = catalogMatch[1];
catalogLastReply();
return;
}
}
})();