// ==UserScript== // @name Vichan Thread Hider + Automatic Image Hashban // @namespace local.vichan.hider // @version 5.0.0 // @description Hide threads, hashban images, import/export bans and optionally use similarity hashing on vichan-style imageboards. // @match *://*/*/catalog.html // @match *://*/catalog.html // @match *://*/*/res/*.html // @match *://*/res/*.html // @match *://*/*/thread/*.html // @match *://*/thread/*.html // @match *://*/mod.php* // @run-at document-idle // @grant GM_xmlhttpRequest // @connect * // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'vichan_hider_v5'; /* * ------------------------------------------------------------ * CONFIGURATION * ------------------------------------------------------------ */ const DEFAULT_SETTINGS = { guiHidden: false, panelOpacity: 1, aggressiveHashing: false }; /* * The vichan icon supplied by the user. * * If a particular board blocks external images via CSP, * the panel will simply fall back to the small "v" text. */ const VICHAN_ICON = 'https://comfy.guide/icons/vichan.png'; const GUI_TOGGLE_SHORTCUT = 'Ctrl+Alt+H'; /* * ------------------------------------------------------------ * STORAGE * ------------------------------------------------------------ */ function getStorage() { try { const data = JSON.parse( localStorage.getItem( STORAGE_KEY ) ); return { hiddenThreads: Array.isArray( data?.hiddenThreads ) ? data.hiddenThreads : [], hashbans: Array.isArray( data?.hashbans ) ? data.hashbans : [], similarityBans: Array.isArray( data?.similarityBans ) ? data.similarityBans : [], hashCache: data?.hashCache && typeof data.hashCache === 'object' ? data.hashCache : {}, similarityCache: data?.similarityCache && typeof data.similarityCache === 'object' ? data.similarityCache : {}, settings: { ...DEFAULT_SETTINGS, ...(data?.settings || {}), panelOpacity: typeof data?.settings?.panelOpacity === 'number' ? Math.max( 0.25, Math.min( 1, data.settings.panelOpacity ) ) : 1 } }; } catch { return { hiddenThreads: [], hashbans: [], similarityBans: [], hashCache: {}, similarityCache: {}, settings: { ...DEFAULT_SETTINGS } }; } } function saveStorage(data) { localStorage.setItem( STORAGE_KEY, JSON.stringify(data) ); } /* * ------------------------------------------------------------ * BOARD / URL * ------------------------------------------------------------ */ function getEffectivePath() { if ( /\/mod\.php$/i.test(location.pathname) && location.search.startsWith('?/') ) { return location.search.slice(1); } return location.pathname; } function getBoard() { if (document.body.dataset.board) { return document.body.dataset.board; } if ( typeof window.board_name !== 'undefined' ) { return String( window.board_name ); } const match = getEffectivePath().match( /^\/([^/]+)\/(?:catalog\.html|res\/|thread\/)/ ); return match ? match[1] : location.hostname; } function getBoardRoot() { const match = getEffectivePath().match( /^\/([^/]+)\// ); if (!match) { return '/'; } return '/' + match[1] + '/'; } function isCatalog() { return /\/catalog\.html$/i.test( getEffectivePath() ); } function isThread() { return /\/(?:res|thread)\/\d+\.html$/i.test( getEffectivePath() ); } function getCurrentThreadId() { const match = getEffectivePath().match( /\/(?:res|thread)\/(\d+)\.html$/i ); return match ? match[1] : null; } /* * ------------------------------------------------------------ * MD5 * ------------------------------------------------------------ */ function md5(buffer) { const bytes = new Uint8Array(buffer); function leftRotate( x, amount ) { return ( (x << amount) | (x >>> (32 - amount)) ) >>> 0; } function add32(a, b) { return ( a + b ) >>> 0; } const originalLength = bytes.length; const bitLength = originalLength * 8; const paddedLength = ((originalLength + 8) >> 6 << 6) + 64; const message = new Uint8Array( paddedLength ); message.set(bytes); message[originalLength] = 0x80; const view = new DataView( message.buffer ); view.setUint32( paddedLength - 8, bitLength >>> 0, true ); view.setUint32( paddedLength - 4, Math.floor( bitLength / 0x100000000 ), true ); let a0 = 0x67452301; let b0 = 0xefcdab89; let c0 = 0x98badcfe; let d0 = 0x10325476; const s = [ 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21 ]; const K = []; for ( let i = 0; i < 64; i++ ) { K[i] = Math.floor( Math.abs( Math.sin(i + 1) ) * 0x100000000 ) >>> 0; } for ( let offset = 0; offset < paddedLength; offset += 64 ) { const M = new Uint32Array(16); for ( let i = 0; i < 16; i++ ) { M[i] = view.getUint32( offset + i * 4, true ); } let A = a0; let B = b0; let C = c0; let D = d0; for ( let i = 0; i < 64; i++ ) { let F; let g; if (i < 16) { F = (B & C) | (~B & D); g = i; } else if (i < 32) { F = (D & B) | (~D & C); g = (5 * i + 1) % 16; } else if (i < 48) { F = B ^ C ^ D; g = (3 * i + 5) % 16; } else { F = C ^ (B | ~D); g = (7 * i) % 16; } const temp = D; D = C; C = B; const sum = add32( add32( add32( A, F ), K[i] ), M[g] ); B = add32( B, leftRotate( sum, s[i] ) ); A = temp; } a0 = add32( a0, A ); b0 = add32( b0, B ); c0 = add32( c0, C ); d0 = add32( d0, D ); } const output = new Uint8Array(16); const result = new DataView( output.buffer ); result.setUint32( 0, a0, true ); result.setUint32( 4, b0, true ); result.setUint32( 8, c0, true ); result.setUint32( 12, d0, true ); return output; } function md5Hex(buffer) { return Array .from(md5(buffer)) .map( byte => byte .toString(16) .padStart( 2, '0' ) ) .join(''); } function md5PackedBase64(buffer) { const hash = md5(buffer); let binary = ''; for ( const byte of hash ) { binary += String.fromCharCode( byte ); } return btoa(binary); } /* * ------------------------------------------------------------ * IMAGE / POST DETECTION * ------------------------------------------------------------ */ function getImageContainer(image) { if (!image) { return null; } let known; if (isCatalog()) { known = image.closest('.mix') || image.closest('.catalog-item') || image.closest('.thread') || image.closest('[data-id]'); } else { known = image.closest('.post') || image.closest('.reply') || image.closest('.postContainer') || image.closest('[data-post-id]'); } if (known) { return known; } return findGenericContainer( image ); } function findGenericContainer(image) { let node = image.parentElement; let hops = 0; while ( node && hops < 6 ) { if ( extractPostNumber( node ) ) { return node; } node = node.parentElement; hops++; } return ( image.closest('article') || image.parentElement?.parentElement || image.parentElement || image ); } function extractPostNumber( container ) { if (!container) { return null; } if (container.dataset.id) { return container.dataset.id; } for ( const attr of [ 'data-thread', 'data-thread-id', 'data-post', 'data-post-id' ] ) { const value = container.getAttribute( attr ); if ( value && /^\d+$/.test(value) ) { return value; } } const image = container.querySelector( 'img[id^="img-"]' ); if (image) { const match = image.id.match( /^img-(\d+)$/ ); if (match) { return match[1]; } } for ( const link of container.querySelectorAll( 'a[href]' ) ) { const match = link.href.match( /\/(?:res|thread)\/(\d+)(?:\.html)?/ ); if (match) { return match[1]; } } if (container.id) { const match = container.id.match( /(\d+)$/ ); if (match) { return match[1]; } } return null; } function getThreadId(element) { if (isThread()) { return getCurrentThreadId(); } return extractPostNumber( getImageContainer( element ) ); } function getPostNumber(element) { return extractPostNumber( getImageContainer( element ) ); } /* * ------------------------------------------------------------ * FULL IMAGE URL * ------------------------------------------------------------ */ function getFullImageURL(image) { if (!image) { return null; } const link = image.closest( 'a[href]' ); if ( link && link.href && !/\/(?:res|thread)\/\d+\.html/i.test( link.href ) ) { return link.href; } for ( const attr of [ 'data-original', 'data-full', 'data-full-image', 'data-src' ] ) { const value = image.getAttribute( attr ); if (value) { return new URL( value, location.href ).href; } } if ( image.src && !/thumb/i.test( image.src ) ) { return image.src; } return image.src || null; } /* * ------------------------------------------------------------ * NETWORK * ------------------------------------------------------------ */ async function gmFetch(url) { try { const response = await fetch( url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } } ); if (!response.ok) { throw new Error( 'HTTP ' + response.status ); } return await response.text(); } catch (error) { if ( typeof GM_xmlhttpRequest === 'undefined' ) { throw error; } return gmXhrFetchText( url ); } } function gmXhrFetchText(url) { return new Promise( ( resolve, reject ) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'text', onload(response) { if ( response.status >= 200 && response.status < 300 ) { resolve( response.responseText ); } else { reject( new Error( 'HTTP ' + response.status ) ); } }, onerror() { reject( new Error( 'Network error' ) ); } }); } ); } async function gmFetchBuffer( url ) { try { const response = await fetch( url, { credentials: 'same-origin' } ); if (!response.ok) { throw new Error( 'Image download failed: HTTP ' + response.status ); } return await response.arrayBuffer(); } catch (error) { if ( typeof GM_xmlhttpRequest === 'undefined' ) { throw error; } return gmXhrFetchBuffer( url ); } } function gmXhrFetchBuffer(url) { return new Promise( ( resolve, reject ) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', onload(response) { if ( response.status >= 200 && response.status < 300 && response.response ) { resolve( response.response ); } else { reject( new Error( 'Image download failed: HTTP ' + response.status ) ); } }, onerror() { reject( new Error( 'Image download failed' ) ); } }); } ); } function downloadImage(url) { return gmFetchBuffer(url); } /* * ------------------------------------------------------------ * VICHAN API * ------------------------------------------------------------ */ async function fetchThreadJSON( threadId ) { const root = location.origin + getBoardRoot(); const urls = [ root + 'res/' + threadId + '.json', root + 'thread/' + threadId + '.json' ]; let lastError = null; for ( const url of urls ) { try { const text = await gmFetch( url ); const data = JSON.parse( text ); return data; } catch (error) { lastError = error; } } throw ( lastError || new Error( 'Could not fetch thread JSON.' ) ); } async function fetchCatalogJSON() { const root = location.origin + getBoardRoot(); const url = root + 'catalog.json'; const text = await gmFetch( url ); const data = JSON.parse( text ); if ( Array.isArray(data) ) { return data; } if ( data && Array.isArray( data.threads ) ) { return [ { threads: data.threads } ]; } return []; } async function resolveAPIPost( image ) { const postNumber = getPostNumber( image ); if (!postNumber) { return null; } const threadIdForFetch = isThread() ? getCurrentThreadId() : postNumber; if (!threadIdForFetch) { return null; } const threadData = await fetchThreadJSON( threadIdForFetch ); const posts = threadData.posts || []; return ( posts.find( post => String( post.no ) === String( postNumber ) ) || null ); } /* * ------------------------------------------------------------ * EXACT HASH * ------------------------------------------------------------ */ async function calculateImageHash( image ) { const url = getFullImageURL( image ); if (!url) { throw new Error( 'Could not determine full image URL.' ); } const data = getStorage(); if ( data.hashCache[url] ) { return data.hashCache[url]; } const buffer = await downloadImage( url ); const result = { hex: md5Hex( buffer ), packed: md5PackedBase64( buffer ) }; data.hashCache[url] = result; saveStorage( data ); return result; } async function getCanonicalHash( image ) { try { const post = await resolveAPIPost( image ); if ( post && post.md5 ) { return { hex: null, packed: post.md5, source: 'api' }; } if ( post && post.tim && post.ext ) { const root = location.origin + getBoardRoot(); const url = root + 'src/' + post.tim + post.ext; const buffer = await downloadImage( url ); return { hex: md5Hex( buffer ), packed: md5PackedBase64( buffer ), source: 'api-src' }; } } catch (error) { console.warn( '[Vichan Hider] API hash lookup failed:', error.message ); } try { const local = await calculateImageHash( image ); return { hex: local.hex, packed: local.packed, source: 'dom-fallback' }; } catch { return null; } } /* * ------------------------------------------------------------ * SIMILARITY HASH * * This is deliberately separate from MD5. * * It creates a 64-bit dHash from a small grayscale * representation of the image. * * Similar images can therefore have similar hashes even * after resizing/recompression. * ------------------------------------------------------------ */ async function calculateSimilarityHash( image ) { const url = getFullImageURL( image ); if (!url) { throw new Error( 'Could not determine full image URL.' ); } const data = getStorage(); if ( data.similarityCache[url] ) { return data.similarityCache[ url ]; } const buffer = await downloadImage( url ); const blob = new Blob( [buffer] ); const bitmap = await createImageBitmap( blob ); const canvas = document.createElement( 'canvas' ); canvas.width = 9; canvas.height = 8; const ctx = canvas.getContext( '2d', { willReadFrequently: true } ); ctx.drawImage( bitmap, 0, 0, 9, 8 ); bitmap.close(); const pixels = ctx.getImageData( 0, 0, 9, 8 ).data; let hash = ''; for ( let y = 0; y < 8; y++ ) { for ( let x = 0; x < 8; x++ ) { const left = getPixelGray( pixels, x, y ); const right = getPixelGray( pixels, x + 1, y ); hash += left > right ? '1' : '0'; } } const result = hashToHex( hash ); data.similarityCache[ url ] = result; saveStorage( data ); return result; } function getPixelGray( pixels, x, y ) { const i = ( y * 9 + x ) * 4; return ( pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114 ); } function hashToHex( binary ) { let output = ''; for ( let i = 0; i < binary.length; i += 4 ) { output += parseInt( binary.slice( i, i + 4 ), 2 ).toString(16); } return output; } function hammingDistance( a, b ) { if ( !a || !b || a.length !== b.length ) { return Infinity; } let distance = 0; for ( let i = 0; i < a.length; i++ ) { const x = parseInt( a[i], 16 ) ^ parseInt( b[i], 16 ); distance += bitCount( x ); } return distance; } function bitCount(value) { let count = 0; while (value) { value &= value - 1; count++; } return count; } /* * Similarity threshold. * * 0 = identical * 64 = completely different * * 10 is deliberately conservative. */ const SIMILARITY_THRESHOLD = 10; /* * ------------------------------------------------------------ * VISIBILITY * ------------------------------------------------------------ */ let hiddenRevealed = false; let hashbansRevealed = false; function hideElement( container, type ) { if (!container) { return; } container.dataset.vichanHidden = 'true'; container.dataset.vichanHiddenType = type; if ( type === 'hash' && hashbansRevealed ) { container.style.display = ''; return; } if ( type === 'normal' && hiddenRevealed ) { container.style.display = ''; return; } container.style.display = 'none'; } function showElement( container ) { if (!container) { return; } container.dataset.vichanHidden = 'false'; container.style.display = ''; } function refreshVisibility() { document .querySelectorAll( '[data-vichan-hidden="true"]' ) .forEach( container => { const type = container.dataset .vichanHiddenType; if ( type === 'hash' ) { container.style.display = hashbansRevealed ? '' : 'none'; } else { container.style.display = hiddenRevealed ? '' : 'none'; } } ); } /* * ------------------------------------------------------------ * NORMAL THREAD HIDING * ------------------------------------------------------------ */ function hideCatalogThread( container ) { const id = getThreadId( container ); if (!id) { alert( 'Could not determine the thread ID.' ); return; } const data = getStorage(); const key = getBoard() + ':' + id; if ( !data.hiddenThreads.includes( key ) ) { data.hiddenThreads.push( key ); } saveStorage( data ); hideElement( container, 'normal' ); } function hideCurrentThread() { const id = getCurrentThreadId(); if (!id) { return; } const data = getStorage(); const key = getBoard() + ':' + id; if ( !data.hiddenThreads.includes( key ) ) { data.hiddenThreads.push( key ); } saveStorage( data ); const thread = document.querySelector( '[id^="thread-"]' ) || document.querySelector( '.thread' ) || document.querySelector( '#thread' ); if (thread) { hideElement( thread, 'normal' ); } else { location.href = location.pathname.replace( /\/res\/\d+\.html$/i, '/catalog.html' ); } } function applyThreadHides() { const data = getStorage(); const board = getBoard(); if (isCatalog()) { document .querySelectorAll( '#Grid .mix,' + '#Grid .thread,' + '.mix,' + '.catalog-item' ) .forEach( container => { const id = getThreadId( container ); if (!id) { return; } const key = board + ':' + id; if ( data.hiddenThreads.includes( key ) ) { hideElement( container, 'normal' ); } } ); } if (isThread()) { const id = getCurrentThreadId(); const key = board + ':' + id; if ( data.hiddenThreads.includes( key ) ) { const thread = document.querySelector( '[id^="thread-"]' ) || document.querySelector( '.thread' ); if (thread) { hideElement( thread, 'normal' ); } } } } /* * ------------------------------------------------------------ * HASHBAN * ------------------------------------------------------------ */ async function hashbanImage( image ) { setStatus( 'Hashing...' ); try { const canonical = await getCanonicalHash( image ); if (!canonical) { throw new Error( 'No hash was obtained.' ); } const data = getStorage(); const hashes = []; if ( canonical.packed ) { hashes.push( canonical.packed ); } if ( canonical.hex ) { hashes.push( canonical.hex ); } for ( const hash of hashes ) { if ( !data.hashbans.includes( hash ) ) { data.hashbans.push( hash ); } } /* * Optional similarity hash. */ if ( data.settings .aggressiveHashing ) { try { const similarity = await calculateSimilarityHash( image ); if ( similarity && !data.similarityBans.includes( similarity ) ) { data.similarityBans.push( similarity ); } } catch (error) { console.warn( '[Vichan Hider] Similarity hash failed:', error ); } } saveStorage( data ); const container = getImageContainer( image ); if (container) { hideElement( container, 'hash' ); } await applyHashbans(); setStatus( 'Hashbanned ✓', 1400 ); } catch (error) { console.error( '[Vichan Hider] Hashing failed:', error ); setStatus( 'Hash failed', 1600 ); alert( 'Could not hash this image.\n\n' + error.message ); } } async function removeHashban( image ) { try { const canonical = await getCanonicalHash( image ); const data = getStorage(); const toRemove = new Set(); if ( canonical?.packed ) { toRemove.add( canonical.packed ); } if ( canonical?.hex ) { toRemove.add( canonical.hex ); } data.hashbans = data.hashbans.filter( value => !toRemove.has( value ) ); /* * Also remove the similarity hash if possible. */ if ( data.settings .aggressiveHashing ) { try { const similarity = await calculateSimilarityHash( image ); data.similarityBans = data.similarityBans.filter( value => value !== similarity ); } catch { // Exact hashes can still be removed. } } saveStorage( data ); location.reload(); } catch (error) { console.error( '[Vichan Hider] Could not remove hashban:', error ); alert( 'Could not calculate the image hash.' ); } } /* * ------------------------------------------------------------ * APPLY HASHBANS * ------------------------------------------------------------ */ async function applyHashbans() { const data = getStorage(); if ( !data.hashbans.length && !data.similarityBans.length ) { return; } const hashSet = new Set( data.hashbans ); try { /* * CATALOG */ if (isCatalog()) { const pages = await fetchCatalogJSON(); const md5ByPost = new Map(); for ( const page of pages ) { const threads = page.threads || []; for ( const thread of threads ) { if ( thread && thread.md5 && thread.no ) { md5ByPost.set( String( thread.no ), thread.md5 ); } } } document .querySelectorAll( '#Grid .mix,' + '#Grid .thread,' + '.mix,' + '.catalog-item' ) .forEach( container => { const id = extractPostNumber( container ); if (!id) { return; } const md5 = md5ByPost.get( String( id ) ); if ( md5 && hashSet.has( md5 ) ) { hideElement( container, 'hash' ); } } ); /* * Aggressive mode: * * Only run this when explicitly enabled. * * This downloads the actual catalog images, * so it is intentionally opt-in. */ if ( data.settings .aggressiveHashing && data.similarityBans.length ) { await applySimilarityToContainers( document.querySelectorAll( '#Grid .mix,' + '#Grid .thread,' + '.mix,' + '.catalog-item' ) ); } } /* * THREAD */ if (isThread()) { const threadId = getCurrentThreadId(); if (!threadId) { return; } const threadData = await fetchThreadJSON( threadId ); const posts = threadData.posts || []; if (!posts.length) { return; } const md5ByPost = new Map(); for ( const post of posts ) { if ( post.md5 && post.no ) { md5ByPost.set( String( post.no ), post.md5 ); } } document .querySelectorAll( '.post,' + '.reply,' + '.postContainer,' + '[data-post-id]' ) .forEach( container => { const id = extractPostNumber( container ); if (!id) { return; } const md5 = md5ByPost.get( String( id ) ); if ( md5 && hashSet.has( md5 ) ) { hideElement( container, 'hash' ); } } ); /* * Aggressive similarity matching. */ if ( data.settings .aggressiveHashing && data.similarityBans.length ) { await applySimilarityToContainers( document.querySelectorAll( '.post,' + '.reply,' + '.postContainer,' + '[data-post-id]' ) ); } } } catch (error) { console.warn( '[Vichan Hider] Could not apply hashbans:', error ); } } async function applySimilarityToContainers( containers ) { const data = getStorage(); if ( !data.similarityBans.length ) { return; } for ( const container of containers ) { const image = container.querySelector( 'img' ); if (!image) { continue; } try { const hash = await calculateSimilarityHash( image ); let matched = false; for ( const banned of data.similarityBans ) { if ( hammingDistance( hash, banned ) <= SIMILARITY_THRESHOLD ) { matched = true; break; } } if (matched) { hideElement( container, 'hash' ); } } catch (error) { console.debug( '[Vichan Hider] Similarity check skipped:', error.message ); } } } /* * ------------------------------------------------------------ * EXPORT / IMPORT * ------------------------------------------------------------ */ function exportHashbans() { const data = getStorage(); const exportData = { format: 'vichan-hider-hashbans', version: 1, exported: new Date() .toISOString(), hashbans: data.hashbans, similarityBans: data.similarityBans }; const text = JSON.stringify( exportData, null, 2 ); const blob = new Blob( [text], { type: 'application/json' } ); const url = URL.createObjectURL( blob ); const link = document.createElement( 'a' ); const date = new Date() .toISOString() .slice( 0, 10 ); link.href = url; link.download = 'vichan-hashbans-' + date + '.json'; document.body.appendChild( link ); link.click(); link.remove(); URL.revokeObjectURL( url ); setStatus( 'Exported ✓', 1200 ); } function importHashbans() { const input = document.createElement( 'input' ); input.type = 'file'; input.accept = '.json,application/json'; input.addEventListener( 'change', () => { const file = input.files?.[0]; if (!file) { return; } const reader = new FileReader(); reader.onload = () => { try { const imported = JSON.parse( reader.result ); if ( !imported || typeof imported !== 'object' ) { throw new Error( 'Invalid file.' ); } /* * Accept our format. */ const importedHashes = Array.isArray( imported.hashbans ) ? imported.hashbans : []; const importedSimilarity = Array.isArray( imported.similarityBans ) ? imported.similarityBans : []; if ( !importedHashes.length && !importedSimilarity.length ) { throw new Error( 'No hashbans were found in this file.' ); } const data = getStorage(); let added = 0; for ( const hash of importedHashes ) { if ( typeof hash !== 'string' ) { continue; } if ( !data.hashbans.includes( hash ) ) { data.hashbans.push( hash ); added++; } } for ( const hash of importedSimilarity ) { if ( typeof hash !== 'string' ) { continue; } if ( !data.similarityBans.includes( hash ) ) { data.similarityBans.push( hash ); added++; } } saveStorage( data ); setStatus( 'Imported ' + added + ' ✓', 1800 ); applyHashbans(); } catch (error) { alert( 'Could not import hashbans.\n\n' + error.message ); } }; reader.readAsText( file ); } ); input.click(); } /* * ------------------------------------------------------------ * UI HELPERS * ------------------------------------------------------------ */ function setStatus( text, timeout ) { const status = document.getElementById( 'vichan-hider-status' ); if (!status) { return; } status.textContent = text; status.style.display = text ? 'inline' : 'none'; if (timeout) { setTimeout( () => { status.textContent = ''; status.style.display = 'none'; }, timeout ); } } function makeButton( text, title ) { const button = document.createElement( 'button' ); button.textContent = text; button.title = title || ''; button.style.cssText = ` appearance: none; border: 1px solid rgba(255,255,255,.16); border-radius: 2px; padding: 4px 7px; min-height: 25px; background: rgba(255,255,255,.08); color: inherit; font: inherit; cursor: pointer; white-space: nowrap; box-sizing: border-box; `; button.addEventListener( 'mouseenter', () => { button.style.background = 'rgba(255,255,255,.16)'; } ); button.addEventListener( 'mouseleave', () => { button.style.background = 'rgba(255,255,255,.08)'; } ); return button; } /* * ------------------------------------------------------------ * SETTINGS * ------------------------------------------------------------ */ function createSettingsPanel( parent ) { const settings = document.createElement( 'div' ); settings.id = 'vichan-hider-settings'; settings.style.cssText = ` position: absolute; left: 0; bottom: calc(100% + 6px); min-width: 235px; padding: 9px; background: #181818; color: #eee; border: 1px solid rgba(255,255,255,.16); border-radius: 2px; box-shadow: 0 5px 20px rgba(0,0,0,.45); font: 12px/1.3 sans-serif; display: none; box-sizing: border-box; `; const heading = document.createElement( 'div' ); heading.textContent = 'Settings'; heading.style.cssText = ` font-weight: 600; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid rgba(255,255,255,.1); `; settings.appendChild( heading ); /* * Opacity */ const opacityRow = document.createElement( 'div' ); opacityRow.style.cssText = ` display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; margin-bottom: 9px; `; const opacityLabel = document.createElement( 'label' ); opacityLabel.textContent = 'Panel opacity'; const opacityValue = document.createElement( 'span' ); const opacitySlider = document.createElement( 'input' ); opacitySlider.type = 'range'; opacitySlider.min = '25'; opacitySlider.max = '100'; opacitySlider.step = '5'; const currentData = getStorage(); opacitySlider.value = Math.round( currentData.settings .panelOpacity * 100 ); opacityValue.textContent = opacitySlider.value + '%'; opacitySlider.style.cssText = ` width: 100%; grid-column: 1 / 3; cursor: pointer; `; opacitySlider.addEventListener( 'input', () => { const value = Number( opacitySlider.value ); opacityValue.textContent = value + '%'; const data = getStorage(); data.settings .panelOpacity = value / 100; saveStorage( data ); applyPanelOpacity(); } ); opacityRow.appendChild( opacityLabel ); opacityRow.appendChild( opacityValue ); opacityRow.appendChild( opacitySlider ); settings.appendChild( opacityRow ); /* * Aggressive hashing. */ const aggressiveRow = document.createElement( 'label' ); aggressiveRow.style.cssText = ` display: flex; align-items: flex-start; gap: 7px; cursor: pointer; margin-bottom: 9px; `; const aggressiveCheckbox = document.createElement( 'input' ); aggressiveCheckbox.type = 'checkbox'; aggressiveCheckbox.checked = currentData.settings .aggressiveHashing; aggressiveCheckbox.style.cssText = ` margin: 2px 0 0; cursor: pointer; `; const aggressiveText = document.createElement( 'span' ); aggressiveText.innerHTML = 'Similarity hashing
' + '' + 'Also detects resized/recompressed versions. ' + 'Uses more image downloads.' + ''; aggressiveRow.appendChild( aggressiveCheckbox ); aggressiveRow.appendChild( aggressiveText ); aggressiveCheckbox.addEventListener( 'change', () => { const data = getStorage(); data.settings .aggressiveHashing = aggressiveCheckbox.checked; saveStorage( data ); if ( aggressiveCheckbox.checked ) { setStatus( 'Similarity hashing on', 1200 ); } else { setStatus( 'Similarity hashing off', 1200 ); } } ); settings.appendChild( aggressiveRow ); /* * Import / export. */ const hashHeading = document.createElement( 'div' ); hashHeading.textContent = 'Hashban list'; hashHeading.style.cssText = ` font-weight: 600; margin: 4px 0 6px; `; settings.appendChild( hashHeading ); const hashButtons = document.createElement( 'div' ); hashButtons.style.cssText = ` display: flex; gap: 5px; `; const exportButton = makeButton( 'Export', 'Export your hashban list as JSON' ); const importButton = makeButton( 'Import', 'Import a previously exported hashban list' ); exportButton.addEventListener( 'click', exportHashbans ); importButton.addEventListener( 'click', importHashbans ); hashButtons.appendChild( exportButton ); hashButtons.appendChild( importButton ); settings.appendChild( hashButtons ); /* * Statistics. */ const stats = document.createElement( 'div' ); stats.id = 'vichan-hider-stats'; stats.style.cssText = ` margin-top: 8px; padding-top: 7px; border-top: 1px solid rgba(255,255,255,.1); opacity: .55; font-size: 11px; `; settings.appendChild( stats ); function updateStats() { const data = getStorage(); stats.textContent = data.hashbans.length + ' exact • ' + data.similarityBans.length + ' similarity • ' + data.hiddenThreads.length + ' hidden'; } settings.updateStats = updateStats; parent.appendChild( settings ); return settings; } function applyPanelOpacity() { const data = getStorage(); const box = document.getElementById( 'vichan-hider-controls' ); if (box) { box.style.opacity = data.settings .panelOpacity; } const tab = document.getElementById( 'vichan-hider-tab' ); if (tab) { tab.style.opacity = data.settings .panelOpacity; } } /* * ------------------------------------------------------------ * MAIN UI * ------------------------------------------------------------ */ function createControls() { if ( document.getElementById( 'vichan-hider-controls' ) ) { return; } const data = getStorage(); /* * Main panel. */ const box = document.createElement( 'div' ); box.id = 'vichan-hider-controls'; box.style.cssText = ` position: fixed; left: 12px; bottom: 32px; z-index: 999999; background: #181818; color: #eee; padding: 5px; border: 1px solid rgba(255,255,255,.16); border-radius: 2px; font: 12px/1 sans-serif; box-shadow: 0 4px 18px rgba(0,0,0,.4); display: ${data.settings.guiHidden ? 'none' : 'flex'}; align-items: center; gap: 4px; box-sizing: border-box; `; /* * Icon. */ const icon = document.createElement( 'img' ); icon.src = VICHAN_ICON; icon.alt = 'vichan'; icon.title = 'vichan hider'; icon.style.cssText = ` width: 22px; height: 22px; object-fit: contain; display: block; margin: 0 2px; `; icon.onerror = () => { icon.style.display = 'none'; }; /* * Title. */ const title = document.createElement( 'span' ); title.textContent = 'vichan hider'; title.title = 'Shift+click: hide • Ctrl+click: hashban • Alt+click: unhashban'; title.style.cssText = ` font-weight: 600; padding: 0 3px; white-space: nowrap; `; /* * Show hidden. */ const hiddenButton = makeButton( 'Show hidden', 'Show or hide manually hidden threads/posts' ); hiddenButton.id = 'vichan-hider-toggle'; hiddenButton.addEventListener( 'click', () => { hiddenRevealed = !hiddenRevealed; hiddenButton.textContent = hiddenRevealed ? 'Hide hidden' : 'Show hidden'; refreshVisibility(); } ); /* * Show hashed. */ const hashedButton = makeButton( 'Show hashed', 'Show or hide hashbanned images/posts' ); hashedButton.id = 'vichan-hider-hash-toggle'; hashedButton.addEventListener( 'click', () => { hashbansRevealed = !hashbansRevealed; hashedButton.textContent = hashbansRevealed ? 'Hide hashed' : 'Show hashed'; refreshVisibility(); } ); /* * Settings button. */ const settingsButton = makeButton( '⚙', 'Vichan Hider settings' ); settingsButton.style.cssText += ` width: 26px; padding: 3px; font-size: 14px; line-height: 1; `; /* * Settings wrapper. */ const settingsWrapper = document.createElement( 'div' ); settingsWrapper.style.cssText = ` position: relative; display: flex; align-items: center; `; const settings = createSettingsPanel( settingsWrapper ); settingsButton.addEventListener( 'click', event => { event.stopPropagation(); const visible = settings.style.display !== 'none'; settings.style.display = visible ? 'none' : 'block'; if ( settings.updateStats ) { settings.updateStats(); } } ); settingsWrapper.appendChild( settingsButton ); /* * Clear. */ const clear = makeButton( 'Clear', 'Remove all locally hidden threads and hashbans' ); clear.style.background = 'rgba(180,40,40,.15)'; clear.addEventListener( 'click', () => { if ( !confirm( 'Clear all locally hidden threads and hashbans?' ) ) { return; } localStorage.removeItem( STORAGE_KEY ); location.reload(); } ); /* * Status. */ const status = document.createElement( 'span' ); status.id = 'vichan-hider-status'; status.style.cssText = ` display: none; opacity: .7; font-size: 11px; margin-left: 2px; white-space: nowrap; `; /* * Collapse button. */ const hideGui = makeButton( '×', 'Hide panel (' + GUI_TOGGLE_SHORTCUT + ')' ); hideGui.style.cssText += ` width: 24px; padding: 3px; font-size: 16px; line-height: 1; `; hideGui.addEventListener( 'click', () => setGuiHidden( true ) ); /* * Assemble. */ box.appendChild( icon ); box.appendChild( title ); box.appendChild( hiddenButton ); box.appendChild( hashedButton ); box.appendChild( settingsWrapper ); box.appendChild( clear ); box.appendChild( status ); box.appendChild( hideGui ); /* * Restore tab. */ const tab = document.createElement( 'div' ); tab.id = 'vichan-hider-tab'; tab.textContent = 'v'; tab.title = 'Show Vichan Hider (' + GUI_TOGGLE_SHORTCUT + ')'; tab.style.cssText = ` position: fixed; left: 12px; bottom: 32px; z-index: 999999; background: #181818; color: #eee; width: 27px; height: 27px; display: ${data.settings.guiHidden ? 'flex' : 'none'}; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,.16); border-radius: 2px; cursor: pointer; font: 14px sans-serif; box-shadow: 0 4px 18px rgba(0,0,0,.4); box-sizing: border-box; `; tab.addEventListener( 'click', () => setGuiHidden( false ) ); document.body.appendChild( tab ); document.body.appendChild( box ); /* * Close settings when clicking elsewhere. */ document.addEventListener( 'click', event => { if ( !settingsWrapper.contains( event.target ) ) { settings.style.display = 'none'; } } ); applyPanelOpacity(); } /* * ------------------------------------------------------------ * GUI HIDE / SHOW * ------------------------------------------------------------ */ function setGuiHidden( hidden ) { const data = getStorage(); data.settings.guiHidden = hidden; saveStorage( data ); const box = document.getElementById( 'vichan-hider-controls' ); const tab = document.getElementById( 'vichan-hider-tab' ); if (box) { box.style.display = hidden ? 'none' : 'flex'; } if (tab) { tab.style.display = hidden ? 'flex' : 'none'; } } /* * ------------------------------------------------------------ * KEYBOARD SHORTCUT * ------------------------------------------------------------ */ document.addEventListener( 'keydown', function (event) { if ( event.ctrlKey && event.altKey && event.key.toLowerCase() === 'h' ) { event.preventDefault(); const data = getStorage(); setGuiHidden( !data.settings .guiHidden ); } } ); /* * ------------------------------------------------------------ * CLICK HANDLER * ------------------------------------------------------------ */ document.addEventListener( 'click', function (event) { const image = event.target.closest( 'img' ); if (!image) { return; } if ( image.closest( '#vichan-hider-controls' ) ) { return; } /* * SHIFT = normal hide */ if ( event.shiftKey ) { event.preventDefault(); event.stopPropagation(); if ( isCatalog() ) { const container = getImageContainer( image ); if (container) { hideCatalogThread( container ); } } if ( isThread() ) { hideCurrentThread(); } return; } /* * CTRL / CMD = hashban */ if ( event.ctrlKey || event.metaKey ) { event.preventDefault(); event.stopPropagation(); hashbanImage( image ); return; } /* * ALT = remove hashban */ if ( event.altKey ) { event.preventDefault(); event.stopPropagation(); removeHashban( image ); return; } } ); /* * ------------------------------------------------------------ * LIVE THREAD UPDATES * ------------------------------------------------------------ */ function observeThreadUpdates() { const target = document.querySelector( 'form[name="postform"]' ) || document.body; let timer = null; const observer = new MutationObserver( () => { clearTimeout( timer ); timer = setTimeout( () => { applyHashbans(); }, 800 ); } ); observer.observe( target, { childList: true, subtree: true } ); } /* * ------------------------------------------------------------ * INIT * ------------------------------------------------------------ */ function init() { console.log( '[Vichan Hider] v5.0.0 loaded on', location.href ); createControls(); applyThreadHides(); applyHashbans(); if ( isThread() ) { observeThreadUpdates(); } } if ( document.readyState === 'complete' || document.readyState === 'interactive' ) { init(); } else { document.addEventListener( 'DOMContentLoaded', init ); } /* * ------------------------------------------------------------ * DEBUG * ------------------------------------------------------------ */ window.__vichanHiderDebug = { getStorage, fetchThreadJSON, fetchCatalogJSON, applyHashbans, applyThreadHides, calculateSimilarityHash, extractPostNumber }; })();