// ==UserScript==
// @name Reddit-Chan
// @namespace http://tampermonkey.net/
// @version 0.1
// @description Chain 4chan comments like Reddit
// @author Antigravity
// @match *://boards.4chan.org/*
// @grant none
// @icon https://www.google.com/s2/favicons?sz=64&domain=4chan.org
// @updateURL https://raw.githubusercontent.com/Acelogic/reddit-chan/master/reddit-chan.user.js
// @downloadURL https://raw.githubusercontent.com/Acelogic/reddit-chan/master/reddit-chan.user.js
// @homepageURL https://github.com/Acelogic/reddit-chan
// ==/UserScript==
(function() {
'use strict';
console.log("Starting Reddit-Chan...");
// --- STATE MANAGEMENT ---
let currentMode = 'unknown'; // 'index' or 'thread' or 'catalog'
function updateMode() {
const loc = window.location.href;
const hash = window.location.hash;
const title = document.title;
const indexMode = document.getElementById('index-mode'); // 4chan X dropdown
const isCatalog =
loc.includes('/catalog') ||
title.includes('Catalog') ||
(hash === '#catalog') ||
(indexMode && indexMode.value === 'catalog') ||
(document.getElementById('catalog') && !loc.includes('/thread/'));
if (isCatalog) {
return setMode('catalog');
} else if (loc.includes('/thread/') || hash.includes('/thread/')) { // X uses hashes sometimes
return setMode('thread');
} else {
return setMode('index');
}
}
function setMode(mode) {
if (currentMode === mode) return;
currentMode = mode;
console.log(`[Reddit-Chan] Switching to mode: ${mode}`);
document.body.classList.remove('is-index-view', 'is-thread-view', 'is-catalog-view');
if (mode === 'index') document.body.classList.add('is-index-view');
if (mode === 'thread') document.body.classList.add('is-thread-view');
if (mode === 'catalog') document.body.classList.add('is-catalog-view');
// Re-process view specific logic if needed immediately
if (mode !== 'catalog') runMainProcessor();
}
// --- GLOBAL CSS ---
const style = document.createElement('style');
style.innerHTML = `
/* Shared */
body.is-index-view, body.is-thread-view { margin-right: 320px !important; }
/* Sidebar */
#reddit-sidebar {
position: fixed; top: 0; right: 0; width: 300px; height: 100vh;
background-color: #f7f7f7; border-left: 1px solid #c6c6c6;
padding: 10px; overflow-y: auto; z-index: 9999; box-sizing: border-box;
font-family: verdana, arial, helvetica, sans-serif;
display: none; /* Hidden by default, shown in view modes */
}
body.is-index-view #reddit-sidebar, body.is-thread-view #reddit-sidebar { display: block; }
/* Sidebar Content Styles */
#reddit-sidebar .sr-header { font-size: 16px; font-weight: bold; margin-bottom: 10px; color: #333; text-align: center; }
#reddit-sidebar .search-box { width: 100%; padding: 5px; margin-bottom: 15px; border: 1px solid #c6c6c6; }
#reddit-sidebar .action-btn { display: block; width: 100%; padding: 8px; margin-bottom: 10px; text-align: center; background: #ffffff; border: 1px solid #c6c6c6; color: #333; font-weight: bold; cursor: pointer; text-decoration: none; }
#reddit-sidebar .action-btn:hover { background: #f0f0f0; }
#reddit-sidebar .submit-link { background: #ebf3fc; border-color: #5f99cf; color: #5f99cf; }
#reddit-sidebar .sub-info { font-size: 11px; margin-top: 20px; line-height: 1.5; border: 1px solid #e0e0e0; background: #fff; padding: 5px; }
/* --- THREAD VIEW STYLES --- */
body.is-thread-view .thread > .postContainer:not(.opContainer) {
border: 1px solid #7c7c7c !important;
background-color: rgba(255,255,255,0.05);
margin-bottom: 10px !important;
border-radius: 4px; padding: 5px; overflow: hidden;
}
body.is-thread-view .post {
border: none !important; border-bottom: none !important;
box-shadow: none !important; background: transparent !important;
padding: 2px 0px !important; display: block !important; max-width: 100%;
}
body.is-thread-view .postContainer { margin: 0 !important; padding: 0 !important; }
body.is-thread-view .replies-container {
margin-left: 15px !important; padding-left: 15px !important;
border-left: 1px solid #dcdcdc !important;
}
body.is-thread-view .replies-container:hover { border-left-color: #888 !important; }
body.is-thread-view .collapse-btn { cursor: pointer; margin-right: 6px; font-size: 10px; vertical-align: middle; color: #555; user-select: none; font-family: monospace; }
body.is-thread-view .nameBlock.op-user .name, body.is-thread-view .nameBlock.op-user .postertrip { color: #0055DF !important; font-weight: bold; background-color: transparent !important; }
body.is-thread-view .nameBlock.op-user:after { content: " [OP]"; color: #0055DF; font-weight: bold; font-size: 0.8em; }
body.is-thread-view .post.collapsed .postMessage, body.is-thread-view .post.collapsed .file, body.is-thread-view .post.collapsed .postInfo .backlink, body.is-thread-view .post.collapsed + .replies-container { display: none !important; }
body.is-thread-view .post.collapsed { padding: 2px 4px !important; opacity: 0.7; border: none !important; }
/* --- INDEX VIEW STYLES --- */
body.is-index-view .replyContainer, body.is-index-view .summary { display: none !important; }
body.is-index-view .board .thread {
display: flex;
flex-direction: row;
background: #fff;
border: 1px solid #ccc;
margin-bottom: 8px !important;
padding: 6px;
border-radius: 4px;
clear: both;
min-height: 80px;
height: auto;
overflow: visible;
}
body.is-index-view .board .thread:hover {
border-color: #898989;
background-color: #f8f8f8;
}
body.is-index-view .postContainer.opContainer {
display: flex !important;
align-items: center;
width: 100%;
}
body.is-index-view .opContainer .post.op {
display: flex !important;
width: 100%;
background: transparent !important;
border: none !important;
flex-direction: row;
align-items: flex-start;
}
body.is-index-view .file {
display: block !important;
margin-right: 12px !important;
margin-bottom: 0 !important;
flex-shrink: 0;
}
body.is-index-view .fileThumb {
max-width: 70px !important;
max-height: 70px !important;
width: auto; height: auto;
float: none !important;
}
body.is-index-view .fileText { display: none !important; }
body.is-index-view .postInfo {
/* Hidden by default in wrapper unless moved, but we need raw style for extracted logic */
/* We will style it inside the wrapper */
}
/* Wrapper Styles */
.reddit-content-wrapper {
/* Handled in JS inline mostly, but good to have base */
}
/* Hide original elements if they are not inside our wrapper yet (FOUC prevention) */
body.is-index-view .postMessage { display: none !important; }
body.is-index-view .subject { display: none !important; } /* Hide original subject span */
/* Reddit Styles */
.subject-link {
font-size: 16px;
color: #0000FF;
font-weight: bold;
display: block;
margin-bottom: 4px;
text-decoration: none;
line-height: 1.2;
padding-top: 2px;
}
.subject-link:hover { text-decoration: underline; }
.reddit-footer {
font-size: 12px;
font-weight: bold;
color: #888;
margin-top: 4px;
}
.reddit-footer a { color: #888; text-decoration: none; }
.reddit-footer a:hover { text-decoration: underline; }
body.is-index-view .postInfo input[type=checkbox] { display: none !important; }
body.is-index-view .postNum { font-size: 0.9em; margin-left: 5px; }
/* Modal Styles */
#reddit-post-modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.6);
backdrop-filter: blur(2px);
align-items: center;
justify-content: center;
}
#reddit-post-content {
background-color: #fefefe;
margin: auto;
padding: 20px;
border: 1px solid #888;
width: auto;
max-width: 600px;
box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
border-radius: 4px;
position: relative;
}
/* Native form override to force display in modal */
#postForm {
display: table !important;
margin: 0 auto;
}
/* Ultra Compact View Styles */
body.is-index-view.is-compact-view .board .thread {
min-height: 52px;
padding: 0;
margin-bottom: -1px !important;
border-radius: 0;
border-left: none;
border-right: none;
align-items: center;
}
body.is-index-view.is-compact-view .postContainer.opContainer,
body.is-index-view.is-compact-view .opContainer .post.op {
align-items: center;
}
/* Vote Column */
.reddit-vote-box {
display: none;
width: 40px;
text-align: center;
flex-direction: column;
align-items: center;
color: #c6c6c6;
font-weight: bold;
font-family: arial, verdana, sans-serif;
margin-right: 8px;
padding-top: 4px;
}
body.is-index-view.is-compact-view .reddit-vote-box {
display: flex;
}
.vote-arrow { color: #c6c6c6; font-size: 14px; }
.vote-count { font-size: 11px; margin: 2px 0; color: #898989; }
/* Thumb */
body.is-index-view.is-compact-view .fileThumb {
width: 50px !important;
height: 50px !important;
margin: 0 8px 0 0 !important;
display: flex !important;
align-items: center;
justify-content: center;
}
body.is-index-view.is-compact-view .fileThumb img {
width: 50px !important;
height: 50px !important;
max-width: 50px !important;
max-height: 50px !important;
object-fit: cover !important;
border-radius: 3px;
}
/* Typography */
body.is-index-view.is-compact-view .subject-link {
font-size: 15px;
font-weight: normal;
margin-bottom: 0px;
display: inline;
}
body.is-index-view.is-compact-view .subject-link:visited { color: #551a8b; }
/* Footer/Meta */
body.is-index-view.is-compact-view .reddit-footer {
font-size: 10px;
margin-top: 1px;
color: #888;
}
body.is-index-view.is-compact-view .reddit-content-wrapper {
padding: 4px 0;
justify-content: center;
}
/* Text Size Variants */
body.is-compact-view.text-size-s .subject-link { font-size: 13px !important; }
body.is-compact-view.text-size-s .reddit-footer { font-size: 9px !important; }
body.is-compact-view.text-size-m .subject-link { font-size: 15px !important; } /* Default */
body.is-compact-view.text-size-m .reddit-footer { font-size: 10px !important; }
body.is-compact-view.text-size-l .subject-link { font-size: 17px !important; }
body.is-compact-view.text-size-l .reddit-footer { font-size: 11px !important; }
`;
document.head.appendChild(style);
// Sidebar Injection
const sidebar = document.createElement('div');
sidebar.id = 'reddit-sidebar';
sidebar.innerHTML = `
Submit a new Link
Submit a new Text Post
Technology
Use the catalog.
3,542 readers
~155 users here now
`;
if (!document.getElementById('reddit-sidebar')) {
document.body.appendChild(sidebar);
// Bind Modal Events
document.getElementById('submit-link-btn').addEventListener('click', (e) => {
e.preventDefault();
openPostModal();
});
document.getElementById('submit-text-btn').addEventListener('click', (e) => {
e.preventDefault();
openPostModal();
});
// View Toggle Events
const cardBtn = document.getElementById('view-card');
const compactBtn = document.getElementById('view-compact');
cardBtn.addEventListener('click', (e) => { e.preventDefault(); setViewMode('card'); });
compactBtn.addEventListener('click', (e) => { e.preventDefault(); setViewMode('compact'); });
updateViewToggleUI(localStorage.getItem('4chan_view_mode') || 'card');
// Text Size Events
const btnS = document.getElementById('text-s');
const btnM = document.getElementById('text-m');
const btnL = document.getElementById('text-l');
btnS.addEventListener('click', (e) => { e.preventDefault(); setTextSize('s'); });
btnM.addEventListener('click', (e) => { e.preventDefault(); setTextSize('m'); });
btnL.addEventListener('click', (e) => { e.preventDefault(); setTextSize('l'); });
setTextSize(localStorage.getItem('4chan_text_size') || 'm');
}
// Modal Injection
if (!document.getElementById('reddit-post-modal')) {
const modal = document.createElement('div');
modal.id = 'reddit-post-modal';
modal.innerHTML = '';
document.body.appendChild(modal);
// Close on outside click
modal.addEventListener('click', (e) => {
if (e.target === modal) closePostModal();
});
}
// View Mode Logic
function setViewMode(mode) {
localStorage.setItem('4chan_view_mode', mode);
updateViewToggleUI(mode);
if (mode === 'compact') {
document.body.classList.add('is-compact-view');
} else {
document.body.classList.remove('is-compact-view');
}
}
function updateViewToggleUI(mode) {
const cardBtn = document.getElementById('view-card');
const compactBtn = document.getElementById('view-compact');
if (!cardBtn || !compactBtn) return;
if (mode === 'compact') {
compactBtn.style.fontWeight = 'bold';
compactBtn.style.color = '#333';
cardBtn.style.fontWeight = 'normal';
cardBtn.style.color = '#0055DF';
} else {
cardBtn.style.fontWeight = 'bold';
cardBtn.style.color = '#333';
compactBtn.style.fontWeight = 'normal';
compactBtn.style.color = '#0055DF';
}
}
function setTextSize(size) {
localStorage.setItem('4chan_text_size', size);
document.body.classList.remove('text-size-s', 'text-size-m', 'text-size-l');
document.body.classList.add(`text-size-${size}`);
const btnS = document.getElementById('text-s');
const btnM = document.getElementById('text-m');
const btnL = document.getElementById('text-l');
if (!btnS || !btnM || !btnL) return;
[btnS, btnM, btnL].forEach(btn => {
btn.style.fontWeight = 'normal';
btn.style.color = '#0055DF';
});
const activeBtn = size === 's' ? btnS : (size === 'l' ? btnL : btnM);
activeBtn.style.fontWeight = 'bold';
activeBtn.style.color = '#333';
}
// Modal Logic
let originalParent = null;
let originalNextSibling = null;
function openPostModal() {
const modal = document.getElementById('reddit-post-modal');
const content = document.getElementById('reddit-post-content');
// Try to find the form. Native is name="post", 4chan X might differ but usually same structure.
// #postForm is the table ID in native.
let form = document.querySelector('form[name="post"]');
if (!form) form = document.getElementById('postForm');
if (!form || !modal || !content) {
console.error("Form or Modal not found");
return;
}
// Save location if not already moved
if (form.parentNode !== content) {
originalParent = form.parentNode;
originalNextSibling = form.nextSibling;
content.appendChild(form);
}
modal.style.display = 'flex';
// Force display
form.style.display = 'block';
if (form.id === 'postForm') form.style.display = 'table'; // Native uses table
// Hide native toggles if any
const toggleLink = document.getElementById('togglePostFormLink');
if (toggleLink) toggleLink.style.display = 'none';
}
function closePostModal() {
const modal = document.getElementById('reddit-post-modal');
let form = document.querySelector('form[name="post"]');
if (!form) form = document.getElementById('postForm');
if (form && originalParent) {
originalParent.insertBefore(form, originalNextSibling);
}
if (modal) modal.style.display = 'none';
// Restore toggle link potentially?
const toggleLink = document.getElementById('togglePostFormLink');
if (toggleLink) toggleLink.style.display = 'block';
}
// Helper to get post ID
function getPostId(postContainer) {
const idAttr = postContainer.id;
return idAttr ? idAttr.replace('pc', '') : null;
}
// --- DOM PROCESSORS ---
function processIndexThread(thread) {
const op = thread.querySelector('.opContainer');
if(!op) return;
const post = op.querySelector('.post.op');
if (!post) return;
let contentWrapper = post.querySelector('.reddit-content-wrapper');
// Do NOT return early. Always refresh content to fix blank/stale titles.
const postInfo = post.querySelector('.postInfo');
const subject = post.querySelector('.subject');
const message = post.querySelector('.postMessage');
const fileText = post.querySelector('.fileText');
const fileLink = fileText ? fileText.querySelector('a') : null;
// 1. Ensure Subject (Priority: Subject > Message > Filename > Default)
let titleText = subject ? (subject.textContent || "").trim() : "";
if (!titleText) {
if (message) {
const rawText = message.textContent || "";
if (rawText.trim()) {
titleText = rawText.trim().split('\n')[0].substring(0, 100);
if (rawText.length > 100) titleText += "...";
}
}
if (!titleText && fileLink) {
titleText = fileLink.textContent || ""; // Use filename if nothing else
}
if (!titleText) titleText = "Untitled Thread";
}
// 2. Wrapper (Create if missing)
if (!contentWrapper) {
contentWrapper = document.createElement('div');
contentWrapper.className = 'reddit-content-wrapper';
// Adjusted styles to prevent clipping
contentWrapper.style.cssText = 'display: flex; flex-direction: column; justify-content: center; margin-left: 10px; flex: 1; overflow: hidden; padding-top: 2px; padding-bottom: 2px;';
post.appendChild(contentWrapper);
}
// Vote Box Injection
if (!thread.querySelector('.reddit-vote-box')) {
const voteBox = document.createElement('div');
voteBox.className = 'reddit-vote-box';
// Random score for aesthetic
const score = Math.floor(Math.random() * 500) + 20;
voteBox.innerHTML = `
▲
${score}
▼
`;
// Insert before the OP post container or inside thread, needs to be flex child 1
// Structure is Thread -> OP Container -> Post
// We want Vote -> Thumb -> Content
// Since Thumb/Content are in Post, we should probably stick Vote inside Post at start
post.insertBefore(voteBox, post.firstChild);
}
// 3. Subject Link (Update or Create)
const postNum = op.querySelector('.postNum a');
const href = postNum ? postNum.getAttribute('href') : '#';
let link = contentWrapper.querySelector('.subject-link');
if (!link) {
link = document.createElement('a');
link.className = 'subject-link';
// Insert at top
contentWrapper.insertBefore(link, contentWrapper.firstChild);
}
// Always update text and href
link.href = href;
link.textContent = titleText;
// 4. Move Meta (Post Info)
if (postInfo && postInfo.parentNode !== contentWrapper) {
contentWrapper.appendChild(postInfo);
postInfo.style.cssText = 'display: block; font-size: 10px; margin-top: 2px;';
}
// 5. METADATA EXTRACTION
// Author
const nameBlock = post.querySelector('.nameBlock');
const authorName = nameBlock ? nameBlock.textContent.trim().replace(/\s+/g, ' ') : 'Anonymous';
// Time
const dateTime = post.querySelector('.dateTime');
let timeAgoStr = 'sometime ago';
if (dateTime) {
let timestamp = 0;
if (dateTime.dataset.utc) {
timestamp = parseInt(dateTime.dataset.utc, 10) * 1000;
}
if (timestamp) timeAgoStr = timeAgo(timestamp);
}
// Replies (Total = Visible + Omitted)
let totalReplies = 0;
const visibleReplies = thread.querySelectorAll('.replyContainer').length;
totalReplies += visibleReplies;
const summary = thread.querySelector('.summary');
if (summary) {
const match = summary.textContent.match(/(\d+)\s+posts?/);
if (match && match[1]) {
totalReplies += parseInt(match[1], 10);
}
}
const commentLabel = totalReplies === 1 ? 'comment' : 'comments';
// 6. Comments Link & Footer (Update or Create)
let footer = contentWrapper.querySelector('.reddit-footer');
if (!footer) {
footer = document.createElement('div');
footer.className = 'reddit-footer';
contentWrapper.appendChild(footer);
}
// Reddit Style Footer
footer.innerHTML = `
submitted ${timeAgoStr} by ${authorName}
${totalReplies} ${commentLabel}
|
Share
|
Save
`;
}
function timeAgo(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
let interval = seconds / 31536000;
if (interval > 1) return Math.floor(interval) + " years ago";
interval = seconds / 2592000;
if (interval > 1) return Math.floor(interval) + " months ago";
interval = seconds / 86400;
if (interval > 1) return Math.floor(interval) + " days ago";
interval = seconds / 3600;
if (interval > 1) return Math.floor(interval) + " hours ago";
interval = seconds / 60;
if (interval > 1) return Math.floor(interval) + " minutes ago";
return Math.floor(seconds) + " seconds ago";
}
function processThreadView() {
// --- 4chan X CONFLICT FIX ---
// 4chan X injects .inline and .clone elements for previews. We must ignore them.
// We also need strict idempotency (data-reddit-processed) to prevent recursion loops if X updates the thread.
const postContainers = Array.from(document.querySelectorAll('.postContainer'));
const opContainer = document.querySelector('.opContainer');
const opId = opContainer ? getPostId(opContainer) : null;
// Build map first (only valid, main posts)
const postMap = {};
postContainers.forEach(pc => {
// Ignore 4chan X clones/inlines for the MAP source
if (pc.closest('.inline') || pc.classList.contains('inline') || pc.closest('.clone')) return;
const id = getPostId(pc);
if(id) postMap[id] = pc;
});
postContainers.forEach(pc => {
// 1. IGNORE CHECKS
// Already processed?
if (pc.dataset.redditProcessed === 'true') return;
// Is it a 4chan X artifact?
if (pc.closest('.inline') || pc.classList.contains('inline') || pc.closest('.clone')) return;
const id = getPostId(pc);
if(!id) return;
const post = pc.querySelector('.post');
if(!post) return;
// Mark as processed immediately to prevent loops
pc.dataset.redditProcessed = 'true';
// OP Highlight (Check if already done)
const nameBlock = post.querySelector('.nameBlock');
if (opId && id === opId && nameBlock && !nameBlock.classList.contains('op-user')) {
nameBlock.classList.add('op-user');
}
// Replies container
if (!pc.querySelector('.replies-container')) {
const rc = document.createElement('div');
rc.className = 'replies-container';
pc.appendChild(rc);
}
// Collapse btn
const postInfo = post.querySelector('.postInfo');
if (postInfo && !postInfo.querySelector('.collapse-btn')) {
const btn = document.createElement('span');
btn.className = 'collapse-btn';
btn.textContent = '[-]';
btn.onclick = (e) => {
e.stopPropagation();
post.classList.toggle('collapsed');
btn.textContent = post.classList.contains('collapsed') ? '[+]' : '[-]';
};
postInfo.insertBefore(btn, postInfo.firstChild);
}
// Threading (Nesting)
// Only do this if not OP
if (pc.classList.contains('opContainer')) return;
// STRICT CHECK: If already nested, don't move it again.
// This prevents the infinite loop if 4chan X clones the post and triggers a mutation which we then process again.
if (pc.parentNode.classList.contains('replies-container')) return;
const message = pc.querySelector('.postMessage') || pc.querySelector('.message');
if (!message) return;
// Find parent
const quotes = message.querySelectorAll('.quotelink');
let parentId = null;
for (let quote of quotes) {
const href = quote.getAttribute('href');
if (href && href.startsWith('#p')) {
const quotedId = href.replace('#p', '');
if (quotedId !== opId && postMap[quotedId] && quotedId !== id) {
parentId = quotedId; break;
}
}
}
if (parentId) {
const parentPc = postMap[parentId];
// Ensure we don't nest into ourselves or invalid parents
if (parentPc && !parentPc.contains(pc)) {
const parentRc = parentPc.querySelector('.replies-container');
if (parentRc) {
parentRc.appendChild(pc); // MOVE the element
}
}
}
});
}
// --- SIDEBAR LOGIC ---
function renderSidebarWidget(topThreads) {
const sidebar = document.getElementById('reddit-sidebar');
if (!sidebar) return;
let widget = document.getElementById('top-threads-widget');
if (!widget) {
widget = document.createElement('div');
widget.id = 'top-threads-widget';
widget.style.marginTop = '20px';
widget.style.borderTop = '1px solid #e0e0e0';
widget.style.paddingTop = '10px';
const subInfo = sidebar.querySelector('.sub-info');
if (subInfo) {
subInfo.parentNode.insertBefore(widget, subInfo.nextSibling);
} else {
sidebar.appendChild(widget);
}
}
let html = 'Top Threads
';
if (!topThreads || topThreads.length === 0) {
html += 'No threads found.
';
} else {
topThreads.forEach(t => {
let displayTitle = t.title;
if (displayTitle.length > 40) displayTitle = displayTitle.substring(0, 40) + '...';
html += `
`;
});
}
widget.innerHTML = html;
}
function updateSidebar() {
if (currentMode !== 'index') return;
const threads = Array.from(document.querySelectorAll('.board .thread'));
const threadData = [];
threads.forEach(thread => {
// 1. Get Title & Link
let title = "Untitled";
let href = "#";
const wrapper = thread.querySelector('.reddit-content-wrapper');
if (wrapper) {
const link = wrapper.querySelector('.subject-link');
if (link) {
title = link.textContent;
href = link.getAttribute('href');
}
} else {
// Fallback
const op = thread.querySelector('.opContainer');
const postNum = op ? op.querySelector('.postNum a') : null;
if (postNum) href = postNum.getAttribute('href');
}
// 2. Calculate Replies
let replyCount = 0;
const visibleReplies = thread.querySelectorAll('.replyContainer').length;
replyCount += visibleReplies;
const summary = thread.querySelector('.summary');
if (summary) {
const match = summary.textContent.match(/(\d+)\s+posts?/);
if (match && match[1]) {
replyCount += parseInt(match[1], 10);
}
}
threadData.push({ title, href, replyCount });
});
// 3. Sort & Slice (Top 20)
threadData.sort((a, b) => b.replyCount - a.replyCount);
const topThreads = threadData.slice(0, 20);
// 4. Save to Cache
try {
sessionStorage.setItem('4chan_top_threads', JSON.stringify(topThreads));
} catch(e) { console.error('Storage failed', e); }
// 5. Render
renderSidebarWidget(topThreads);
}
function restoreSidebar() {
// Only run if we are NOT in index mode (or if we want to force load)
// But typically we reuse the sidebar if it exists?
// 4chan reloads page on navigation, so sidebar is recreated.
// We need to fetch data.
try {
const data = sessionStorage.getItem('4chan_top_threads');
if (data) {
const topThreads = JSON.parse(data);
renderSidebarWidget(topThreads);
}
} catch(e) { console.error('Restore failed', e); }
}
function runMainProcessor() {
if (currentMode === 'index') {
const threads = document.querySelectorAll('.board .thread');
threads.forEach(processIndexThread);
updateSidebar();
} else if (currentMode === 'thread') {
processThreadView();
restoreSidebar(); // Restore sidebar from cache in thread view
}
}
// --- INIT & OBSERVER ---
// Initial run
updateMode();
// Initialize View Mode
setViewMode(localStorage.getItem('4chan_view_mode') || 'card');
// Continuous Observation
// Continuous Observation
const observer = new MutationObserver((mutations) => {
let needsUpdate = false;
for(let m of mutations) {
// 1. New nodes added
if (m.addedNodes.length > 0) {
for (const node of m.addedNodes) {
if (node.nodeType === 1 && (node.classList.contains('thread') || node.classList.contains('postContainer'))) {
needsUpdate = true;
break;
}
if (node.nodeType === 1 && node.querySelector && (node.querySelector('.thread') || node.querySelector('.postContainer'))) {
needsUpdate = true;
break;
}
// Check for subject injection (span.subject)
if (node.nodeType === 1 && node.classList.contains('subject')) {
needsUpdate = true; // Subject added later
break;
}
}
}
// 2. Character data changed (text updates) OR ChildList changed inside a thread
// This filters relevant changes inside a thread but outside our wrapper.
if (!needsUpdate && (m.type === 'characterData' || m.type === 'childList')) {
let parent = m.target.parentElement;
let insideThread = false;
let insideWrapper = false;
while (parent && parent !== document.body) {
if (parent.classList && parent.classList.contains('reddit-content-wrapper')) {
insideWrapper = true;
break;
}
if (parent.classList && (parent.classList.contains('thread') || parent.classList.contains('postContainer'))) {
insideThread = true;
// Don't break immediately, keep checking if we are inside wrapper too?
// No, traversal is bottom-up. If we hit wrapper first, we break.
// If we hit thread first (without hitting wrapper), then we are safe.
break;
}
parent = parent.parentElement;
}
if (insideThread && !insideWrapper) {
needsUpdate = true;
}
}
if (needsUpdate) break;
}
if (needsUpdate) {
updateMode();
runMainProcessor();
}
});
// observe characterData too for text changes
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
console.log("Reddit-Chan v2 Loaded (SPA + 4chanX Support)");
})();