// ==UserScript== // @name VRChat: Hide Worlds // @namespace https://github.com/ceeprus/userscript // @version 1.10 // @license MIT // @icon https://www.google.com/s2/favicons?sz=64&domain=vrchat.com // @description Hides worlds you never want to see again from the VRChat website. Marks hidden worlds in the lists and on the world page, with an eye toggle button next to the friends list. // @author ceeprus // @match https://vrchat.com/home* // @match https://*.vrchat.com/home* // @noframes // @grant GM_getValue // @grant GM_setValue // @grant GM.getValue // @grant GM.setValue // @grant GM_registerMenuCommand // @downloadURL https://raw.githubusercontent.com/ceeprus/userscript/main/vrchat/vrchat-hide-worlds.user.js // @updateURL https://raw.githubusercontent.com/ceeprus/userscript/main/vrchat/vrchat-hide-worlds.user.js // ==/UserScript== // To submit bugs or submit revisions please see visit the repository at: // https://github.com/ceeprus/userscript const REGEX_WORLD_ID = /\/home\/world\/(wrld_[0-9a-f-]{36})/iu; const WORLD_LINK_SELECTOR = 'a[href*="/home/world/wrld_"]'; const WORLD_INFO_SELECTOR = '[role="region"][aria-label="World Info"]'; const LAUNCH_SELECTOR = 'button[aria-label="Launch"]'; const OWN_SELECTOR = '.VRCWH-BTN, .VRCWH-FAB, .VRCWH-BADGE, .VRCWH-PAGE-BTN'; const HEADING_SELECTOR = 'h1, h2, h3, h4'; const TITLE_HEADING_SELECTOR = 'h1, h2, h3'; // `:scope` binds only to the compound it sits in, so ':scope > h1, h2' would // mean "direct-child h1, or ANY descendant h2". Scope every compound. const CHILD_HEADING_SELECTOR = HEADING_SELECTOR.split(',') .map((tag) => `:scope > ${tag.trim()}`) .join(', '); const STATES = ['normal', 'dimmed', 'hidden']; const MAX_CLIMB = 10; ((_undefined) => { // Enable for debugging const DEBUG = false; const KEY_STATE = 'VRCWH_STATE'; const KEY_HIDDEN = 'VRCWH_HIDDEN'; const logDebug = (...msgs) => { if (DEBUG) console.debug('[VRC-WH]', msgs); }; // Prefer GM storage, fall back to localStorage. Always strings so all // three backends round-trip identically. const stateGet = async (key, defaultValue) => { try { return GM_getValue(key, defaultValue); } catch (_) { /* fall through */ } try { return await GM.getValue(key, defaultValue); } catch (_) { /* fall through */ } return localStorage.getItem(key) ?? defaultValue; }; const stateSet = async (key, value) => { try { GM_setValue(key, value); return; } catch (_) { /* fall through */ } try { await GM.setValue(key, value); return; } catch (_) { /* fall through */ } localStorage.setItem(key, value); }; // GreaseMonkey no longer supports GM_addStyle. So we have to define // our own polyfill here const addStyle = (aCss) => { const head = document.getElementsByTagName('head')[0]; if (head) { const style = document.createElement('style'); style.setAttribute('type', 'text/css'); style.textContent = aCss; head.appendChild(style); return style; } return null; }; addStyle(` .VRCWH-WORLD-HIDDEN { display: none !important } .VRCWH-WORLD-DIMMED { opacity: .25; filter: grayscale(.85); transition: opacity .15s ease, filter .15s ease; } .VRCWH-WORLD-DIMMED:hover, .VRCWH-WORLD-DIMMED:focus-within { opacity: .75 } .VRCWH-WORLD-MARKED { outline: 2px dashed rgba(255, 122, 122, .75); outline-offset: 2px; } .VRCWH-ROW-EMPTY { display: none !important } .VRCWH-BADGE { align-items: center; background: #8f2f2f; border-radius: 4px; color: #fff; display: inline-flex; flex: none; font-size: 10px; font-weight: 700; letter-spacing: .04em; line-height: 1; margin-left: 6px; padding: 3px 5px; text-transform: uppercase; vertical-align: middle; white-space: nowrap; } .VRCWH-PAGE-BTN { align-items: center; align-self: flex-start; background: #07242b; border: 2px solid #053c48; border-radius: 4px; color: #fff; cursor: pointer; display: inline-flex; font-size: 1rem; gap: 8px; margin-top: 8px; padding: 5px 12px; transition: .1s ease-in; } .VRCWH-PAGE-BTN:hover { background: #05191d } .VRCWH-PAGE-BTN.VRCWH-BTN-ON { background: rgba(74, 12, 12, .9); border-color: #8f2f2f; color: #ff9d9d; } .VRCWH-PAGE-BTN svg { height: 16px; width: 16px } /* The Launch button themes itself off this variable, so recolouring it here keeps VRChat's own hover and contrast rules intact. */ .VRCWH-LAUNCH-HIDDEN { --profile-button-color: #8f2f2f } .VRCWH-CARD { position: relative } .VRCWH-BTN { align-items: center; background: rgba(5, 25, 29, .85); border: 2px solid #053c48; border-radius: 100%; color: #fff; cursor: pointer; display: flex; height: 28px; justify-content: center; opacity: 0; padding: 0; /* Invisible must also mean untouchable, or a tap on the card corner silently hides a world instead of opening it */ pointer-events: none; position: absolute; right: 6px; top: 6px; transition: opacity .12s ease, transform .12s ease, background .12s ease; width: 28px; z-index: 20; } .VRCWH-CARD:hover .VRCWH-BTN, .VRCWH-CARD:focus-within .VRCWH-BTN, .VRCWH-BTN:focus { opacity: 1; pointer-events: auto } /* No hover to reveal it on touch, so keep it out permanently. Scoped through .VRCWH-CARD to outrank the base rule on specificity, not just on order. */ @media (hover: none) { .VRCWH-CARD .VRCWH-BTN { opacity: .85; pointer-events: auto } } .VRCWH-BTN:hover { background: #07242b; transform: scale(1.12) } .VRCWH-BTN:active { transform: scale(.92) } .VRCWH-BTN.VRCWH-BTN-ON { background: rgba(74, 12, 12, .9); border-color: #8f2f2f; color: #ff9d9d; opacity: 1; pointer-events: auto; } .VRCWH-BTN svg { height: 15px; width: 15px } .VRCWH-FAB { align-items: center; background: #07242b; border: 4px solid #053c48; border-radius: 100%; color: #fff; cursor: pointer; display: flex; height: 65px; justify-content: center; position: fixed; right: 100px; top: 70px; transition: .1s ease-in; width: 65px; z-index: 3; } .VRCWH-FAB:hover { background: #05191d; transform: scale(1.05) } .VRCWH-FAB:active { background: #053c48; transform: scale(.95) } .VRCWH-FAB svg { height: 28px; width: 28px } .VRCWH-FAB.VRCWH-STATE-dimmed { border-color: #6b5a12; color: #ffd24a } .VRCWH-FAB.VRCWH-STATE-hidden { border-color: #6b1f1f; color: #ff7a7a } .VRCWH-FAB-COUNT { align-items: center; background: #053c48; border: 2px solid #07242b; border-radius: 100%; bottom: -4px; color: #fff; display: flex; font-size: 11px; font-weight: 700; height: 22px; justify-content: center; line-height: 1; min-width: 22px; padding: 0 3px; position: absolute; right: -4px; } .VRCWH-FAB-COUNT:empty { display: none } `); const ICONS = { eye: '', eyeSlash: '', }; // =========================================================== // In-memory mirror of storage so the render path stays synchronous. // `hidden` maps world id -> world name, name is tooltip-only. let toggleState = 'normal'; let hidden = {}; const isHidden = (id) => Object.hasOwn(hidden, id); const persistHidden = () => stateSet(KEY_HIDDEN, JSON.stringify(hidden)); const parseHidden = (raw) => { if (!raw) return {}; try { const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!parsed || typeof parsed !== 'object') return {}; // Tolerate the array shape an older build may have written if (Array.isArray(parsed)) { return Object.fromEntries(parsed.map((id) => [id, ''])); } return parsed; } catch (error) { console.error('[VRC-WH]', error); return {}; } }; // =========================================================== const debounce = function (func, wait, immediate) { let timeout; return (...args) => { const later = () => { timeout = null; if (!immediate) func.apply(this, args); }; const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(this, args); }; }; // Every DOM write goes through these, so a run that changes nothing emits // no mutation records and can't re-trigger the observer. const setClass = (el, className, on) => { if (on) { if (!el.classList.contains(className)) el.classList.add(className); } else if (el.classList.contains(className)) { el.classList.remove(className); } }; const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; }; const setAttr = (el, name, value) => { if (el.getAttribute(name) !== value) el.setAttribute(name, value); }; // Compared via a dataset flag, not innerHTML: the browser re-serialises // SVG, so an innerHTML compare would never match. const setIcon = (el, name) => { if (el.dataset.vrcwhIcon === name) return; el.dataset.vrcwhIcon = name; el.innerHTML = ICONS[name]; }; // =========================================================== const worldIdFrom = (href) => { const match = href && REGEX_WORLD_ID.exec(href); return match ? match[1] : null; }; const linkWorldId = (anchor) => worldIdFrom(anchor.getAttribute('href') || anchor.href); // Emotion class hashes (css-1w3pyrn, e3qzuk74, ...) change on every VRChat // deploy, so cards are found structurally: walk up from a world link until // a parent holds more than one distinct world. const resolveCard = (link, idCache) => { const distinctWorldIds = (el) => { let count = idCache.get(el); if (count !== undefined) return count; const seen = new Set(); for (const anchor of el.querySelectorAll(WORLD_LINK_SELECTOR)) { const id = linkWorldId(anchor); if (id) seen.add(id); } count = seen.size; idCache.set(el, count); return count; }; let node = link; for (let i = 0; i < MAX_CLIMB; i++) { const parent = node.parentElement; if (!parent || parent === document.body) return null; if (distinctWorldIds(parent) > 1) { // A section holding a single world lets the climb run past the // card and into the section itself, so check the container is // really a card list and the candidate is really a card. Cards // carry exactly one