* its booing time, and then i booed all over the place

* fixes

* fixes x2
This commit is contained in:
Jake 2025-11-05 20:20:53 +00:00 • committed by GitHub
parent 754b42efb9
commit d1c4cbc707
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 604 additions and 28 deletions

View file

@ -8,10 +8,23 @@ import { Channel, Message } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack";
import { MessageStore, useEffect, UserStore, useState, useStateFromStores } from "@webpack/common";
import { cl } from ".";
import { cl, settings } from ".";
import { IconGhost } from "./IconGhost";
function isChannelExempted(channelId: string): boolean {
const exemptList = settings.store.exemptedChannels
.split(",")
.map(id => id.trim())
.filter(id => id.length > 0);
return exemptList.includes(channelId);
}
const countedChannels = new Set<string>();
// track channels that were manually cleared and the message ID at time of clear
const clearedChannels = new Map<string, string>();
// listeners for when a channel is cleared or un-cleared (thororen is this allowed lolz)
const clearedChannelListeners = new Set<(channelId: string) => void>();
let _booCount = 0;
const listeners = new Set<(n: number) => void>();
@ -32,6 +45,39 @@ export function onBooCountChange(cb: (n: number) => void) {
};
}
export function onClearedChannelChange(cb: (channelId: string) => void) {
clearedChannelListeners.add(cb);
return () => {
clearedChannelListeners.delete(cb);
};
}
export function getGhostedChannels(): string[] {
return Array.from(countedChannels);
}
export function clearChannelFromGhost(channelId: string): void {
if (countedChannels.has(channelId)) {
countedChannels.delete(channelId);
setBooCount(getBooCount() - 1);
// so we can detect new messages from the other person
const lastMessage = MessageStore.getMessages(channelId)?.last();
if (lastMessage) {
clearedChannels.set(channelId, lastMessage.id);
}
// notify all listeners that this channel was cleared
for (const listener of clearedChannelListeners) {
listener(channelId);
}
}
}
export function isChannelCleared(channelId: string): boolean {
return clearedChannels.has(channelId);
}
const ChannelWrapperStyles = findByPropsLazy("muted", "wrapper");
export function Boo({ channel }: { channel: Channel; }) {
@ -47,6 +93,7 @@ export function Boo({ channel }: { channel: Channel; }) {
containsQuestionMark: false,
isDataProcessed: false,
});
const [isCleared, setIsCleared] = useState(false);
useEffect(() => {
if (!lastMessage || !currentUserId) return;
@ -61,25 +108,89 @@ export function Boo({ channel }: { channel: Channel; }) {
});
}, [lastMessage, currentUserId]);
// track if this channel was manually cleared
useEffect(() => {
setIsCleared(clearedChannels.has(id));
// subscribe to cleared channel changes for instant visual updates
const unsubscribe = onClearedChannelChange(clearedChannelId => {
if (clearedChannelId === id) {
// check current state: if it's still in clearedChannels, it was cleared; otherwise un-cleared
setIsCleared(clearedChannels.has(id));
}
});
return unsubscribe;
}, [id, lastMessage?.id]);
useEffect(() => {
if (!state.isDataProcessed) return;
const isExempted = isChannelExempted(id);
let wasManuallyCleared = clearedChannels.has(id);
// if manually cleared, check if there's a NEW message from the other person
if (wasManuallyCleared && !state.isCurrentUser) {
const clearedAtMessageId = clearedChannels.get(id);
const currentLastMessageId = lastMessage?.id;
// if it's the same message, stay cleared (don't re-ghost)
if (clearedAtMessageId === currentLastMessageId) {
return;
}
// if there's a NEW message from the OTHER person, remove from cleared state
// so it can be re-ghosted
if (currentLastMessageId !== clearedAtMessageId) {
clearedChannels.delete(id);
wasManuallyCleared = false; // update the flag since we deleted it
// notify listeners that this channel is no longer cleared (new message)
for (const listener of clearedChannelListeners) {
listener(id);
}
}
}
// if the current user responded, clear all tracking
if (state.isCurrentUser) {
if (countedChannels.has(id)) {
countedChannels.delete(id);
setBooCount(getBooCount() - 1);
}
if (clearedChannels.has(id)) {
clearedChannels.delete(id);
}
return;
}
// if exempted, remove from ghost tracking
if (isExempted) {
if (countedChannels.has(id)) {
countedChannels.delete(id);
setBooCount(getBooCount() - 1);
}
return;
}
// if manually cleared, don't add back to ghost count
if (wasManuallyCleared) {
return;
}
// normal ghosting logic: last message is from other person
if (!state.isCurrentUser) {
if (!countedChannels.has(id)) {
countedChannels.add(id);
setBooCount(getBooCount() + 1);
}
} else {
if (countedChannels.has(id)) {
countedChannels.delete(id);
setBooCount(getBooCount() - 1);
}
}
}, [state.isCurrentUser, state.isDataProcessed]);
}, [state.isCurrentUser, state.isDataProcessed, id, lastMessage?.id]);
if (!state.isDataProcessed || !currentUserId || !lastMessage || state.isCurrentUser)
if (!state.isDataProcessed || !currentUserId || !lastMessage || state.isCurrentUser || isChannelExempted(id) || isCleared)
return null;
if (!settings.store.showDmIcons) return null;
return (
<div className={cl("icon", ChannelWrapperStyles.wrapper)}>
<IconGhost fill={state.containsQuestionMark ? "#ff8000" : "currentColor"} />

View file

@ -0,0 +1,125 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize } from "@utils/modal";
import { findByPropsLazy } from "@webpack";
import { Avatar, Button, ChannelStore, MessageStore, React, Text, UserStore } from "@webpack/common";
const cl = classNameFactory("vc-boo-");
function formatMessageDate(timestamp: string | Date): string {
const date = new Date(timestamp);
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const year = String(date.getFullYear()).slice(-2);
return `${month}/${day}/${year}`;
}
const SelectedChannelActionCreators = findByPropsLazy("selectPrivateChannel");
interface GhostedUsersModalProps {
modalProps: any;
ghostedChannels: string[];
onClose: () => void;
onClearGhost: (channelId: string) => void;
}
export function GhostedUsersModal({ modalProps, ghostedChannels: initialChannels, onClose, onClearGhost }: GhostedUsersModalProps) {
const [ghostedChannels, setGhostedChannels] = React.useState(initialChannels);
const handleChannelClick = (channelId: string) => {
const channel = ChannelStore.getChannel(channelId);
if (channel) {
SelectedChannelActionCreators.selectPrivateChannel(channelId);
onClose();
}
};
const handleClearClick = (e: React.MouseEvent, channelId: string) => {
e.stopPropagation();
onClearGhost(channelId);
// update local state to remove the cleared channel
setGhostedChannels(prev => prev.filter(id => id !== channelId));
};
return (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalHeader>
<Text
variant="heading-lg/semibold"
style={{ flexGrow: 1 }}
>
Ghosted Users ({ghostedChannels.length})
</Text>
<ModalCloseButton onClick={onClose} />
</ModalHeader>
<ModalContent>
<div style={{ padding: "16px 0" }}>
{ghostedChannels.length === 0 ? (
<Text variant="text-md/normal">No ghosts here!</Text>
) : (
ghostedChannels.map(channelId => {
const channel = ChannelStore.getChannel(channelId);
if (!channel) return null;
const lastMessage = MessageStore.getMessages(channelId)?.last();
const lastMessageDate = lastMessage?.timestamp ? formatMessageDate(lastMessage.timestamp) : "";
const isGroupDM = channel.recipients?.length > 1;
let displayName: string;
let avatarSrc: string;
if (isGroupDM && lastMessage) {
// group dms show the last sender
const lastSender = UserStore.getUser(lastMessage.author.id);
displayName = lastSender?.username || "Unknown User";
avatarSrc = lastSender?.getAvatarURL(undefined, 128, true) || "";
} else {
// logic for one on one dms
const recipientId = channel.recipients?.[0];
const user = UserStore.getUser(recipientId);
displayName = user?.username || "Unknown User";
avatarSrc = user?.getAvatarURL(undefined, 128, true) || "";
}
return (
<div
key={channelId}
onClick={() => handleChannelClick(channelId)}
className={cl("ghosted-entry")}
>
<Avatar
src={avatarSrc}
size="SIZE_40"
aria-label={displayName}
/>
<div className={cl("user-info")}>
<Text variant="text-md/normal">
{displayName}
</Text>
{lastMessageDate && (
<Text variant="text-xs/normal" style={{ color: "var(--text-muted)" }}>
{lastMessageDate}
</Text>
)}
</div>
<Button
size={Button.Sizes.SMALL}
color={Button.Colors.PRIMARY}
onClick={e => handleClearClick(e, channelId)}
>
Clear
</Button>
</div>
);
})
)}
</div>
</ModalContent>
</ModalRoot>
);
}

View file

@ -0,0 +1,71 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { createAudioPlayer } from "@api/AudioPlayer";
import { classNameFactory } from "@api/Styles";
import { React } from "@webpack/common";
const cl = classNameFactory("vc-boo-");
// GitHub raw URLs for hosted assets (from Equibored repository)
const JUMPSCARE_IMAGE_URL = "https://raw.githubusercontent.com/Equicord/Equibored/03e4650cc71591796d60c40d6105c31fce9bb481/icons/boo/job.png";
const JUMPSCARE_SOUND_URL = "https://raw.githubusercontent.com/Equicord/Equibored/03e4650cc71591796d60c40d6105c31fce9bb481/sounds/boo/vine-boom.mp3";
interface JumpscareOverlayProps {
onClose: () => void;
}
export function JumpscareOverlay({ onClose }: JumpscareOverlayProps) {
const audioPlayer = React.useRef(createAudioPlayer(JUMPSCARE_SOUND_URL, { volume: 100 }));
const [isClosing, setIsClosing] = React.useState(false);
React.useEffect(() => {
try {
audioPlayer.current?.play();
} catch (err) {
console.error("Failed to play jumpscare sound:", err); // scarier than a job application
}
const timer = setTimeout(() => {
startClosing();
}, 3000);
return () => {
clearTimeout(timer);
audioPlayer.current?.stop();
};
}, [onClose]);
const startClosing = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
}, 500);
};
const handleClick = () => {
audioPlayer.current?.stop();
startClosing();
};
return (
<div
onClick={handleClick}
className={cl("jumpscare-overlay")}
style={{
animation: isClosing
? "boo-jumpscare-fade-out 0.5s ease-out forwards"
: "boo-jumpscare-fade 0.2s ease-in forwards"
}}
>
<img
src={JUMPSCARE_IMAGE_URL}
alt="Jumpscare"
className={cl("jumpscare-image")}
/>
</div>
);
}

