feat/boo (#481)
* its booing time, and then i booed all over the place * fixes * fixes x2
This commit is contained in:
parent
754b42efb9
commit
d1c4cbc707
5 changed files with 604 additions and 28 deletions
|
|
@ -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"} />
|
||||
|
|
|
|||
125
src/equicordplugins/boo/GhostedUsersModal.tsx
Normal file
125
src/equicordplugins/boo/GhostedUsersModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/equicordplugins/boo/JumpscareOverlay.tsx
Normal file
71
src/equicordplugins/boo/JumpscareOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue