Last active
October 26, 2025 18:46
-
-
Save shinmai/5d73979e36534f13505da5ba641b7935 to your computer and use it in GitHub Desktop.
a Vencord plugin to hide a user client-side, with a serverlist control to peek at them or their messages (with mental-health safeguard delays)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * defaults to peeking: hold the button at the top of the server list to temporarily show the user and their messages | |
| * (there is a delay to re-arm the button to peek again) | |
| * shift+click on the button to toggle once (click again to re-hide) | |
| * in toggle mode: click the button to unhide, click again to hide (there is a delay before unhiding) | |
| * | |
| * ctlr+hold to peek just the users messages and their presence on Voice Calls | |
| * | |
| * also adds two classes to the DOM to use with Custom CSS: | |
| * .mental-health.helper for the main peeking/toggling | |
| * .mental-health-helper-alt for the ctrl+hold peeking | |
| * the classes are present by default and get removed when peeked/toggled | |
| * | |
| * installation: https://docs.vencord.dev/installing/custom-plugins/ | |
| * tl;dr: | |
| * rename to index.tsx, place in Vencord/src/userplugins/OutOfSight/ | |
| * recompile & re-inject Vencord | |
| */ | |
| import definePlugin, { OptionType } from "@utils/types"; | |
| import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList"; | |
| import { React, Alerts, Forms, Button, TextInput, UserStore, Forms, Button } from "@webpack/common"; | |
| import { definePluginSettings, Settings } from "@api/Settings"; | |
| const CLASS_MAIN = "mental-health-helper"; | |
| const CLASS_ALT = "mental-health-helper-alt"; | |
| const TARGET_ID = "app-mount"; | |
| const USTYLE_ID = "outofsight-user-style"; | |
| const cssEsc = (s: string) => String(s).replace(/["\\]/g, "\\$&"); | |
| function ensureUserStyle(): HTMLStyleElement { | |
| let el = document.getElementById(USTYLE_ID) as HTMLStyleElement | null; | |
| if (!el) { | |
| el = document.createElement("style"); | |
| el.id = USTYLE_ID; | |
| document.head.appendChild(el); | |
| } | |
| return el; | |
| } | |
| function buildUserCss(ids: string[]): string { | |
| if (!ids?.length) return ""; | |
| const main = ids.map(id => { | |
| const x = cssEsc(id); | |
| return `div[data-list-item-id$="${x}"],li[class^="channel__"]:has(img[src*="${x}"]), div[data-list-item-id^="members-"]:has(img[src*="${x}"]), h3:has(+ div[data-list-item-id^="members-"] img[src*="${x}"]) { display: none; }`; }).join("\n"); | |
| const alt = ids.map(id => { | |
| const x = cssEsc(id); | |
| return `svg:has(img[src*="${x}"]), div[class^="clickTrapContainer__"] div[class^="avatarContainer__"]:has(div[style*="${x}"]), div[class^="clickTrapContainer__"] div[class^="avatarContainer__"]:has(img[src*="${x}"]), div[class^="list"] div:has(> div > div > div[style*='${x}']), li[data-author-id="${x}"], div[role="separator"]:has(+ li[data-author-id="${x}"]) { display: none; }`; | |
| }).join("\n"); | |
| return `.mental-health-helper {${main}} .mental-health-helper.mental-health-helper-alt {${alt}}`.trim(); | |
| } | |
| function refreshUserCss(ids: string[]) { ensureUserStyle().textContent = buildUserCss(ids); } | |
| type IdEditorProps = { value?: string[]; onChange?: (v: string[]) => void }; | |
| const COMPONENT_TYPE = (OptionType as any).COMPONENT ?? (OptionType as any).CUSTOM; | |
| const writeIds = (next: string[]) => { (settings.store as any).userIds = [...next]; refreshUserCss(next); }; | |
| const readIds = (): string[] => { | |
| const raw = (settings.store as any).userIds; | |
| return Array.isArray(raw) ? raw.map(String) : []; | |
| }; | |
| const normalizeIds = (s: string): string[] => { | |
| const out = new Set<string>(); | |
| for (const tok of String(s).split(/[\s,;]+/g)) { | |
| const m = tok.match(/\d{15,25}/); | |
| if (m) out.add(m[0]); | |
| } | |
| return [...out]; | |
| }; | |
| const UsersEditor = ({ value, onChange }: IdEditorProps) => { | |
| const ids = Array.isArray(value) ? value : readIds(); | |
| const setIds = (next: string[]) => (onChange ? onChange(next) : writeIds(next)); | |
| const [input, setInput] = React.useState(""); | |
| const add = () => { | |
| const toAdd = normalizeIds(input); | |
| if (!toAdd.length) return; | |
| const next = Array.from(new Set([...ids, ...toAdd])); | |
| setIds(next); | |
| setInput(""); | |
| }; | |
| const remove = (id: string) => setIds(ids.filter(x => x !== id)); | |
| const clear = () => setIds([]); | |
| return ( | |
| <Forms.FormSection title="Hidden users (IDs)"> | |
| <div style={{ display: "flex", gap: 8 }}> | |
| <TextInput | |
| value={input} | |
| onChange={v => setInput(String(v))} | |
| placeholder="IDs or mentions (<@123…>), comma/space separated" | |
| style={{ flex: 1 }} | |
| /> | |
| <Button size={Button.Sizes.SMALL} onClick={add}>Add</Button> | |
| <Button size={Button.Sizes.SMALL} color={Button.Colors.RED} onClick={clear}>Clear</Button> | |
| </div> | |
| <div style={{ marginTop: 12 }}> | |
| {ids.length ? ( | |
| <div style={{ display: "grid", gap: 8 }}> | |
| {ids.map(id => { | |
| const u = UserStore.getUser?.(id); | |
| const label = u ? `${u.globalName || u.username} (${id})` : id; | |
| return ( | |
| <div key={id} style={{ display: "flex", alignItems: "center", gap: 8 }}> | |
| <Forms.FormText>{label}</Forms.FormText> | |
| <Button size={Button.Sizes.SMALL} onClick={() => remove(id)}>Remove</Button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ) : ( | |
| <Forms.FormText type="description" style={{ opacity: .7 }}> | |
| No users yet. Paste IDs or mentions above and click Add. | |
| </Forms.FormText> | |
| )} | |
| </div> | |
| </Forms.FormSection> | |
| ); | |
| }; | |
| export const settings = definePluginSettings({ | |
| holdToPeek: { | |
| type: OptionType.BOOLEAN, | |
| description: "Hold to peek (toggle when disabled)", | |
| default: true | |
| }, | |
| peekDelaySec: { | |
| type: OptionType.NUMBER, | |
| description: "Toggle-peek confirm delay (seconds)", | |
| default: 45, min: 0, max: 300, step: 1 | |
| }, | |
| rearmDelaySec: { | |
| type: OptionType.NUMBER, | |
| description: "Re-arm delay after hold-peek (seconds)", | |
| default: 75, min: 0, max: 600, step: 1 | |
| }, | |
| autoRearmDelayMin: { | |
| type: OptionType.NUMBER, | |
| description: "Auto re-arm delay after hold-peek (minutes)", | |
| default: 100, min: 0, max: 600, step: 1 | |
| }, | |
| userIds: { | |
| type: COMPONENT_TYPE, | |
| description: "IDs to hide (affects both peek modes via generated CSS)", | |
| default: [] as string[], | |
| component: (p: IdEditorProps) => <UsersEditor {...p} />, | |
| render: (p: IdEditorProps) => <UsersEditor {...p} /> | |
| } | |
| }); | |
| let armed = true; | |
| let suppressNextClick = false; | |
| let latchedOneShot = false; | |
| let armTimeout = null; | |
| let armDeadline = 0; | |
| const app = () => document.getElementById(TARGET_ID); | |
| const hasMain = () => !!app()?.classList.contains(CLASS_MAIN); | |
| const setMain = (on: boolean) => app()?.classList.toggle(CLASS_MAIN, on); | |
| const setAlt = (on: boolean) => app()?.classList.toggle(CLASS_ALT, on); | |
| function label(): string { | |
| const s = Settings.plugins.OutOfSight; | |
| if (s.holdToPeek) { | |
| if (latchedOneShot) return "restore"; | |
| return armed ? "peek" : "re-arm"; | |
| } | |
| return hasMain() ? "peek" : "restore"; | |
| } | |
| function armClickSwallow(ms = 250) { | |
| suppressNextClick = true; | |
| window.setTimeout(() => { suppressNextClick = false; }, ms); | |
| } | |
| function confirmAfterDelayAlert(delaySec: number, title: string): Promise<boolean> { | |
| const ms = Math.max(0, Math.floor(delaySec) * 1000); | |
| const deadline = Date.now() + ms; | |
| const uid = `outofsight-count-${Math.random().toString(36).slice(2)}`; | |
| return new Promise(resolve => { | |
| Alerts.show({ | |
| title, | |
| confirmText: "Confirm", | |
| cancelText: "Cancel", | |
| body: ( | |
| <> | |
| <div style={{ marginBottom: 8 }}> | |
| please wait <span id={uid}>{Math.max(0, Math.ceil((deadline - Date.now())/1000))}</span>s before confirming | |
| </div> | |
| <div style={{ opacity: 0.8 }}>confirm unlocks when the timer reaches zero</div> | |
| </> | |
| ), | |
| onConfirm: () => resolve(true), | |
| onCancel: () => resolve(false), | |
| onCloseCallback: () => resolve(false) | |
| } as any); | |
| const mount = () => { | |
| const dialogs = document.querySelectorAll('[role="dialog"][data-dialog="modal"]'); | |
| const modal = dialogs[dialogs.length - 1] as HTMLElement | undefined; | |
| if (!modal) { requestAnimationFrame(mount); return; } | |
| const span = modal.querySelector<HTMLElement>(`#${uid}`); | |
| const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>('footer button[role="button"], footer button')); | |
| const confirmBtn = buttons.find(b => /confirm/i.test(b.textContent ?? "")) ?? buttons.at(-1); | |
| if (!span || !confirmBtn) { requestAnimationFrame(mount); return; } | |
| const setEnabled = (enabled: boolean, r?: number) => { | |
| confirmBtn!.disabled = !enabled; | |
| confirmBtn!.setAttribute("aria-disabled", String(!enabled)); | |
| const base = "Confirm"; | |
| let confirmBtnText = enabled ? base : `${base} (${r ?? ""})`; | |
| confirmBtn!.innerHTML = `<div class="buttonChildrenWrapper_a22cb0"><div class="buttonChildren_a22cb0"><span class="lineClamp1__4bd52 text-md/medium_cf4812" data-text-variant="text-md/medium">${confirmBtnText}</span></div></div>` | |
| }; | |
| const tick = () => { | |
| const r = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); | |
| span.textContent = String(r); | |
| setEnabled(r <= 0, r); | |
| }; | |
| tick(); | |
| if (ms > 0) { | |
| const iv = setInterval(tick, 250); | |
| const stop = () => clearInterval(iv); | |
| modal.addEventListener("click", stop, { once: true, capture: true }); | |
| } | |
| }; | |
| requestAnimationFrame(mount); | |
| }); | |
| } | |
| function Chip(): JSX.Element { | |
| const ref = React.useRef<HTMLDivElement | null>(null); | |
| React.useEffect(() => { | |
| ref.current && (ref.current.textContent = label()); | |
| setMain(true); | |
| setAlt(true); | |
| }, []); | |
| const setText = (t: string) => { if (ref.current) ref.current.textContent = t; }; | |
| const reArm = _ => { armed = true; if(armTimeout) { clearTimeout(armTimeout); armTimeout = null; } if(ref.current) { ref.current.ariaLabel = ref.current.textContent = label(); ref.current.title = "armed" } } | |
| const setAutoReArm = s => { | |
| if(armTimeout) clearTimeout(armTimeout) | |
| armTimeout = setTimeout(reArm, 1_000 * 60 * s.autoRearmDelayMin); | |
| armDeadline = Date.now() + 1_000 * 60 * s.autoRearmDelayMin; | |
| ref.current.ariaLabel = ref.current.title = (new Date(Date.now() + 1_000 * 60 * s.autoRearmDelayMin)).toLocaleTimeString([],{timeStyle:'short'}) | |
| } | |
| const onMouseDown = async (e: any) => { | |
| const s = Settings.plugins.OutOfSight; | |
| if (s.holdToPeek && latchedOneShot) return; | |
| if (e.ctrlKey) { | |
| e.preventDefault(); e.stopPropagation(); | |
| setAlt(false); | |
| armClickSwallow(); | |
| return; | |
| } | |
| if (s.holdToPeek) { | |
| if (!armed) { | |
| e.preventDefault(); e.stopPropagation(); | |
| const ok = await confirmAfterDelayAlert(s.rearmDelaySec, "re-arm peek?"); | |
| if (ok) reArm(); | |
| return; | |
| } | |
| if (e.shiftKey) { | |
| e.preventDefault(); e.stopPropagation(); | |
| setMain(false); | |
| latchedOneShot = true; | |
| if (ref.current) ref.current.textContent = label(); | |
| armClickSwallow(); | |
| return; | |
| } | |
| e.preventDefault(); e.stopPropagation(); | |
| setMain(false); | |
| armClickSwallow(); | |
| return; | |
| } | |
| }; | |
| const onMouseUp = (e: any) => { | |
| const s = Settings.plugins.OutOfSight; | |
| if (!s.holdToPeek) return; | |
| if (latchedOneShot) return; | |
| if(!hasMain()) { | |
| armed = false; | |
| setAutoReArm(s) | |
| } | |
| setMain(true); | |
| setAlt(true); | |
| if (ref.current) ref.current.textContent = label(); | |
| }; | |
| const onMouseLeave = (e: any) => { | |
| const s = Settings.plugins.OutOfSight; | |
| if (e.ctrlKey) setAlt(true); | |
| if (s.holdToPeek && !latchedOneShot) setMain(true); | |
| }; | |
| const onClick = async (e: any) => { | |
| if (suppressNextClick) { suppressNextClick = false; return; } | |
| const s = Settings.plugins.OutOfSight; | |
| if (s.holdToPeek && latchedOneShot) { | |
| setMain(true); | |
| latchedOneShot = false; | |
| armed = false; | |
| setAutoReArm(s) | |
| if (ref.current) ref.current.textContent = label(); | |
| return; | |
| } | |
| if (!s.holdToPeek) { | |
| if (hasMain()) { | |
| const ok = await confirmAfterDelayAlert(s.peekDelaySec, "peek?"); | |
| if (ok) setMain(false); | |
| } else { | |
| setMain(true); | |
| } | |
| if (ref.current) ref.current.textContent = label(); | |
| } | |
| }; | |
| return ( | |
| <div | |
| ref={ref} | |
| role="button" | |
| tabIndex={0} | |
| title="outofsight" | |
| aria-label="outofsight" | |
| onMouseDown={onMouseDown} | |
| onMouseUp={onMouseUp} | |
| onMouseLeave={onMouseLeave} | |
| onClick={onClick} | |
| style={{ | |
| width: 48, | |
| height: 48, | |
| borderRadius: 9999, | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| boxSizing: "border-box", | |
| padding: 24, | |
| marginLeft: 6, | |
| cursor: "pointer", | |
| userSelect: "none", | |
| background: "var(--background-secondary)", | |
| color: "var(--text-normal)", | |
| textAlign: "center", | |
| fontSize: 10, | |
| lineHeight: "12px", | |
| overflow: "hidden", | |
| wordBreak: "keep-all", | |
| whiteSpace: "nowrap", | |
| textOverflow: "ellipsis" | |
| }} | |
| /> | |
| ); | |
| } | |
| // ---------- plugin ---------- | |
| export default definePlugin({ | |
| name: "OutOfSight", | |
| description: "Hide a user from your client, w/ a server list control to peek/unhide them (ctrl+click to peek just messages & VC presence)", | |
| authors: [{ name: "shi", id: 142594671078539264 }], | |
| dependencies: ["ServerListAPI"], | |
| settings, | |
| patches: [ | |
| { | |
| find: ".messageListItem", | |
| replacement: { | |
| match: /\.messageListItem(?=,"aria)/, | |
| replace: "$&,...$self.decorateUserIDs(arguments[0])" | |
| } | |
| } | |
| ], | |
| decorateUserIDs(props: { message: Message; }) { | |
| try { | |
| const author = props.message?.author; | |
| const authorId = author?.id; | |
| return { "data-author-id": authorId }; | |
| } catch (e) { return {} } | |
| }, | |
| start() { | |
| addServerListElement(ServerListRenderPosition.Above, Chip as any); | |
| setMain(true); setAlt(true); | |
| const ids = (settings.store as any).userIds; | |
| refreshUserCss(Array.isArray(ids) ? ids.map(String) : []); | |
| }, | |
| stop() { | |
| removeServerListElement(ServerListRenderPosition.Above, Chip as any); | |
| setMain(false); setAlt(false); | |
| document.getElementById(USTYLE_ID).remove(); | |
| } | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment