From d1c4cbc7070788f75119043a517a1f86c9bf6192 Mon Sep 17 00:00:00 2001 From: Jake <134332787+justjxke@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:20:53 +0000 Subject: [PATCH] feat/boo (#481) * its booing time, and then i booed all over the place * fixes * fixes x2 --- src/equicordplugins/boo/Boo.tsx | 127 +++++++++++- src/equicordplugins/boo/GhostedUsersModal.tsx | 125 ++++++++++++ src/equicordplugins/boo/JumpscareOverlay.tsx | 71 +++++++ src/equicordplugins/boo/index.tsx | 183 ++++++++++++++++-- src/equicordplugins/boo/styles.css | 126 +++++++++++- 5 files changed, 604 insertions(+), 28 deletions(-) create mode 100644 src/equicordplugins/boo/GhostedUsersModal.tsx create mode 100644 src/equicordplugins/boo/JumpscareOverlay.tsx diff --git a/src/equicordplugins/boo/Boo.tsx b/src/equicordplugins/boo/Boo.tsx index adf8b1aee..8e96ae4c1 100644 --- a/src/equicordplugins/boo/Boo.tsx +++ b/src/equicordplugins/boo/Boo.tsx @@ -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(); +// track channels that were manually cleared and the message ID at time of clear +const clearedChannels = new Map(); +// 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 (
diff --git a/src/equicordplugins/boo/GhostedUsersModal.tsx b/src/equicordplugins/boo/GhostedUsersModal.tsx new file mode 100644 index 000000000..312410146 --- /dev/null +++ b/src/equicordplugins/boo/GhostedUsersModal.tsx @@ -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 ( + + + + Ghosted Users ({ghostedChannels.length}) + + + + +
+ {ghostedChannels.length === 0 ? ( + No ghosts here! + ) : ( + 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 ( +
handleChannelClick(channelId)} + className={cl("ghosted-entry")} + > + +
+ + {displayName} + + {lastMessageDate && ( + + {lastMessageDate} + + )} +
+ +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/src/equicordplugins/boo/JumpscareOverlay.tsx b/src/equicordplugins/boo/JumpscareOverlay.tsx new file mode 100644 index 000000000..8dac73183 --- /dev/null +++ b/src/equicordplugins/boo/JumpscareOverlay.tsx @@ -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 ( +
+ Jumpscare +
+ ); +} diff --git a/src/equicordplugins/boo/index.tsx b/src/equicordplugins/boo/index.tsx index b2ca9dffc..5b7e4d1ab 100644 --- a/src/equicordplugins/boo/index.tsx +++ b/src/equicordplugins/boo/index.tsx @@ -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 => ( + + closeModal(modalKey)} + onClearGhost={clearChannelFromGhost} + /> + + )); + }; + + 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 ( -
- - {({ onMouseEnter, onMouseLeave }) => ( -
- {count} -
- )} -
-
+ <> + {showJumpscare && } + {settings.store.showIndicator && ( +
+ + {({ onMouseEnter, onMouseLeave }) => ( +
+ {count} +
+ )} +
+
+ )} + ); } 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: [ { diff --git a/src/equicordplugins/boo/styles.css b/src/equicordplugins/boo/styles.css index 7d278a3cf..a790b9a0d 100644 --- a/src/equicordplugins/boo/styles.css +++ b/src/equicordplugins/boo/styles.css @@ -13,4 +13,128 @@ margin-left: 8px; color: var(--text-default); fill: var(--text-default); -} \ No newline at end of file +} + +.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); + } +}