Skip to content

Instantly share code, notes, and snippets.

@shinmai
Last active October 26, 2025 18:46
Show Gist options
  • Select an option

  • Save shinmai/5d73979e36534f13505da5ba641b7935 to your computer and use it in GitHub Desktop.

Select an option

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)
/*
* 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