/** * @name GhostUsers * @author spale * @authorId 938917185286447145 * @description Locally hide selected people: no messages, no "typing...", removed from member lists, invisible in calls + automatic local mute. Per-user options. Right-click a user → "Hide user (Ghost)". * @version 1.23.1 * @source https://github.com/spixyy7/bd-ghostusers * @updateUrl https://raw.githubusercontent.com/spixyy7/bd-ghostusers/main/GhostUsers.plugin.js */ "use strict"; const PLUGIN = "GhostUsers"; const HIDE_CLASS = "ghostusers-hidden"; const DEFAULTS = { users: {}, // id -> { tag, ...OPT_KEYS (per-user options) } scopeGroups: true, // default for NEWLY hidden users: group DMs scopeServers: false, // default: servers scopeDMs: false, // default: 1-on-1 DMs autoVoiceMute: true, hideMemberList: true, hideMentions: true, // also hide other people's messages that tag/reply to a hidden user diagnostic: false, // toast with member-counter diagnostics (for troubleshooting) reactionCache: {}, // persisted knowledge of hidden users' reactions (see loadReactionCache) lastSeenVersion: "" // last version whose changelog was shown (see maybeShowChangelog) }; // What's new, shown once after an update (BdApi's changelog modal colours these: // added = green, fixed = red, improved = blue). Written for the person using the // plugin, not for whoever wrote it — one sentence per change, no internals. const CHANGELOG = [ { version: "1.22.0", changes: [ { title: "Added", type: "added", items: [ "A short \"what's new\" window like this one, shown once after every update.", ] }, { title: "Fixed", type: "fixed", items: [ "Turning the plugin off now takes effect instantly — no hidden messages stay hidden and nothing keeps running in the background.", ] }, ], }, { version: "1.20.0", changes: [ { title: "Improved", type: "improved", items: [ "Reactions left by hidden people stay subtracted after you restart Discord, and a chat no longer flickers when it opens.", ] }, ], }, { version: "1.19.0", changes: [ { title: "Improved", type: "improved", items: [ "The moment you hide someone, their reactions disappear from messages already on your screen — no need to hover over anything.", ] }, ], }, { version: "1.18.0", changes: [ { title: "Fixed", type: "fixed", items: [ "A call where only hidden people are left no longer leaves a black call area on screen.", ] }, ], }, ]; // Options every hidden user carries INDIVIDUALLY. The global values above are // only defaults — they get written onto the user at the moment of hiding (and // via migration for existing entries), then edited per user in settings. const OPT_KEYS = ["scopeGroups", "scopeServers", "scopeDMs", "autoVoiceMute", "hideMemberList", "hideMentions"]; const CSS = ` .${HIDE_CLASS} { display: none !important; } .gu-panel { color: var(--text-normal, #dbdee1); font-size: 14px; line-height: 1.45; } .gu-panel h3 { margin: 14px 0 4px; color: var(--header-primary, #f2f3f5); font-size: 15px; font-weight: 600; } .gu-check { display: flex; gap: 8px; align-items: center; margin: 8px 0; cursor: pointer; user-select: none; } .gu-check input { width: 16px; height: 16px; cursor: pointer; } .gu-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; background: var(--background-secondary, #2b2d31); border-radius: 8px; margin: 6px 0; } .gu-btn { background: var(--button-danger-background, #da373c); color: #fff; border: none; border-radius: 4px; padding: 5px 12px; cursor: pointer; font-size: 13px; } .gu-btn:hover { filter: brightness(1.1); } .gu-btn.gu-safe { background: var(--brand-500, var(--brand-experiment, #5865f2)); } .gu-input { flex: 1; background: var(--input-background, #1e1f22); border: none; border-radius: 4px; color: inherit; padding: 6px 8px; font-size: 13px; } .gu-muted { color: var(--text-muted, #949ba4); font-size: 12px; } .gu-user { padding: 10px 12px; background: var(--background-secondary, #2b2d31); border-radius: 8px; margin: 6px 0; } .gu-user-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .gu-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .gu-chip { padding: 3px 10px; border-radius: 999px; font-size: 12px; line-height: 18px; cursor: pointer; user-select: none; border: 1px solid var(--background-modifier-accent, #3f4147); color: var(--text-muted, #949ba4); background: transparent; } .gu-chip.gu-on { background: var(--brand-500, var(--brand-experiment, #5865f2)); border-color: transparent; color: #fff; } .gu-chip:hover { filter: brightness(1.15); } .gu-sub { color: var(--text-muted, #949ba4); font-size: 12px; margin: 2px 0 8px; } `; module.exports = class GhostUsers { constructor(meta) { this.meta = meta; } /* ---------------- lifecycle ---------------- */ start() { this._stopped = false; this._timers = new Set(); this.settings = Object.assign({}, DEFAULTS, BdApi.Data.load(PLUGIN, "settings")); this.settings.users = this.settings.users || {}; // migration from the old "onlyGroupDMs" switch (≤ v1.7) to scope options if (typeof this.settings.onlyGroupDMs === "boolean") { const everywhere = this.settings.onlyGroupDMs === false; this.settings.scopeGroups = true; this.settings.scopeServers = everywhere; this.settings.scopeDMs = everywhere; delete this.settings.onlyGroupDMs; this.save(); } // migration to PER-USER options (v1.15): every hidden user carries their // own set of switches; the global values found become their personal ones let migrated = false; for (const rec of Object.values(this.settings.users)) for (const k of OPT_KEYS) if (typeof rec[k] !== "boolean") { rec[k] = !!this.settings[k]; migrated = true; } if (migrated) this.save(); // identity cache for filtered store results (stable references → no extra re-renders) this._epoch = 0; this._filterCache = new WeakMap(); // hidden users' reactions we know about: "chId:msgId" -> Map(emojiKey -> Set). // Populated from live gateway events and from fetched reactor lists; read-level // code subtracts these from chip counts (adjustReactions). PERSISTED in the // plugin config (reactionCache), so after a Discord restart the plugin already // knows what to subtract — no re-learning, no hover needed. this.loadReactionCache(); this._reactRerenderT = null; this._reactSaveT = null; const Webpack = BdApi.Webpack; const byKeys = Webpack.Filters?.byKeys ?? Webpack.Filters?.byProps; const findModule = f => Webpack.getModule(f) ?? Webpack.getModule(f, { searchExports: true }); const getStore = (name, fallbackFilter) => (Webpack.getStore ? Webpack.getStore(name) : null) ?? (fallbackFilter ? findModule(fallbackFilter) : null); this.Dispatcher = findModule(byKeys("dispatch", "subscribe", "unsubscribe")); this.ChannelStore = getStore("ChannelStore", byKeys("getChannel", "getDMFromUserId")); this.MessageStore = getStore("MessageStore", byKeys("getMessages", "getMessage")); this.SelectedChannelStore = getStore("SelectedChannelStore", byKeys("getChannelId", "getVoiceChannelId")); this.UserStore = getStore("UserStore", byKeys("getCurrentUser", "getUser")); this.VoiceActions = findModule(m => m?.toggleLocalMute && m?.setLocalVolume); this.MediaEngineStore = getStore("MediaEngineStore", m => m?.isLocalMute && m?.getLocalVolume); this.VoiceStateStore = getStore("VoiceStateStore", m => typeof m?.getVoiceStatesForChannel === "function"); this.StreamStore = getStore("ApplicationStreamingStore", m => typeof m?.getAnyStreamForUser === "function"); this.CallStore = getStore("CallStore", m => typeof m?.getCall === "function" && typeof m?.getCalls === "function"); // Honestly report what stopped working if Discord internals changed — // the DOM fallback (scanDOM) still hides visually even without these modules. const missing = []; if (!this.Dispatcher) missing.push("Dispatcher (filtering new messages/notifications)"); if (!this.ChannelStore) missing.push("ChannelStore ('group DMs only' scope)"); if (!this.MessageStore) missing.push("MessageStore (removing old messages from view)"); if (!this.VoiceActions || !this.MediaEngineStore) missing.push("VoiceEngine (auto-mute in calls)"); if (!this.VoiceStateStore) missing.push("VoiceStateStore (hiding call participants)"); if (!this.StreamStore) missing.push("ApplicationStreamingStore (hiding streams)"); if (!this.CallStore) missing.push("CallStore (ringing circles when you start a call)"); if (missing.length) { console.warn(`[${PLUGIN}] Unavailable Discord modules:`, missing); BdApi.UI.showToast(`${PLUGIN}: ${missing.length} features unavailable — details in Console (Ctrl+Shift+I)`, { type: "warning" }); } if (this.Dispatcher) { BdApi.Patcher.instead(PLUGIN, this.Dispatcher, "dispatch", (thisObj, args, orig) => { try { if (this.interceptDispatch(args[0])) return; } catch (e) { console.error(`[${PLUGIN}] dispatch`, e); } return orig.apply(thisObj, args); }); } // Filter AT READ LEVEL: getMessages returns the collection the chat draws on // every render. We return a filtered copy (without hidden users) → their // messages are NEVER rendered, even ones Discord loads from its cache before // the plugin starts. No more "flash then disappear" flicker, and hiding/ // unhiding is instant (non-destructive — nothing is deleted from the store). if (this.MessageStore?.getMessages) { BdApi.Patcher.after(PLUGIN, this.MessageStore, "getMessages", (thisObj, args, ret) => { try { if (!ret || !Object.keys(this.settings.users).length) return ret; const channelId = args[0]; return this.cached(ret, () => this.filterMessageCollection(ret, channelId)); } catch (e) { console.error(`[${PLUGIN}] getMessages`, e); return ret; } }); } // Hidden users don't exist as call participants even at the data level — // Discord then computes the layout as if they weren't there (remaining // participants get re-centered). CRUCIAL for the "empty slot": Discord // positions call tiles/circles absolutely (JS layout computed from the // participant list), so DOM hiding leaves a hole — the filter MUST happen // at the data source. Participants aren't read only from VoiceStateStore, // so we patch EVERY module that has getVoiceStatesForChannel (e.g. // SortedVoiceStateStore — the call UI asks it for the sorted display list). const voiceModules = new Set(); if (this.VoiceStateStore) voiceModules.add(this.VoiceStateStore); for (const opts of [{ first: false }, { first: false, searchExports: true }]) { try { const found = Webpack.getModule(m => typeof m?.getVoiceStatesForChannel === "function", opts); if (Array.isArray(found)) for (const m of found) if (m) voiceModules.add(m); } catch (e) { /* older BD without first:false — the named store is already in the set */ } } try { const sorted = Webpack.getStore?.("SortedVoiceStateStore"); if (sorted) voiceModules.add(sorted); } catch (e) { /* not present in this Discord version */ } for (const store of voiceModules) this.patchVoiceStates(store); // Their screen share / Go Live stream doesn't exist for us at read level either // (if the stream started before we hid them, the events are already in the store) if (this.StreamStore) { if (this.StreamStore.getAnyStreamForUser) BdApi.Patcher.after(PLUGIN, this.StreamStore, "getAnyStreamForUser", (thisObj, args, ret) => { if (ret && this.isHiddenIn(args[0], ret.channelId)) return null; return ret; }); if (this.StreamStore.getAllActiveStreams) BdApi.Patcher.after(PLUGIN, this.StreamStore, "getAllActiveStreams", (thisObj, args, ret) => { if (Array.isArray(ret) && Object.keys(this.settings.users).length) return ret.filter(s => !this.isHiddenIn(s?.ownerId, s?.channelId)); return ret; }); } // When YOU start a call: the circles of users Discord is "ringing" come from // CallStore (call.ringing), NOT from voice states — without this filter the // hidden user keeps a reserved empty slot in the call strip even though the // DOM fallback hides their avatar. The clone shares the original's prototype // (methods keep working), the store stays untouched — non-destructive like // all the other filters. if (this.CallStore) { const filterCall = call => { if (!call || !Array.isArray(call.ringing) || !call.ringing.length) return call; const kept = call.ringing.filter(id => !this.isHiddenIn(id, call.channelId)); if (kept.length === call.ringing.length) return call; const clone = Object.create(Object.getPrototypeOf(call)); Object.assign(clone, call); clone.ringing = kept; return clone; }; BdApi.Patcher.after(PLUGIN, this.CallStore, "getCall", (thisObj, args, ret) => { try { if (!ret || !Object.keys(this.settings.users).length) return ret; // a call whose every remaining participant is hidden doesn't exist for us if (this.visibleCallParticipants(ret.channelId) === 0) return null; return this.cached(ret, () => filterCall(ret)); } catch (e) { console.error(`[${PLUGIN}] getCall`, e); return ret; } }); if (typeof this.CallStore.getCalls === "function") BdApi.Patcher.after(PLUGIN, this.CallStore, "getCalls", (thisObj, args, ret) => { try { if (!Array.isArray(ret) || !ret.length || !Object.keys(this.settings.users).length) return ret; let changed = false; const out = []; for (const c of ret) { if (c && this.visibleCallParticipants(c.channelId) === 0) { changed = true; continue; } const f = filterCall(c); if (f !== c) changed = true; out.push(f); } return changed ? out : ret; } catch (e) { console.error(`[${PLUGIN}] getCalls`, e); return ret; } }); if (typeof this.CallStore.isCallActive === "function") BdApi.Patcher.after(PLUGIN, this.CallStore, "isCallActive", (thisObj, args, ret) => { try { if (!ret || !Object.keys(this.settings.users).length) return ret; return this.visibleCallParticipants(this.argChannelId(args[0]) ?? args[0]) === 0 ? false : ret; } catch (e) { return ret; } }); } // Reactions: the tooltip/popout reads "who reacted" through getReactions — // filter hidden users out of every module that has it, and at the same time // RECORD what we learned (trackHiddenReaction) so read-level code can subtract // them from the chip counts too. History-loaded counts don't say who reacted, // so they can only be corrected once the reactor list is fetched (hover/popout); // live reactions are tracked from the moment they arrive. this._reactionModules = new Set(); for (const opts of [{ first: false }, { first: false, searchExports: true }]) { try { const found = Webpack.getModule(m => typeof m?.getReactions === "function", opts); if (Array.isArray(found)) for (const m of found) if (m) this._reactionModules.add(m); } catch (e) { /* older BD — reaction filtering degrades gracefully */ } } for (const mod of this._reactionModules) BdApi.Patcher.after(PLUGIN, mod, "getReactions", (thisObj, args, ret) => { try { if (!ret || typeof ret !== "object" || !Object.keys(this.settings.users).length) return ret; const channelId = args[0], messageId = args[1], emoji = args[2]; return this.cached(ret, () => { if (Array.isArray(ret)) { let changed = false; const out = []; for (const u of ret) { const uid = u?.id ?? u?.userId ?? null; if (uid && this.isHiddenIn(uid, channelId)) { changed = true; this.trackHiddenReaction(channelId, messageId, emoji, uid); continue; } out.push(u); } if (changed) this.queueReactionRerender(); return changed ? out : ret; } let changed = false; const out = {}; for (const [uid, u] of Object.entries(ret)) { const id = u?.id ?? uid; if (this.isHiddenIn(id, channelId)) { changed = true; this.trackHiddenReaction(channelId, messageId, emoji, id); continue; } out[uid] = u; } if (changed) this.queueReactionRerender(); return changed ? out : ret; }); } catch (err) { console.error(`[${PLUGIN}] reactions`, err); return ret; } }); // Note: the server member list is NOT filtered through ChannelMemberStore.getProps — // runtime diagnostics showed the member list never calls it. Instead, counters // and rows are covered through React fibers (adjustCounts + scanDOM): a header's // fiber props contain ALL members of its section (virtualized and skeleton rows too). this.ctxCallback = (tree, props) => this.injectContextItem(tree, props); this.unpatchCtx = BdApi.ContextMenu.patch("user-context", this.ctxCallback); BdApi.DOM.addStyle(PLUGIN, CSS); this.startObserver(); for (const id of Object.keys(this.settings.users)) if (this.userOpt(id, "autoVoiceMute")) this.ensureLocalMute(id, true); // refresh the view so the getMessages filter immediately drops already-cached messages this.forceRerenderMessages(); // sweep the reactor cache for the open channel (matters after a mid-session // plugin reload — the cache may already hold hidden users' reactions) this.later(() => this.sweepKnownReactions(), 1500); // let Discord finish starting before a modal shows up this.later(() => this.maybeShowChangelog(), 2000); } stop() { this._stopped = true; // anything already queued turns into a no-op BdApi.Patcher.unpatchAll(PLUGIN); for (const id of this._timers ?? []) clearTimeout(id); this._timers?.clear(); if (this._rafId) { cancelAnimationFrame(this._rafId); this._rafId = null; } this._scanQueued = false; if (this._reactRerenderT) { clearTimeout(this._reactRerenderT); this._reactRerenderT = null; } if (this._reactSaveT) { clearTimeout(this._reactSaveT); this._reactSaveT = null; } this.saveReactionCache(); // flush the reaction knowledge to disk try { this.unpatchCtx?.(); } catch (e) { /* already detached */ } try { BdApi.ContextMenu.unpatch?.("user-context", this.ctxCallback); } catch (e) { /* already detached */ } this.observer?.disconnect(); this.observer = null; document.querySelectorAll(`.${HIDE_CLASS}`).forEach(n => n.classList.remove(HIDE_CLASS)); this.restoreReplacedImgs(); this.restoreCounts(); BdApi.DOM.removeStyle(PLUGIN); this.save(); } save() { BdApi.Data.save(PLUGIN, "settings", this.settings); } /* ---------------- what's new (once per update) ---------------- */ cmpVersion(a, b) { const pa = String(a).split("."), pb = String(b).split("."); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (parseInt(pa[i], 10) || 0) - (parseInt(pb[i], 10) || 0); if (d) return d; } return 0; } /** Shows the changes since the version the user last saw. A fresh install just records the version — nobody wants a changelog for something they've never used. Everything is best-effort: a missing API must never block the plugin. */ maybeShowChangelog() { try { const version = this.meta?.version; if (!version) return; const seen = this.settings.lastSeenVersion; this.settings.lastSeenVersion = version; this.save(); // A first install gets nothing — there is no "new" for someone who has // never used it. An existing setup without a record is an upgrade from // before this window existed, so that one does get shown. const upgrading = seen ? this.cmpVersion(version, seen) > 0 : Object.keys(this.settings.users || {}).length > 0; if (!upgrading) return; const fresh = CHANGELOG.filter(e => !seen || this.cmpVersion(e.version, seen) > 0).slice(0, 3); if (!fresh.length) return; const multi = fresh.length > 1; const changes = fresh.flatMap(e => e.changes.map(c => ({ type: c.type, title: multi ? `${c.title} — ${e.version}` : c.title, items: c.items, }))); BdApi.UI.showChangelogModal({ title: PLUGIN, subtitle: `Version ${version}`, blurb: "Everything this plugin does happens only in your client — the people you hide can't tell.", changes, }); } catch (e) { console.warn(`[${PLUGIN}] changelog`, e); } } /** Deferred work that is guaranteed to die with the plugin: every timer is tracked and cleared in stop(), and a callback that survived the race does nothing once stopped. Without this a disable could still re-hide rows or dispatch into Discord a moment later. */ later(fn, ms) { const id = setTimeout(() => { this._timers?.delete(id); if (this._stopped) return; try { fn(); } catch (e) { console.warn(`[${PLUGIN}] deferred task`, e); } }, ms); this._timers?.add(id); return id; } /* ---------------- core logic ---------------- */ isHidden(id) { return !!(id && this.settings.users[id]); } // Personal option of a hidden user; falls back to the global default // (after the migration in start() every record has its own values). userOpt(id, key) { const rec = this.settings.users[id]; return (rec && typeof rec[key] === "boolean") ? rec[key] : !!this.settings[key]; } // In which channel kind hiding applies FOR THE SPECIFIC user // (group DM / server / 1-on-1 DM — everyone has their own switches) scopeAllowsFor(userId, channelId) { const g = this.userOpt(userId, "scopeGroups"); const s = this.userOpt(userId, "scopeServers"); const d = this.userOpt(userId, "scopeDMs"); if (g && s && d) return true; // everything enabled — channel type irrelevant if (!channelId || !this.ChannelStore?.getChannel) return false; const ch = this.ChannelStore.getChannel(channelId); if (!ch) return false; const t = ch.type; if (t === 3 || t === "GROUP_DM") return g; if (t === 1 || t === "DM") return d; return s; // all other types are server channels (text/voice/thread/forum…) } isHiddenIn(userId, channelId) { if (!this.isHidden(userId)) return false; return this.scopeAllowsFor(userId, channelId); } currentChannelId() { return this.SelectedChannelStore?.getChannelId?.() ?? null; } // Intercepts Discord flux events. return true = the event is swallowed (the // message never enters the store → no display, no sound, no notification). interceptDispatch(e) { if (!e?.type) return false; switch (e.type) { case "MESSAGE_CREATE": case "MESSAGE_UPDATE": { const msg = e.message; const chId = e.channelId ?? msg?.channel_id; if (this.isHiddenIn(msg?.author?.id, chId)) return true; if (this.mentionsHidden(msg, chId)) return true; if (msg?.referenced_message && this.isHiddenIn(msg.referenced_message?.author?.id, chId)) msg.referenced_message = null; // reply preview of their message return false; } case "LOAD_MESSAGES_SUCCESS": { if (Array.isArray(e.messages)) { e.messages = e.messages.filter(m => !this.isHiddenIn(m?.author?.id, e.channelId) && !this.mentionsHidden(m, e.channelId)); for (const m of e.messages) if (m?.referenced_message && this.isHiddenIn(m.referenced_message?.author?.id, e.channelId)) m.referenced_message = null; } return false; } case "TYPING_START": return this.isHiddenIn(e.userId, e.channelId); case "MESSAGE_REACTION_ADD": case "MESSAGE_REACTION_REMOVE": { // Reactions flow INTO the store normally (counts stay consistent with // the server) — we track hidden users' reactions and subtract them at // read level (adjustReactions), so the chip/count never shows them. // Symmetry: a live ADD is tracked (−1 shown), its later REMOVE is // untracked (+1 shown) while the store does the opposite → net exact. if (this.isHiddenIn(e.userId, e.channelId)) { if (e.type === "MESSAGE_REACTION_ADD") this.trackHiddenReaction(e.channelId, e.messageId, e.emoji, e.userId); else this.untrackHiddenReaction(e.channelId, e.messageId, e.emoji, e.userId); this.queueReactionRerender(); } return false; } case "MESSAGE_REACTION_REMOVE_ALL": this.clearTrackedReactions(e.channelId, e.messageId, null); return false; case "MESSAGE_REACTION_REMOVE_EMOJI": this.clearTrackedReactions(e.channelId, e.messageId, e.emoji); return false; case "MESSAGE_REACTION_ADD_USERS": { // Bulk reactor load (tooltip/popout fetch) — hidden users never enter // the reactors store, and we record them (trackHiddenReaction) so the // chip counts subtract them too. if (Array.isArray(e.users) && e.users.length) { const kept = e.users.filter(u => { const uid = u?.id ?? (typeof u === "string" ? u : null); if (uid && this.isHiddenIn(uid, e.channelId)) { this.trackHiddenReaction(e.channelId, e.messageId, e.emoji, uid); return false; } return true; }); if (kept.length !== e.users.length) { e.users = kept; this.queueReactionRerender(); } } return false; } case "VOICE_STATE_UPDATES": { // Present a hidden user joining the call as "left" → the store never // records them: no join sound, no display. Leaves pass through // normally (a no-op if they were never recorded). if (Array.isArray(e.voiceStates)) e.voiceStates = e.voiceStates.map(vs => { const ch = vs?.channelId ?? vs?.channel_id; if (!ch || !this.isHiddenIn(vs.userId, ch)) { this.forgetHiddenInCall(vs?.userId); return vs; } // remembered, because once masked they are invisible in the // store — and a ring has to know the call is not empty this.rememberHiddenInCall(vs.userId, ch); return { ...vs, channelId: null }; }); return false; } case "CALL_CREATE": case "CALL_UPDATE": { // When calling, the hidden user isn't "ringing" in our view (ringing // list), and their voice state from the call payload is never written — // the call strip layout is computed from the start as if they weren't // there (no empty slot). // This payload comes straight off the gateway, so depending on the // client build its keys arrive camelCased or as-sent (snake_case). // Both are read: looking at only one of them would filter nothing at // all, silently, which is impossible to notice from the outside. const chId = e.channelId ?? e.channel_id; const vsKey = Array.isArray(e.voiceStates) ? "voiceStates" : Array.isArray(e.voice_states) ? "voice_states" : null; // Newer clients don't send `ringing` (a list of user ids) any more but // `ongoingRings`, whose entries are objects. Both are handled, and the // ids are dug out of whatever shape an entry happens to have. const ringKey = Array.isArray(e.ringing) ? "ringing" : Array.isArray(e.ongoingRings) ? "ongoingRings" : null; const ringId = r => (r && typeof r === "object" ? (r.userId ?? r.user_id ?? r.ringerId ?? r.ringer_id ?? r.id) : r); // Kept for the settings panel: Discord's client disables DevTools, so // the shape of this payload has to be readable without a console. this._lastCall = `${e.type} · keys: ${Object.keys(e).join(", ")}` + ` · participants: ${vsKey ? `${e[vsKey].length} (${vsKey})` : "not in payload"}` + ` · rings: ${ringKey ? `${e[ringKey].length} (${ringKey}: ${ e[ringKey].map(r => (r && typeof r === "object" ? Object.keys(r).join("/") : typeof r)).join(" ") || "-"})` : "none"}` + ` · hidden in this call: ${this.hiddenInCall(chId).size}`; if (this.settings.diagnostic) { console.log(`[${PLUGIN}] ${this._lastCall}`, e); BdApi.UI.showToast(`${PLUGIN}: ${e.type} — see plugin settings`, { type: "info" }); } if (ringKey && e[ringKey].length) e[ringKey] = e[ringKey].filter(r => !this.isHiddenIn(ringId(r), chId)); if (vsKey && e[vsKey].length) e[vsKey] = e[vsKey].filter(vs => !this.isHiddenIn(vs?.userId ?? vs?.user_id, chId ?? vs?.channelId ?? vs?.channel_id)); // A call whose only participants are hidden does not exist for us: no // ring, no sound, no banner. Suppression needs positive proof that the // call is theirs — either a hidden person is known to be in it, or the // one and only person on the other side of this DM is hidden. Counting // nobody is not proof: voice states can simply be late. const evidence = this.hiddenInCall(chId).size > 0 || this.hiddenDmRecipient(chId); const visible = vsKey ? e[vsKey].length : this.visibleCallParticipants(chId); if (evidence && visible === 0 && ringKey && e[ringKey].length) e[ringKey] = []; return false; } case "STREAM_CREATE": case "STREAM_UPDATE": case "STREAM_SERVER_UPDATE": // streamKey format: "call::" or "guild:::" return this.isStreamKeyHidden(e.streamKey); case "CHANNEL_SELECT": // Messages are already covered by the getMessages read filter. Here we // additionally sweep the reactor CACHE of the newly opened channel — // reactions Discord loaded before a user was hidden get subtracted // immediately, without waiting for a hover (see sweepKnownReactions). if (e.channelId && Object.keys(this.settings.users).length) { const chId = e.channelId; this.later(() => this.sweepKnownReactions(chId), 300); } return false; } return false; } // A tag/reply to a hidden user only hides the message if THAT user has their // personal "hideMentions" option enabled (per-user setting). mentionHides(id, channelId) { return typeof id === "string" && this.isHiddenIn(id, channelId) && this.userOpt(id, "hideMentions"); } // Does a message (anyone's) tag a hidden user or reply to their message? // Works with raw payloads (snake_case) and store models (camelCase) alike. mentionsHidden(msg, channelId) { if (!msg) return false; const ment = msg.mentions; if (Array.isArray(ment)) for (const m of ment) { const id = m?.id ?? m; if (this.mentionHides(id, channelId)) return true; } const refAuthor = msg.referenced_message?.author?.id ?? msg.referencedMessage?.author?.id; if (refAuthor && this.mentionHides(refAuthor, channelId)) return true; // reply without an embedded reference → try a store lookup const ref = msg.message_reference ?? msg.messageReference; const refId = ref?.message_id ?? ref?.messageId; if (refId && this.MessageStore?.getMessage) { const rm = this.MessageStore.getMessage(ref.channel_id ?? ref.channelId ?? channelId, refId); if (rm?.author?.id && this.mentionHides(rm.author.id, channelId)) return true; } // <@id> tag in the text (catches edge cases without a mentions array) if (typeof msg.content === "string" && msg.content.includes("<@")) for (const id of Object.keys(this.settings.users)) if (this.mentionHides(id, channelId) && (msg.content.includes(`<@${id}>`) || msg.content.includes(`<@!${id}>`))) return true; return false; } // Identity-cached filter: same input object + unchanged hidden list // → same output object (React shallow-compare sees no fake change). cached(src, compute) { const hit = this._filterCache.get(src); if (hit && hit.epoch === this._epoch) return hit.value; const value = compute(); this._filterCache.set(src, { epoch: this._epoch, value }); return value; } isStreamKeyHidden(streamKey) { if (typeof streamKey !== "string") return false; const parts = streamKey.split(":"); if (parts.length < 3) return false; const ownerId = parts[parts.length - 1]; const channelId = parts[parts.length - 2]; return this.isHiddenIn(ownerId, channelId); } /* ---------------- reactions of hidden users ---------------- */ emojiKey(emoji) { return emoji?.id ?? emoji?.name ?? String(emoji ?? ""); } // The tracking map is persisted in the plugin config so knowledge survives // restarts — counts are corrected from the FIRST render (no flicker where the // reaction briefly shows and then disappears once re-learned). loadReactionCache() { this._hiddenReactions = new Map(); try { const obj = this.settings.reactionCache; if (!obj || typeof obj !== "object") return; for (const [key, per] of Object.entries(obj)) { const m = new Map(); for (const [ek, arr] of Object.entries(per ?? {})) if (Array.isArray(arr) && arr.length) m.set(ek, new Set(arr)); if (m.size) this._hiddenReactions.set(key, m); } } catch (e) { console.warn(`[${PLUGIN}] reactionCache load`, e); } } saveReactionCache() { try { const obj = {}; for (const [key, per] of this._hiddenReactions) { const e = {}; for (const [ek, set] of per) if (set.size) e[ek] = [...set]; if (Object.keys(e).length) obj[key] = e; } this.settings.reactionCache = obj; this.save(); } catch (e) { console.warn(`[${PLUGIN}] reactionCache save`, e); } } // Debounced config write — tracking can change in bursts (sweep, tooltip fetch) queueReactionCacheSave() { if (this._reactSaveT) return; this._reactSaveT = setTimeout(() => { this._reactSaveT = null; this.saveReactionCache(); }, 2000); } // Remember that a hidden user reacted with this emoji on this message. The chip // count is then decreased at read level for every member of the set who is // (still) hidden in that channel — so unhiding restores counts automatically. trackHiddenReaction(channelId, messageId, emoji, userId) { if (!channelId || !messageId || !userId) return; const key = `${channelId}:${messageId}`; let per = this._hiddenReactions.get(key); if (!per) { if (this._hiddenReactions.size > 3000) for (const k of [...this._hiddenReactions.keys()].slice(0, 500)) this._hiddenReactions.delete(k); per = new Map(); this._hiddenReactions.set(key, per); } const ek = this.emojiKey(emoji); let set = per.get(ek); if (!set) { set = new Set(); per.set(ek, set); } if (!set.has(userId)) { set.add(userId); this.queueReactionCacheSave(); } } untrackHiddenReaction(channelId, messageId, emoji, userId) { const set = this._hiddenReactions.get(`${channelId}:${messageId}`)?.get(this.emojiKey(emoji)); if (set && set.delete(userId)) this.queueReactionCacheSave(); } clearTrackedReactions(channelId, messageId, emoji) { const key = `${channelId}:${messageId}`; if (emoji == null) { if (this._hiddenReactions.delete(key)) this.queueReactionCacheSave(); return; } if (this._hiddenReactions.get(key)?.delete(this.emojiKey(emoji))) this.queueReactionCacheSave(); } // Debounced re-render after reaction knowledge changes (a hover can trigger a // burst of getReactions calls — one epoch bump is enough). queueReactionRerender() { if (this._reactRerenderT) return; this._reactRerenderT = setTimeout(() => { this._reactRerenderT = null; this.forceRerenderMessages(); }, 100); } // Return a message whose reaction chips exclude hidden users' known reactions. // If only hidden users reacted with an emoji, that chip disappears entirely. // Clones share prototypes (methods keep working); the store original is untouched. adjustReactions(msg, channelId) { if (!msg?.id || !Array.isArray(msg.reactions) || !msg.reactions.length) return msg; const per = this._hiddenReactions.get(`${channelId}:${msg.id}`); if (!per) return msg; let changed = false; const out = []; for (const r of msg.reactions) { const set = per.get(this.emojiKey(r?.emoji)); let sub = 0; if (set) for (const uid of set) if (this.isHiddenIn(uid, channelId)) sub++; if (!sub) { out.push(r); continue; } changed = true; const n = (r.count ?? 0) - sub; if (n <= 0) continue; // only hidden users reacted → the whole chip disappears const nr = Object.create(Object.getPrototypeOf(r)); Object.assign(nr, r); nr.count = n; if (nr.countDetails && typeof nr.countDetails.normal === "number") { const cd = Object.create(Object.getPrototypeOf(nr.countDetails)); Object.assign(cd, nr.countDetails); cd.normal = Math.max(0, cd.normal - sub); nr.countDetails = cd; } out.push(nr); } if (!changed) return msg; const clone = Object.create(Object.getPrototypeOf(msg)); Object.assign(clone, msg); clone.reactions = out; return clone; } // Proactively read the ALREADY-CACHED reactor lists for every loaded message in // a channel. The reads go through our patched getReactions, which records hidden // users' reactions (trackHiddenReaction) and schedules a re-render — so reactions // Discord loaded BEFORE a user was hidden disappear immediately, no hover needed. // Store getters are pure cache reads — this never triggers a network fetch. sweepKnownReactions(channelId) { try { const chId = channelId ?? this.currentChannelId(); if (!chId || !Object.keys(this.settings.users).length) return; if (!this._reactionModules?.size || !this.MessageStore?.getMessages) return; const coll = this.MessageStore.getMessages(chId); const arr = coll?._array ?? (typeof coll?.toArray === "function" ? coll.toArray() : null); if (!Array.isArray(arr)) return; for (const m of arr) { if (!m?.id || !Array.isArray(m.reactions) || !m.reactions.length) continue; for (const r of m.reactions) for (const mod of this._reactionModules) { try { mod.getReactions(chId, m.id, r?.emoji); } catch (e) { /* skip this module */ } } } } catch (e) { /* cache sweep is best-effort */ } } // User id from a voice state record — covers all the shapes various getters // return: a bare voice state, a {user, voiceState} pair from sorted lists… vsUserId(vs) { return vs?.userId ?? vs?.user?.id ?? vs?.voiceState?.userId ?? null; } // A getter's last argument may be a channelId string OR a whole channel object // (SortedVoiceStateStore.getVoiceStatesForChannel takes a channel) — normalize to an id. argChannelId(v) { if (typeof v === "string") return v; if (v && typeof v === "object" && typeof v.id === "string") return v.id; return null; } // How many participants of the call in this channel remain VISIBLE (non-hidden, // ourselves included). Reads through the already-filtered voice getters — hidden // users' states are also masked out of the store at ingest, so a hidden-only call // reads as empty. null = unknown store shape (fail open: the call stays visible). /* Hidden people are masked out of the voice store the moment they join, which makes them invisible everywhere — including to us. So they are remembered here: it is the difference between "this call is empty because only hidden people are in it" and "this call looks empty because the voice states are a few milliseconds late", and only the first one may silence a ring. */ rememberHiddenInCall(userId, channelId) { if (!userId || !channelId) return; this._hiddenVoice ??= new Map(); let set = this._hiddenVoice.get(channelId); if (!set) { set = new Set(); this._hiddenVoice.set(channelId, set); } set.add(userId); } forgetHiddenInCall(userId) { if (!userId || !this._hiddenVoice) return; for (const [ch, set] of this._hiddenVoice) if (set.delete(userId) && !set.size) this._hiddenVoice.delete(ch); } hiddenInCall(channelId) { return this._hiddenVoice?.get(channelId) ?? new Set(); } /** True when this is a one-on-one DM and the person on the other side is hidden. */ hiddenDmRecipient(channelId) { try { const ch = channelId && this.ChannelStore?.getChannel?.(channelId); if (!ch || ch.type !== 1) return false; const other = (Array.isArray(ch.recipients) ? ch.recipients : [])[0]; const id = typeof other === "string" ? other : other?.id; return !!id && this.isHiddenIn(id, channelId); } catch (e) { return false; } } visibleCallParticipants(channelId) { try { if (!channelId) return null; // Context-agnostic primary path: count states pointing at this channel // across ALL contexts. DM calls are keyed under a special context, so the // per-channel getter's signature/behavior differs between client versions — // getAllVoiceStates (already filtered) is reliable regardless. const all = this.VoiceStateStore?.getAllVoiceStates?.(); if (all && typeof all === "object") { let n = 0; for (const map of Object.values(all)) for (const vs of Object.values(map ?? {})) if ((vs?.channelId ?? vs?.channel_id) === channelId) n++; return n; } } catch (e) { /* fall through to the per-channel getter */ } try { const vs = this.VoiceStateStore?.getVoiceStatesForChannel?.(channelId); if (Array.isArray(vs)) return vs.length; if (vs && typeof vs === "object") return Object.keys(vs).length; } catch (e) { /* unknown store shape */ } return null; } // All read getters of one voice-state module return results without hidden users. // Called for every module that looks like a call-participant source (see start()). patchVoiceStates(store) { if (typeof store.getVoiceStatesForChannel === "function") BdApi.Patcher.after(PLUGIN, store, "getVoiceStatesForChannel", (thisObj, args, ret) => { try { if (!ret || typeof ret !== "object" || !Object.keys(this.settings.users).length) return ret; const channelId = this.argChannelId(args[args.length - 1]); // (channelId) | (guildId, channelId) | (channel) return this.cached(ret, () => { if (Array.isArray(ret)) { const out = ret.filter(vs => !this.isHiddenIn(this.vsUserId(vs), channelId ?? vs?.channelId)); return out.length === ret.length ? ret : out; } let changed = false; const out = {}; for (const [uid, vs] of Object.entries(ret)) { if (this.isHiddenIn(this.vsUserId(vs) ?? uid, channelId ?? vs?.channelId)) { changed = true; continue; } out[uid] = vs; } return changed ? out : ret; }); } catch (err) { console.error(`[${PLUGIN}] voiceStates`, err); return ret; } }); // getters returning a SINGLE voice state — signature-agnostic (scope from the result itself) for (const fn of ["getVoiceStateForChannel", "getVoiceStateForUser", "getVoiceState", "getVoiceStateForSession"]) if (typeof store[fn] === "function") BdApi.Patcher.after(PLUGIN, store, fn, (thisObj, args, ret) => { if (!ret) return ret; const uid = ret.userId ?? ret.user_id ?? null; const ch = ret.channelId ?? ret.channel_id ?? null; return uid && this.isHiddenIn(uid, ch) ? null : ret; }); if (typeof store.getAllVoiceStates === "function") BdApi.Patcher.after(PLUGIN, store, "getAllVoiceStates", (thisObj, args, ret) => { try { if (!ret || typeof ret !== "object" || !Object.keys(this.settings.users).length) return ret; return this.cached(ret, () => { let changedAny = false; const out = {}; for (const [ctx, map] of Object.entries(ret)) { let changed = false; const m = {}; for (const [uid, vs] of Object.entries(map ?? {})) { if (this.isHiddenIn(this.vsUserId(vs) ?? uid, vs?.channelId)) { changed = true; continue; } m[uid] = vs; } out[ctx] = changed ? m : map; changedAny = changedAny || changed; } return changedAny ? out : ret; }); } catch (err) { console.error(`[${PLUGIN}] allVoiceStates`, err); return ret; } }); } // Build a filtered copy of a MessageCollection (without hidden users' messages // and without others' messages that tag/reply to them). Shares the original's // prototype (all methods work), only _array and _map change — the store // original stays untouched. filterMessageCollection(coll, channelId) { const arr = coll?._array ?? (typeof coll?.toArray === "function" ? coll.toArray() : null); if (!Array.isArray(arr)) return coll; const drop = m => this.isHiddenIn(m?.author?.id, channelId) || this.mentionsHidden(m, channelId); const kept = arr.filter(m => !drop(m)); // subtract hidden users' known reactions from the kept messages' chips const adjusted = kept.map(m => this.adjustReactions(m, channelId)); const reactionsChanged = adjusted.some((m, i) => m !== kept[i]); if (kept.length === arr.length && !reactionsChanged) return coll; // nothing to hide const clone = Object.create(Object.getPrototypeOf(coll)); Object.assign(clone, coll); clone._array = adjusted; if (coll._map && typeof coll._map === "object") { clone._map = Object.assign({}, coll._map); for (const m of arr) if (drop(m) && m?.id != null) delete clone._map[m.id]; for (const m of adjusted) if (m?.id != null && clone._map[m.id]) clone._map[m.id] = m; } return clone; } // Force the chat to redraw (after hide/unhide) without deleting from the store forceRerenderMessages() { this._epoch++; // invalidates cached filtered collections try { this.MessageStore?.emitChange?.(); } catch (e) { /* not critical */ } } /* ---------------- hide / unhide ---------------- */ hideUser(user) { const id = user?.id; if (!id) return; if (id === this.UserStore?.getCurrentUser?.()?.id) return BdApi.UI.showToast("You can't hide yourself 🙂", { type: "error" }); const tag = user.globalName || user.username || user.tag || null; const rec = { tag }; for (const k of OPT_KEYS) rec[k] = !!this.settings[k]; // snapshot of defaults — edited per user afterwards this.settings.users[id] = rec; // ensureLocalMute checks the current state — if already muted, it touches nothing if (rec.autoVoiceMute) this.ensureLocalMute(id, true); this.save(); this.forceRerenderMessages(); // they disappear from view instantly (nothing deleted from the store) this.refreshDOM(); this.sweepKnownReactions(); // their already-loaded reactions vanish right away too BdApi.UI.showToast(`${tag ?? id} — hidden ✔`, { type: "success" }); } unhideUser(id) { const rec = this.settings.users[id]; delete this.settings.users[id]; // always unmute on unhide — so a "stuck" mute can't remain in any scenario this.ensureLocalMute(id, false); this.save(); this.forceRerenderMessages(); // messages come back immediately — no more Ctrl+R this.refreshDOM(); BdApi.UI.showToast(`${rec?.tag ?? id} — visible again ✔`, { type: "success" }); } // Discord's built-in per-user local mute (same as right-click → Mute in a call) ensureLocalMute(id, wantMuted) { try { if (!this.VoiceActions?.toggleLocalMute || !this.MediaEngineStore?.isLocalMute) return; if (this.MediaEngineStore.isLocalMute(id) !== wantMuted) this.VoiceActions.toggleLocalMute(id); } catch (e) { console.warn(`[${PLUGIN}] localMute`, e); } } /* ---------------- context menu ---------------- */ injectContextItem(tree, props) { try { const user = props?.user ?? props?.targetUser; if (!user?.id) return; if (user.id === this.UserStore?.getCurrentUser?.()?.id) return; const hidden = this.isHidden(user.id); const item = BdApi.ContextMenu.buildItem({ id: "ghostusers-toggle", label: hidden ? "Show user (Ghost)" : "Hide user (Ghost)", danger: !hidden, action: () => hidden ? this.unhideUser(user.id) : this.hideUser(user) }); const children = tree?.props?.children; if (Array.isArray(children)) children.push(BdApi.ContextMenu.buildItem({ type: "separator" }), item); } catch (e) { console.error(`[${PLUGIN}] ctx`, e); } } /* ---------------- DOM fallback (member list, call tiles, leftover messages) ---------------- */ startObserver() { this.processed = new WeakSet(); this.observer = new MutationObserver(muts => { // React sometimes restores the original src on re-render — such images // are dropped from "processed" so the next scan handles them again. for (const m of muts) if (m.type === "attributes" && m.target?.tagName === "IMG") this.processed.delete(m.target); this.queueScan(); }); // characterData: React writes the member count directly into the text node this.observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["src"], characterData: true }); this.queueScan(); } queueScan() { if (this._scanQueued || this._stopped) return; this._scanQueued = true; this._rafId = requestAnimationFrame(() => { this._scanQueued = false; this._rafId = null; if (this._stopped) return; // a frame queued just before stop must not re-hide rows this.scanDOM(); }); } scanDOM() { try { const chId = this.currentChannelId(); // [role="listitem"] in the members panel also catches skeleton rows (no // avatar, no user props — identified via fiber.key in getUserIdFromNode). // Member selectors always run — whether a ROW is actually removed is // decided by the specific user's personal "hideMemberList" option. const memberSel = '[class*="member_"], [class*="members_"] [role="listitem"], [class*="membersWrap"] [role="listitem"]'; // Rows Discord refills in place — a member coming online, a call tile // changing hands, the list scrolling — must be judged again on every // scan. Remembering a verdict for those is what let a hidden person // reappear the moment they came online: their row had already been seen // while it was still an empty skeleton with nobody in it. const liveSel = memberSel + ', [class*="voiceUser_"], [class*="tile_"], [class*="participant"]'; const selector = 'li[id^="chat-messages"], img[src*="/avatars/"], ' + liveSel; for (const node of document.querySelectorAll(selector)) { let live = false; try { live = node.matches(liveSel); } catch (e) { /* treat as a regular node */ } if (!live) { if (this.processed.has(node)) continue; this.processed.add(node); } if (node.tagName === "IMG") { this.hideOwnerOf(node, chId); continue; } const uid = this.getUserIdFromNode(node); if (live) { let isMemberRow = false; try { isMemberRow = node.matches(memberSel); } catch (e) { /* not a member row */ } const hide = !!uid && this.isHiddenIn(uid, chId) && (!isMemberRow || this.userOpt(uid, "hideMemberList")); // and the other way round: a row that now holds somebody visible // must lose the class it inherited from whoever was in it before if (hide) this.collapseAndHide(node); else this.unhideRow(node); continue; } if (!uid || !this.isHiddenIn(uid, chId)) continue; this.collapseAndHide(node); } this.updateCallVisibility(chId); } catch (e) { console.error(`[${PLUGIN}] scan`, e); } this.adjustCounts(); } // DOM backstop for the in-channel call area: if Discord still renders the call // container while the call has NO visible participants (hidden-only call), hide // the whole container; un-hide it the moment someone visible joins (every scan // re-evaluates, and a join mutates the DOM which triggers a scan). Data-level // filters (getCall/isCallActive) normally stop it from rendering at all — this // covers client versions where the call page reads a source we don't patch. updateCallVisibility(chId) { try { if (!chId || !Object.keys(this.settings.users).length) return; const conts = document.querySelectorAll('[class*="callContainer"]'); if (!conts.length) return; const visible = this.visibleCallParticipants(chId); for (const cont of conts) { if (visible === 0) cont.classList.add(HIDE_CLASS); else cont.classList.remove(HIDE_CLASS); } } catch (e) { /* skip */ } } /* ----- member counts: show (real count − hidden in that group) ----- */ channelFromNode(node) { try { const key = Object.keys(node).find(k => k.startsWith("__reactFiber$")); let f = key && node[key]; for (let i = 0; f && i < 12; i++, f = f.return) { const c = f.memoizedProps?.channel; if (c?.id) return c; } } catch (e) { /* skip */ } return null; } fiberOf(el) { try { const k = Object.keys(el).find(x => x.startsWith("__reactFiber$")); return k ? el[k] : null; } catch (e) { return null; } } // How many hidden members belong to this header's section. From the header we // climb the fiber chain to the first array of member ELEMENTS (key = userId, // props = {user, status}) — that's its section. Works when the list is // virtualized and when a row is a skeleton, because React props contain ALL // members of the section. If the array spans several groups at once, the // "Online"/"Offline" header filters by props.status. hiddenCountForHeader(headerEl, channelId) { const isMemberEl = v => v && typeof v === "object" && v.props && typeof v.props === "object" && typeof v.props.user?.id === "string"; const txt = headerEl.textContent ?? ""; const wantStatus = /offline/i.test(txt) ? "offline" : /online/i.test(txt) ? "online" : null; let f = this.fiberOf(headerEl); for (let i = 0; f && i < 14; i++, f = f.return) { const p = f.memoizedProps; if (!p || typeof p !== "object") continue; const arrs = []; for (const v of Object.values(p)) if (Array.isArray(v) && v.some(isMemberEl)) arrs.push(v); if (Array.isArray(p.children) && p.children.some(isMemberEl)) arrs.push(p.children); for (const arr of arrs) { let n = 0; for (const el of arr) { if (!isMemberEl(el)) continue; const uid = el.props.user.id; if (!this.isHiddenIn(uid, channelId) || !this.userOpt(uid, "hideMemberList")) continue; if (wantStatus && el.props.status && el.props.status !== wantStatus) continue; n++; } return n; // the first array with member elements is this header's section } } return 0; } hiddenRecipientCount(ch) { const recips = Array.isArray(ch?.recipients) ? ch.recipients : []; let n = 0; for (const id of recips) if (this.isHiddenIn(id, ch.id) && this.userOpt(id, "hideMemberList")) n++; return n; } adjustCounts() { try { // "7 Members" in the DM sidebar (group row) for (const li of document.querySelectorAll("nav li")) { const ch = this.channelFromNode(li); if (!ch?.id) continue; if (!(ch.type === 3 || ch.type === "GROUP_DM")) continue; this.fixCountLeaves(li, this.hiddenRecipientCount(ch)); } // Member list headers ("Online — N" / "Offline — N" / "Members — N" / roles). // The list is virtualized and a hidden row often isn't in the DOM at all // (or hangs as a forever-loading skeleton), so the tally is NOT computed // from DOM rows — but from React fibers: from each header to its section's // member-element array (hiddenCountForHeader). We take only TOP-LEVEL // membersGroup elements (h3), not their child spans. const cont = document.querySelector('[class*="membersWrap"], [class*="members_"], [class*="memberList"]'); let diagHeaders = 0, diagHidden = 0; if (cont) { const chId = this.currentChannelId(); const headers = [...cont.querySelectorAll('[class*="membersGroup"]')].filter(el => { const pc = el.parentElement?.className; return !(typeof pc === "string" && pc.includes("membersGroup")); }); diagHeaders = headers.length; for (const h of headers) { const n = this.hiddenCountForHeader(h, chId); diagHidden += n; this.fixCountLeaves(h, n, true); } } if (this.settings.diagnostic) { const msg = `GhostUsers: container=${cont ? "YES" : "NO"}, headers=${diagHeaders}, hidden=${diagHidden}`; if (msg !== this._lastDiag) { this._lastDiag = msg; BdApi.UI.showToast(msg, { type: "info", timeout: 8000 }); } } } catch (e) { console.error(`[${PLUGIN}] counts`, e); } } // Finds text elements like "N Members" / "Members—N" and shows N − hiddenCount. // The original is kept in data-gu-count; if Discord writes a fresh number (an // actual membership change), it's recognized because the number won't match the // last one we wrote. fixCountLeaves(root, hiddenCount, generic = false) { try { const full = (root.textContent ?? "").replace(/\s+/g, " ").trim(); let curNum = null; const mMembers = /(\d+)\s+Members?\b/i.exec(full); // "7 Members" (anywhere in the text) const mLabel = /[—–-]\s*(\d+)\s*$/.exec(full); // "... — 7" (at the end) if (mMembers) curNum = parseInt(mMembers[1], 10); else if (mLabel && (generic || /members/i.test(full))) curNum = parseInt(mLabel[1], 10); if (curNum === null || !Number.isFinite(curNum)) return; const stored = parseInt(root.dataset.guCount ?? "", 10); const shown = parseInt(root.dataset.guShown ?? "", 10); const orig = (Number.isFinite(stored) && curNum === shown) ? stored : curNum; const want = Math.max(0, orig - hiddenCount); // the number often sits in its own leaf (label and number are separate // spans) — find the LAST leaf containing a number and rewrite that one const leaves = root.childElementCount === 0 ? [root] : [...root.querySelectorAll("*")].filter(el => el.childElementCount === 0); let target = null, tm = null; for (let i = leaves.length - 1; i >= 0; i--) { const t = leaves[i].textContent ?? ""; const m = /(\d+)(?!\D*\d)/.exec(t); if (m) { target = leaves[i]; tm = m; break; } } if (!target) return; // Write only if the CURRENT number in the leaf differs from the wanted one — // the condition is against the leaf (not curNum), because the hiddenVisually // span keeps the original number so "want === curNum" would skip the restore // when hiddenCount drops to 0. Without the condition we'd keep writing the // same text → characterData → observer loop. if (parseInt(tm[1], 10) !== want) { const t = target.textContent; target.textContent = t.slice(0, tm.index) + want + t.slice(tm.index + tm[1].length); } if (hiddenCount > 0) { root.dataset.guCount = String(orig); root.dataset.guShown = String(want); } else { delete root.dataset.guCount; delete root.dataset.guShown; } } catch (e) { /* skip */ } } // Restore the original numbers (plugin shutdown / user unhidden) restoreCounts() { document.querySelectorAll("[data-gu-count]").forEach(root => { const orig = root.dataset.guCount; const leaves = root.childElementCount === 0 ? [root] : [...root.querySelectorAll("*")].filter(el => el.childElementCount === 0); for (let i = leaves.length - 1; i >= 0; i--) { const t = leaves[i].textContent ?? ""; const m = /(\d+)(?!\D*\d)/.exec(t); if (m) { leaves[i].textContent = t.slice(0, m.index) + orig + t.slice(m.index + m[1].length); break; } } delete root.dataset.guCount; delete root.dataset.guShown; }); } // From an avatar image, find the HIGHEST React component belonging to the hidden // user (whole call tile / preview circle / list row) and hide its DOM element. // The channel scope is read from the nearest `channel` prop (e.g. a group row in // the sidebar), so "group DMs only" applies even when that group isn't open. hideOwnerOf(imgNode, currentChId) { try { const key = Object.keys(imgNode).find(k => k.startsWith("__reactFiber$")); if (!key) return; let best = null; let bestId = null; let channelCtx = null; let f = imgNode[key]; for (let i = 0; f && i < 25; i++, f = f.return) { const p = f.memoizedProps; if (!p || typeof p !== "object") continue; if (!channelCtx && typeof p.channel?.id === "string") channelCtx = p.channel.id; const id = this.fiberUserId(p); if (id && this.isHidden(id)) { best = f; bestId = id; } } const scopeCh = channelCtx ?? currentChId; let hiddenId = null; if (bestId && this.isHiddenIn(bestId, scopeCh)) hiddenId = bestId; else { // Fallback: user id from the avatar URL — group icon collages in the // DM list often carry no user props, but custom avatar URLs contain the ID. const m = /\/avatars\/(\d{15,21})\//.exec(imgNode.src ?? ""); if (m && this.isHiddenIn(m[1], scopeCh)) hiddenId = m[1]; } if (!hiddenId) return; // The members panel honors the personal "hide from member lists" option if (!this.userOpt(hiddenId, "hideMemberList") && imgNode.closest('[class*="membersWrap"], [class*="members_"], [class*="memberList"]')) return; // Group icon collage (DM sidebar + chat header) → instead of a gray circle, // use the avatar of the first member who is NOT hidden. Detection: a small // icon (≤56px) inside nav/section/header chrome — call circles are big and // in the chat area, so those get hidden, not replaced. const chObj = this.ChannelStore?.getChannel?.(channelCtx ?? scopeCh) ?? null; const isGroup = chObj && (chObj.type === 3 || chObj.type === "GROUP_DM"); if (isGroup && imgNode.closest("nav, section, header")) { const rect = imgNode.getBoundingClientRect?.(); if (rect && rect.width === 0) { // layout hasn't run yet — let the next scan try again this.processed.delete(imgNode); return; } if (rect && rect.width <= 56) { this.replaceCollageAvatar(imgNode, hiddenId, chObj); return; } } // Everything else (call, member list, messages…) → hide the whole element/slot if (best && bestId === hiddenId) { let host = best; for (let i = 0; host && i < 15 && !(host.stateNode instanceof HTMLElement); i++) host = host.child; this.collapseAndHide(host?.stateNode instanceof HTMLElement ? host.stateNode : imgNode); } else { this.collapseAndHide(imgNode); } } catch (e) { /* unknown fiber structure — skip */ } } // Avatar URL for a given user (for collage replacement); null if unavailable avatarURLFor(userId) { const u = this.UserStore?.getUser?.(userId); if (!u) return null; try { const url = u.getAvatarURL?.(null, 80) ?? u.getAvatarURL?.(); if (typeof url === "string") return url; } catch (e) { /* getAvatarURL changed — try manually */ } return u.avatar ? `https://cdn.discordapp.com/avatars/${userId}/${u.avatar}.webp?size=80` : null; } // First group member who isn't hidden, isn't already shown in the collage, and has an avatar pickReplacement(channel, excludeIds) { const recips = Array.isArray(channel?.recipients) ? channel.recipients : []; for (const rid of recips) { if (this.isHidden(rid) || excludeIds.has(rid)) continue; const url = this.avatarURLFor(rid); if (url) return url; } return null; } replaceCollageAvatar(imgNode, hiddenId, channel) { const row = imgNode.closest("li") ?? imgNode.parentElement?.parentElement ?? imgNode; const exclude = new Set(); for (const other of row.querySelectorAll('img[src*="/avatars/"]')) { if (other === imgNode) continue; const mm = /\/avatars\/(\d{15,21})\//.exec(other.src ?? ""); if (mm) exclude.add(mm[1]); } const url = this.pickReplacement(channel, exclude); if (!url) { this.collapseAndHide(imgNode); return; } // no candidate → gray circle if (imgNode.src === url) return; // already replaced — idempotent, no loop if (!imgNode.dataset.guOriginal) imgNode.dataset.guOriginal = imgNode.src; imgNode.dataset.guReplaced = hiddenId; imgNode.removeAttribute("srcset"); imgNode.src = url; } // Restore original images on all replaced avatars (unhide / plugin shutdown) restoreReplacedImgs() { document.querySelectorAll("img[data-gu-replaced]").forEach(img => { if (img.dataset.guOriginal) img.src = img.dataset.guOriginal; delete img.dataset.guReplaced; delete img.dataset.guOriginal; }); } // Also hide wrappers that contain ONLY this user (flex/grid slot) — so the // remaining participants re-flow/center instead of leaving an empty hole. collapseAndHide(el) { const stop = new Set(["UL", "OL", "MAIN", "SECTION", "ASIDE", "NAV", "BODY", "HTML"]); // In call/voice UI hide the WHOLE slot: the first ancestor that has a sibling // of the SAME class is the repeated unit of the participant list (tile/circle/ // row) — once it's hidden, the flex/grid layout leaves no gap in its place. // Chat rows are skipped: there a "same-class sibling" would be someone ELSE's // whole message (e.g. their reply to a hidden user) — we must not hide that // this way. try { if (!el.closest?.('li[id^="chat-messages"]') && el.closest?.('[class*="call" i], [class*="voice" i], [class*="tile" i], [class*="participant" i], [class*="ringing" i]')) { const cls = n => ((n.getAttribute?.("class") ?? "") + "") .split(/\s+/).filter(c => c && c !== HIDE_CLASS).sort().join(" "); let n = el; for (let i = 0; n && i < 10 && !stop.has(n.tagName); i++, n = n.parentElement) { if (typeof n.id === "string" && n.id.startsWith("chat-messages")) break; const c = cls(n); if (!c || !n.parentElement) continue; const twin = [...n.parentElement.children].some(s => s !== n && s.tagName === n.tagName && cls(s) === c); if (twin) { n.classList.add(HIDE_CLASS); return; } } } } catch (e) { /* heuristic failed — fall through to the fallback below */ } let target = el; let p = el.parentElement; for (let i = 0; p && i < 4 && p.childElementCount === 1 && !stop.has(p.tagName); i++) { target = p; p = p.parentElement; } target.classList.add(HIDE_CLASS); } /** Undo a hide on a recycled row: collapseAndHide may have marked an ancestor, so the way back up is walked too — only our own class is ever removed. */ unhideRow(el) { let n = el; for (let i = 0; n && i < 10; i++, n = n.parentElement) { // the call area is hidden for a different reason (a call with nobody // visible in it) — walking past it would bring that whole thing back if (n.matches?.('[class*="callContainer"]')) return; if (n.classList?.contains(HIDE_CLASS)) n.classList.remove(HIDE_CLASS); } } refreshDOM() { this._epoch++; // invalidates cached filtered store results document.querySelectorAll(`.${HIDE_CLASS}`).forEach(n => n.classList.remove(HIDE_CLASS)); this.restoreReplacedImgs(); this.processed = new WeakSet(); this.queueScan(); // Nudge with an empty voice event → the store emits → the call UI re-renders // with filtered participants and immediately re-centers the layout. The // CallStore poke does the same for ringing circles (hide/unhide mid-ring). this.later(() => { try { this.Dispatcher?.dispatch({ type: "VOICE_STATE_UPDATES", voiceStates: [] }); } catch (e) { /* not critical */ } try { this.CallStore?.emitChange?.(); } catch (e) { /* not critical */ } }, 50); } // Extract a user id from a React component's props (message, member row, call tile, stream…) fiberUserId(p) { if (!p || typeof p !== "object") return null; return p.user?.id ?? p.message?.author?.id ?? p.participant?.user?.id ?? p.voiceState?.userId ?? (typeof p.userId === "string" ? p.userId : null); } // Reads a user id from a DOM element's React fiber — independent of class names. // The fallback is fiber.key: member rows (and their skeleton placeholders that // carry no user props) are keyed by key = userId. getUserIdFromNode(node) { try { const key = Object.keys(node).find(k => k.startsWith("__reactFiber$")); if (!key) return null; let fiber = node[key]; for (let i = 0; fiber && i < 30; i++, fiber = fiber.return) { const id = this.fiberUserId(fiber.memoizedProps); if (id) return id; const fk = fiber.key; if (typeof fk === "string" && /^\d{15,21}$/.test(fk)) return fk; } } catch (e) { /* unknown fiber structure — skip the node */ } return null; } /* ---------------- settings panel ---------------- */ getSettingsPanel() { const panel = document.createElement("div"); panel.className = "gu-panel"; const OPTS = [ ["scopeGroups", "Group DMs", "Hiding applies in group DMs"], ["scopeServers", "Servers", "Hiding applies on servers"], ["scopeDMs", "1-on-1 DMs", "Hiding applies in direct messages"], ["autoVoiceMute", "Auto mute in calls", "Automatic local mute in calls"], ["hideMemberList", "Hide from member lists", "Not shown in member lists or counters"], ["hideMentions", "Hide tags & replies", "Also hide others' messages that tag or reply to them"] ]; const h = txt => { const el = document.createElement("h3"); el.textContent = txt; return el; }; const sub = txt => { const el = document.createElement("div"); el.className = "gu-sub"; el.textContent = txt; return el; }; // A row of pills (chips) — the same UI for a user's personal options and for // the defaults. Blue (on) = the option applies; a click changes it instantly, // no save/reload needed. const mkChips = (getVal, setVal) => { const wrap = document.createElement("div"); wrap.className = "gu-chips"; for (const [key, label, desc] of OPTS) { const chip = document.createElement("button"); chip.type = "button"; chip.className = "gu-chip" + (getVal(key) ? " gu-on" : ""); chip.textContent = label; chip.title = desc; chip.addEventListener("click", () => { const now = !getVal(key); setVal(key, now); chip.classList.toggle("gu-on", now); }); wrap.append(chip); } return wrap; }; /* ----- hidden users (cards with personal options) ----- */ const listWrap = document.createElement("div"); const renderList = () => { listWrap.replaceChildren(); listWrap.append(h("Hidden users")); const entries = Object.entries(this.settings.users); if (!entries.length) { const p = document.createElement("div"); p.className = "gu-muted"; p.textContent = "Nobody is hidden. Right-click a user → “Hide user (Ghost)”."; listWrap.append(p); return; } listWrap.append(sub("Click a name to open that user's options — a blue (lit) pill applies; click a pill to toggle it.")); for (const [id, rec] of entries) { const box = document.createElement("div"); box.className = "gu-user"; const top = document.createElement("div"); top.className = "gu-user-top"; top.style.cursor = "pointer"; const info = document.createElement("div"); const label = () => rec?.tag || "(unknown name)"; const name = document.createElement("div"); name.textContent = "▸ " + label(); const idEl = document.createElement("div"); idEl.className = "gu-muted"; idEl.textContent = id; info.append(name, idEl); const btn = document.createElement("button"); btn.className = "gu-btn"; btn.textContent = "Remove"; btn.title = "The user becomes visible again"; btn.addEventListener("click", ev => { ev.stopPropagation(); this.unhideUser(id); renderList(); }); top.append(info, btn); const chips = mkChips( key => this.userOpt(id, key), (key, val) => { rec[key] = val; this.save(); if (key === "autoVoiceMute") this.ensureLocalMute(id, val); this.forceRerenderMessages(); this.refreshDOM(); } ); chips.style.display = "none"; // collapsed until the name is clicked top.addEventListener("click", () => { const open = chips.style.display !== "none"; chips.style.display = open ? "none" : ""; name.textContent = (open ? "▸ " : "▾ ") + label(); }); box.append(top, chips); listWrap.append(box); } }; renderList(); panel.append(listWrap); /* ----- add by ID ----- */ const addRow = document.createElement("div"); addRow.className = "gu-row"; const input = document.createElement("input"); input.className = "gu-input"; input.placeholder = "Add by User ID (right-click → Copy User ID)"; const addBtn = document.createElement("button"); addBtn.className = "gu-btn"; addBtn.textContent = "Hide"; addBtn.addEventListener("click", () => { const id = input.value.trim(); if (!/^\d{15,21}$/.test(id)) return BdApi.UI.showToast("Invalid User ID", { type: "error" }); const user = this.UserStore?.getUser?.(id) ?? { id, username: null }; this.hideUser(user); input.value = ""; renderList(); }); addRow.append(input, addBtn); panel.append(addRow); /* ----- defaults for newly hidden users — also collapsible ----- */ const defWrap = document.createElement("div"); defWrap.style.display = "none"; defWrap.append( sub("Written onto a user at the moment of hiding — for already hidden users, edit the options above, on their card."), mkChips( key => !!this.settings[key], (key, val) => { this.settings[key] = val; this.save(); } ) ); const applyAll = document.createElement("button"); applyAll.className = "gu-btn gu-safe"; applyAll.style.marginTop = "8px"; applyAll.textContent = "Apply defaults to all hidden users"; applyAll.addEventListener("click", () => { for (const [id, rec] of Object.entries(this.settings.users)) { for (const k of OPT_KEYS) rec[k] = !!this.settings[k]; this.ensureLocalMute(id, !!rec.autoVoiceMute); } this.save(); this.forceRerenderMessages(); this.refreshDOM(); renderList(); BdApi.UI.showToast("Applied to all hidden users ✔", { type: "success" }); }); defWrap.append(applyAll); const defTitleTxt = "Defaults for newly hidden users"; const defTitle = h("▸ " + defTitleTxt); defTitle.style.cursor = "pointer"; defTitle.addEventListener("click", () => { const open = defWrap.style.display !== "none"; defWrap.style.display = open ? "none" : ""; defTitle.textContent = (open ? "▸ " : "▾ ") + defTitleTxt; }); panel.append(defTitle, defWrap); /* ----- diagnostics + note ----- */ const diag = document.createElement("label"); diag.className = "gu-check"; diag.style.marginTop = "12px"; const diagCb = document.createElement("input"); diagCb.type = "checkbox"; diagCb.checked = !!this.settings.diagnostic; diagCb.addEventListener("change", () => { this.settings.diagnostic = diagCb.checked; this.save(); }); const diagTxt = document.createElement("span"); diagTxt.textContent = "Counter diagnostics (toast — for troubleshooting)"; diag.append(diagCb, diagTxt); panel.append(diag); // Last call event seen, in plain sight — Discord's client has DevTools // disabled, so this is the only way to check what a call payload looked like. if (this._lastCall) { const callInfo = document.createElement("div"); callInfo.className = "gu-muted"; callInfo.style.marginTop = "10px"; callInfo.style.wordBreak = "break-word"; callInfo.textContent = `Last call event: ${this._lastCall}`; panel.append(callInfo); } const note = document.createElement("div"); note.className = "gu-muted"; note.style.marginTop = "10px"; note.textContent = "Hidden users' messages are never shown (new or old), and every change takes effect immediately, without a reload."; panel.append(note); return panel; } };