View file

@ -7,49 +7,194 @@
import "./styles.css";
import { addServerListElement, removeServerListElement, ServerListRenderPosition } from "@api/ServerList";
import { definePluginSettings } from "@api/Settings";
import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary";
import { Devs, EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { closeModal, openModal } from "@utils/modal";
import definePlugin, { OptionType } from "@utils/types";
import { Channel } from "@vencord/discord-types";
import { Tooltip, useEffect, useState } from "@webpack/common";
import { ChannelStore, MessageStore, Tooltip, useEffect, UserStore, useState } from "@webpack/common";
import { Boo, getBooCount, onBooCountChange } from "./Boo";
import { Boo, clearChannelFromGhost, getBooCount, getGhostedChannels, onBooCountChange } from "./Boo";
import { GhostedUsersModal } from "./GhostedUsersModal";
import { IconGhost } from "./IconGhost";
import { JumpscareOverlay } from "./JumpscareOverlay";
export const cl = classNameFactory("vc-boo-");
let jumpscareTimerId: NodeJS.Timeout | null = null;
let showJumpscareOverlay = false;
const jumpscareCallbacks: Set<() => void> = new Set();
function triggerJumpscare() {
showJumpscareOverlay = true;
for (const callback of jumpscareCallbacks) {
callback();
}
}
function closeJumpscare() {
showJumpscareOverlay = false;
for (const callback of jumpscareCallbacks) {
callback();
}
}
function resetJumpscareTimer() {
if (jumpscareTimerId) {
clearTimeout(jumpscareTimerId);
jumpscareTimerId = null;
}
const count = getBooCount();
if (count > 0 && settings.store.Boo) {
// this is such a bad way to do this LMFAO
const milliseconds = 60 * 60 * 1000;
jumpscareTimerId = setTimeout(() => {
if (getBooCount() > 0) {
triggerJumpscare();
}
}, milliseconds);
}
}
export const settings = definePluginSettings({
showIndicator: {
type: OptionType.BOOLEAN,
description: "Show the ghost counter at the top of the server list",
default: true,
restartNeeded: false
},
showDmIcons: {
type: OptionType.BOOLEAN,
description: "Show ghost icons next to individual DMs",
default: true,
restartNeeded: false
},
exemptedChannels: {
type: OptionType.STRING,
description: "Comma-separated list of channel IDs to exempt from ghosting (right-click a DM channel to copy its ID)",
default: "",
restartNeeded: false
},
Boo: {
type: OptionType.BOOLEAN,
description: "Something might happen if you ignore someone for too long...",
default: true,
restartNeeded: false
}
});
function getChannelDisplayName(channelId: string): string {
const channel = ChannelStore.getChannel(channelId);
if (!channel) return "Unknown";
// get last message to determine sender for group DMs
const lastMessage = MessageStore.getMessages(channelId)?.last();
// check if it's a group DM
if (channel.recipients?.length > 1 && lastMessage) {
// show last message sender for group DMs
const lastSender = UserStore.getUser(lastMessage.author.id);
return lastSender?.username || "Unknown User";
}
// 1-on-1 DM
const recipientId = channel.recipients?.[0];
const user = UserStore.getUser(recipientId);
return user?.username || "Unknown User";
}
function BooIndicator() {
const [count, setCount] = useState(getBooCount());
const [showJumpscare, setShowJumpscare] = useState(false);
useEffect(() => {
const unsubscribe = onBooCountChange(setCount);
return () => unsubscribe();
const unsubscribe = onBooCountChange(newCount => {
setCount(newCount);
resetJumpscareTimer();
});
// register jumpscare callback
const jumpscareCallback = () => {
setShowJumpscare(showJumpscareOverlay);
};
jumpscareCallbacks.add(jumpscareCallback);
// lucky lucky
resetJumpscareTimer();
return () => {
unsubscribe();
jumpscareCallbacks.delete(jumpscareCallback);
if (jumpscareTimerId) {
clearTimeout(jumpscareTimerId);
jumpscareTimerId = null;
}
};
}, []);
if (!settings.store.showIndicator && !showJumpscare) return null;
const handleClick = () => {
const ghostedChannels = getGhostedChannels();
const modalKey = openModal(modalProps => (
<ErrorBoundary>
<GhostedUsersModal
modalProps={modalProps}
ghostedChannels={ghostedChannels}
onClose={() => closeModal(modalKey)}
onClearGhost={clearChannelFromGhost}
/>
</ErrorBoundary>
));
};
const getTooltipText = () => {
const ghostedChannels = getGhostedChannels();
if (ghostedChannels.length === 0) {
return "No Ghosted Users";
}
if (ghostedChannels.length <= 5) {
return ghostedChannels
.map(id => getChannelDisplayName(id))
.join(", ");
}
return `${ghostedChannels.length} Ghosted Users`;
};
return (
<div id={cl("container")}>
<Tooltip text={`${count} Ghosted Users`} position="right">
{({ onMouseEnter, onMouseLeave }) => (
<div
id={cl("container")}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{count} <IconGhost fill="currentColor" />
</div>
)}
</Tooltip>
</div>
<>
{showJumpscare && <JumpscareOverlay onClose={closeJumpscare} />}
{settings.store.showIndicator && (
<div id={cl("container")}>
<Tooltip text={getTooltipText()} position="right">
{({ onMouseEnter, onMouseLeave }) => (
<div
id={cl("container")}
className={cl("clickable")}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={handleClick}
>
{count} <IconGhost fill="currentColor" />
</div>
)}
</Tooltip>
</div>
)}
</>
);
}
export default definePlugin({
name: "Boo",
description: "A cute ghost will appear if you don't answer their DMs",
authors: [EquicordDevs.vei, Devs.sadan],
authors: [EquicordDevs.vei, Devs.sadan, EquicordDevs.justjxke],
settings,
dependencies: ["AudioPlayerAPI"],
patches: [
{

View file

@ -13,4 +13,128 @@
margin-left: 8px;
color: var(--text-default);
fill: var(--text-default);
}
}
.vc-boo-clickable {
cursor: pointer;
transition: opacity 0.2s ease;
}
.vc-boo-clickable:hover {
opacity: 0.8;
}
.vc-boo-clickable:active {
opacity: 0.6;
}
/* modal styling */
.vc-boo-ghosted-entry {
padding: 12px;
cursor: pointer;
border-radius: 8px;
margin-bottom: 8px;
background-color: var(--background-secondary);
display: flex;
align-items: center;
gap: 8px;
transition: background-color 0.1s ease;
}
.vc-boo-ghosted-entry:hover {
background-color: var(--background-modifier-hover);
}
.vc-boo-user-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
/* Jumpscare overlay styling */
.vc-boo-jumpscare-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgb(0 0 0 / 50%);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: pointer;
}
.vc-boo-jumpscare-image {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
/* job application */
@keyframes boo-jumpscare-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes boo-jumpscare-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes boo-jumpscare-shake {
/* stylelint-disable-next-line rule-empty-line-before */
0%,
100% {
transform: translate(0, 0) rotate(0deg);
}
10% {
transform: translate(-5px, -5px) rotate(-2deg);
}
20% {
transform: translate(5px, 5px) rotate(2deg);
}
30% {
transform: translate(-5px, 5px) rotate(-1deg);
}
40% {
transform: translate(5px, -5px) rotate(1deg);
}
50% {
transform: translate(-3px, -3px) rotate(-1deg);
}
60% {
transform: translate(3px, 3px) rotate(1deg);
}
70% {
transform: translate(-3px, 3px) rotate(-0.5deg);
}
80% {
transform: translate(3px, -3px) rotate(0.5deg);
}
90% {
transform: translate(-2px, -2px) rotate(-0.5deg);
}
}