feat(plugin): ScheduledMessages (#534)

* scheduledMessages plugin developed and created

* some fixes

* Updated buttons to use @components/button

* webpack message model patch added, fixed media display/persistence, added day counter, and improve rate limit handling

* Refactored codebase of plugin thanks to Prism and found a new method for handling reactions

* now using isPrivate() for DM checks, and isMultiUserDM check removed because of redundancy, simplified into isDM and isGroupDM for 1:1 DMs and group DMs respectively. Now using discord's channel.isPrivate() method as opposed to the isDM, isGroupDM and isMultiUserDM checks

* I did as I was told

---------

Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
OMeta 2025-12-04 19:07:01 -05:00 • committed by GitHub
parent faf342312e
commit ce04141b7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1497 additions and 0 deletions

View file

@ -0,0 +1,54 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { useEffect, useState } from "@webpack/common";
import { CalendarIcon } from "./Icons";
import { openViewScheduledModal } from "./ViewScheduledModal";
export let isScheduleModeEnabled = false;
const stateUpdaters = new Set<(value: boolean) => void>();
export function setScheduleModeEnabled(value: boolean): void {
isScheduleModeEnabled = value;
stateUpdaters.forEach(updater => updater(value));
}
export const ScheduledMessagesButton: ChatBarButtonFactory = ({ isMainChat }) => {
const [enabled, setEnabled] = useState(isScheduleModeEnabled);
useEffect(() => {
stateUpdaters.add(setEnabled);
return () => { stateUpdaters.delete(setEnabled); };
}, []);
if (!isMainChat) return null;
const toggleScheduleMode = () => {
const newValue = !enabled;
setScheduleModeEnabled(newValue);
};
const tooltip = enabled
? "Schedule Mode ON (click to disable, right-click for list)"
: "Schedule Mode OFF (click to enable, right-click for list)";
return (
<ChatBarButton
tooltip={tooltip}
onClick={toggleScheduleMode}
onContextMenu={e => {
e.preventDefault();
openViewScheduledModal();
}}
buttonProps={{ "aria-haspopup": "dialog" }}
>
<CalendarIcon color={enabled ? "var(--status-positive)" : undefined} />
</ChatBarButton>
);
};

View file

@ -0,0 +1,57 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
interface IconProps {
width?: number | string;
height?: number | string;
className?: string;
color?: string;
}
export function CalendarIcon({ width = 20, height = 20, className, color }: IconProps) {
return (
<svg
aria-hidden="true"
role="img"
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
>
<g fill="none" fillRule="evenodd">
<path
fill={color ?? "currentColor"}
d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm-8 4H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2z"
/>
<rect width="24" height="24" />
</g>
</svg>
);
}
export function TimerIcon({ width = 20, height = 20, className }: IconProps) {
return (
<svg
aria-hidden="true"
role="img"
width={width}
height={height}
className={className}
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42A8.962 8.962 0 0 0 12 4c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-2.12-.74-4.07-1.97-5.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z" />
</svg>
);
}
export function ErrorIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg>
);
}

View file

@ -0,0 +1,39 @@
/*
* 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 { ExtendedMessage } from "../types";
import { phantomMessageMap } from "../utils";
import { TimerIcon } from "./Icons";
const cl = classNameFactory("vc-scheduled-msg-");
export function MessageAccessory({ message }: { message: ExtendedMessage; }) {
const data = phantomMessageMap.get(message?.id) ?? message?.scheduledMessageData;
if (!data) return null;
const { scheduledTime } = data;
const timeLeft = scheduledTime - Date.now();
let timeLeftStr = "";
if (timeLeft > 0) {
const mins = Math.floor(timeLeft / 60000);
const hrs = Math.floor(mins / 60);
const days = Math.floor(hrs / 24);
timeLeftStr = days > 0 ? ` (${days}d ${hrs % 24}h remaining)`
: hrs > 0 ? ` (${hrs}h ${mins % 60}m remaining)`
: ` (${mins}m remaining)`;
}
return (
<div className={cl("accessory")}>
<TimerIcon width={14} height={14} />
<span>Scheduled for {new Date(scheduledTime).toLocaleString()}{timeLeftStr}</span>
</div>
);
}

View file

@ -0,0 +1,155 @@
/*
* 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 { Button } from "@components/Button";
import ErrorBoundary from "@components/ErrorBoundary";
import { Heading } from "@components/Heading";
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
import { findByPropsLazy } from "@webpack";
import { ChannelStore, DraftType, showToast, TextInput, Toasts, UploadManager, useState } from "@webpack/common";
import { ScheduledAttachment } from "../types";
import { addScheduledMessage, getChannelDisplayInfo } from "../utils";
import { ErrorIcon } from "./Icons";
const cl = classNameFactory("vc-scheduled-msg-");
const ComponentDispatch = findByPropsLazy("dispatchToLastSubscribed");
function ScheduleTimeModalInner({ channelId, content, attachments, rootProps, close }: {
channelId: string;
content: string;
attachments?: ScheduledAttachment[];
rootProps: ModalProps;
close: () => void;
}) {
const [scheduleType, setScheduleType] = useState<"delay" | "time">("delay");
const [delayMinutes, setDelayMinutes] = useState("5");
const [scheduledDateTime, setScheduledDateTime] = useState("");
const [error, setError] = useState("");
const { name, avatar } = getChannelDisplayInfo(channelId);
const channel = ChannelStore.getChannel(channelId);
if (!channel) return null;
const isDM = channel.isPrivate();
const handleSchedule = async () => {
let scheduledTime: number;
if (scheduleType === "delay") {
const minutes = parseInt(delayMinutes, 10);
if (isNaN(minutes) || minutes < 1) {
setError("Please enter a valid delay (minimum 1 minute)");
return;
}
scheduledTime = Date.now() + minutes * 60 * 1000;
} else {
const dateTime = new Date(scheduledDateTime).getTime();
if (isNaN(dateTime) || dateTime <= Date.now()) {
setError("Please select a future date and time");
return;
}
scheduledTime = dateTime;
}
const result = await addScheduledMessage(channelId, content, scheduledTime, attachments);
if (result.success) {
ComponentDispatch.dispatchToLastSubscribed("CLEAR_TEXT");
UploadManager.clearAll(channelId, DraftType.ChannelMessage);
showToast("Message scheduled!", Toasts.Type.SUCCESS);
close();
} else {
setError(result.error ?? "Failed to schedule message");
}
};
return (
<ModalRoot {...rootProps}>
<ModalHeader className={cl("modal-header")}>
<Heading tag="h2" className={cl("modal-title")}>Schedule Message</Heading>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className={cl("modal-content")}>
<div className={cl("channel-info")}>
{avatar && <img src={avatar} className={cl("channel-avatar")} alt="" />}
<span className={cl("channel-text")}>
Scheduling for: <strong>{isDM ? name : `#${name}`}</strong>
</span>
</div>
<Heading tag="h5" className={cl("field-label")}>Schedule Type</Heading>
<div className={cl("schedule-type-buttons")}>
<Button
size="small"
variant={scheduleType === "delay" ? "primary" : "secondary"}
onClick={() => setScheduleType("delay")}
>
Delay
</Button>
<Button
size="small"
variant={scheduleType === "time" ? "primary" : "secondary"}
onClick={() => setScheduleType("time")}
>
Specific Time
</Button>
</div>
{scheduleType === "delay" ? (
<>
<Heading tag="h5" className={cl("field-label")}>Delay (minutes)</Heading>
<TextInput
value={delayMinutes}
onChange={setDelayMinutes}
placeholder="5"
type="number"
/>
</>
) : (
<>
<Heading tag="h5" className={cl("field-label")}>Date & Time</Heading>
<input
type="datetime-local"
className={cl("datetime-input")}
value={scheduledDateTime}
onChange={e => setScheduledDateTime(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
/>
</>
)}
{error && (
<div className={cl("error")}>
<ErrorIcon />
<span>{error}</span>
</div>
)}
</ModalContent>
<ModalFooter className={cl("modal-footer")}>
<Button onClick={handleSchedule} variant="positive">Schedule</Button>
<Button variant="secondary" onClick={close}>Cancel</Button>
</ModalFooter>
</ModalRoot>
);
}
export const ScheduleTimeModal = ErrorBoundary.wrap(ScheduleTimeModalInner, { noop: true });
export function openScheduleTimeModal(channelId: string, content: string, attachments?: ScheduledAttachment[]): void {
const key = openModal(props => (
<ScheduleTimeModal
channelId={channelId}
content={content}
attachments={attachments}
rootProps={props}
close={() => closeModal(key)}
/>
));
}

View file

@ -0,0 +1,111 @@
/*
* 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 { Button } from "@components/Button";
import ErrorBoundary from "@components/ErrorBoundary";
import { Heading } from "@components/Heading";
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
import { ChannelStore, showToast, Toasts, useState } from "@webpack/common";
import { clearAllScheduledMessages, getChannelDisplayInfo, getScheduledMessages, removeScheduledMessage } from "../utils";
import { CalendarIcon, TimerIcon } from "./Icons";
const cl = classNameFactory("vc-scheduled-msg-");
interface ViewScheduledModalProps {
rootProps: ModalProps;
close: () => void;
}
function ViewScheduledModalInner({ rootProps, close }: ViewScheduledModalProps) {
const [messages, setMessages] = useState(getScheduledMessages());
const handleDelete = async (id: string) => {
await removeScheduledMessage(id);
setMessages(getScheduledMessages());
showToast("Scheduled message removed", Toasts.Type.SUCCESS);
};
const handleClearAll = async () => {
await clearAllScheduledMessages();
setMessages([]);
showToast("All scheduled messages cleared", Toasts.Type.SUCCESS);
};
return (
<ModalRoot {...rootProps}>
<ModalHeader className={cl("modal-header")}>
<Heading tag="h2" className={cl("modal-title")}>Scheduled Messages</Heading>
{messages.length > 0 && (
<Button size="small" variant="dangerPrimary" onClick={handleClearAll}>
Clear All
</Button>
)}
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className={cl("modal-content")}>
{!messages.length ? (
<div className={cl("empty-state")}>
<CalendarIcon width={48} height={48} />
<span>No scheduled messages</span>
</div>
) : (
<div className={cl("message-list")}>
{messages.map(msg => {
const { name, avatar } = getChannelDisplayInfo(msg.channelId);
const channel = ChannelStore.getChannel(msg.channelId);
if (!channel) return null;
const isDM = channel.isPrivate();
const displayContent = msg.content.length > 200
? msg.content.slice(0, 200) + "..."
: msg.content;
return (
<div key={msg.id} className={cl("message-item")}>
<div className={cl("message-info")}>
<div className={cl("message-header")}>
{avatar && <img src={avatar} className={cl("message-avatar")} alt="" />}
<span className={cl("message-channel")}>
{isDM ? name : `#${name}`}
</span>
</div>
<div className={cl("message-time")}>
<TimerIcon width={14} height={14} />
<span>{new Date(msg.scheduledTime).toLocaleString()}</span>
</div>
<div className={cl("message-content")}>{displayContent}</div>
</div>
<Button
size="small"
variant="dangerPrimary"
onClick={() => handleDelete(msg.id)}
>
Delete
</Button>
</div>
);
})}
</div>
)}
</ModalContent>
<ModalFooter className={cl("modal-footer")}>
<Button onClick={close} variant="secondary">Close</Button>
</ModalFooter>
</ModalRoot>
);
}
export const ViewScheduledModal = ErrorBoundary.wrap(ViewScheduledModalInner, { noop: true });
export function openViewScheduledModal(): void {
const key = openModal(props => (
<ViewScheduledModal rootProps={props} close={() => closeModal(key)} />
));
}

View file

@ -0,0 +1,181 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./styles.css";
import { MessageOptions } from "@api/MessageEvents";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType } from "@utils/types";
import { isScheduleModeEnabled, ScheduledMessagesButton, setScheduleModeEnabled } from "./components/ChatBarButton";
import { CalendarIcon } from "./components/Icons";
import { MessageAccessory } from "./components/MessageAccessory";
import { openScheduleTimeModal } from "./components/ScheduleTimeModal";
import { openViewScheduledModal } from "./components/ViewScheduledModal";
import { FluxReactionEvent, ScheduledAttachment } from "./types";
import {
cleanupAllPhantomMessages,
handleReactionAdd,
handleReactionRemove,
isPhantomMessage,
loadScheduledMessages,
recreatePhantomMessages,
resyncPhantomReactions,
startScheduler,
stopScheduler
} from "./utils";
const logger = new Logger("ScheduledMessages");
export const settings = definePluginSettings({
maxMessagesPerMinute: {
type: OptionType.SLIDER,
description: "Max scheduled messages per channel that can fire in the same minute.",
markers: [1, 2, 3, 4, 5],
default: 3,
stickToMarkers: true
},
checkIntervalSeconds: {
type: OptionType.SLIDER,
description: "How often to check for messages to send (seconds).",
markers: [5, 10, 15, 30, 60],
default: 10,
stickToMarkers: true,
onChange: () => {
stopScheduler();
startScheduler();
}
},
showNotifications: {
type: OptionType.BOOLEAN,
description: "Show toast notifications when messages are sent.",
default: true
},
showPhantomMessages: {
type: OptionType.BOOLEAN,
description: "Show scheduled messages as phantom messages in chat.",
default: true
}
});
function handleReactionEvent(event: FluxReactionEvent): void {
const { messageId, channelId, emoji } = event;
logger.info("handleReactionEvent:", { type: event.type, messageId, optimistic: event.optimistic, emoji: emoji?.name });
if (!messageId || !channelId || !emoji) {
logger.warn("handleReactionEvent: Missing required fields");
return;
}
const isPhantom = isPhantomMessage(messageId);
logger.info("handleReactionEvent: isPhantomMessage =", isPhantom);
if (!isPhantom) return;
if (event.optimistic) {
// User-initiated reaction change - update our data
logger.info("handleReactionEvent: Processing optimistic", event.type);
if (event.type === "MESSAGE_REACTION_ADD") {
handleReactionAdd(messageId, channelId, emoji);
} else if (event.type === "MESSAGE_REACTION_REMOVE") {
handleReactionRemove(messageId, channelId, emoji);
}
} else {
logger.info("handleReactionEvent: Non-optimistic event, scheduling resync");
setTimeout(() => {
resyncPhantomReactions(messageId, channelId);
}, 50);
}
}
export default definePlugin({
name: "ScheduledMessages",
description: "Schedule messages to be sent at a specific time or after a delay.",
authors: [EquicordDevs.mmeta, EquicordDevs.Prism],
settings,
flux: {
MESSAGE_REACTION_ADD: handleReactionEvent,
MESSAGE_REACTION_REMOVE: handleReactionEvent
},
chatBarButton: {
icon: CalendarIcon,
render: ScheduledMessagesButton
},
toolboxActions: {
"View Scheduled Messages": openViewScheduledModal
},
patches: [
{
find: "}addReaction(",
replacement: {
match: /this\.customRenderedContent=(\i)\.customRenderedContent,/,
replace: "$&this.scheduledMessageData=$1.scheduledMessageData,"
}
}
],
renderMessageAccessory(props: Record<string, any>) {
return <MessageAccessory message={props.message} />;
},
async onBeforeMessageSend(channelId: string, messageObj: { content: string; }, options: MessageOptions) {
if (!isScheduleModeEnabled) return;
if (!messageObj.content.trim() && !options.uploads?.length) return;
setScheduleModeEnabled(false);
let attachments: ScheduledAttachment[] | undefined;
if (options.uploads?.length) {
attachments = [];
for (const upload of options.uploads) {
const { file } = upload.item;
try {
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result.split(",")[1] ?? result);
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
attachments.push({
filename: upload.filename,
data: base64,
type: file.type
});
} catch {
continue;
}
}
}
openScheduleTimeModal(channelId, messageObj.content, attachments);
return { cancel: true };
},
async start() {
await loadScheduledMessages();
startScheduler();
recreatePhantomMessages();
},
stop() {
stopScheduler();
cleanupAllPhantomMessages();
}
});

View file

@ -0,0 +1,276 @@
/* Modal Header */
.vc-scheduled-msg-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
gap: 16px;
}
.vc-scheduled-msg-modal-title {
flex: 1;
margin: 0 !important;
color: var(--header-primary);
font-size: 20px;
font-weight: 600;
}
/* Modal Content */
.vc-scheduled-msg-modal-content {
padding: 0 16px 16px;
}
/* Modal Footer */
.vc-scheduled-msg-modal-footer {
display: flex;
flex-direction: row-reverse;
justify-content: flex-start;
align-items: center;
gap: 8px;
padding: 16px;
}
/* Channel Info */
.vc-scheduled-msg-channel-info {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 12px;
background: var(--background-secondary);
border-radius: 8px;
}
.vc-scheduled-msg-channel-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
/* Dark theme channel text */
.theme-dark .vc-scheduled-msg-channel-text {
color: #dbdee1 !important;
}
.theme-dark .vc-scheduled-msg-channel-text strong {
color: #f2f3f5 !important;
}
/* Light theme channel text */
.theme-light .vc-scheduled-msg-channel-text {
color: #313338 !important;
}
.theme-light .vc-scheduled-msg-channel-text strong {
color: #060607 !important;
}
/* Field Labels */
.vc-scheduled-msg-field-label {
margin: 16px 0 8px !important;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* Dark theme field labels */
.theme-dark .vc-scheduled-msg-field-label {
color: #b5bac1 !important;
}
/* Light theme field labels */
.theme-light .vc-scheduled-msg-field-label {
color: #4e5058 !important;
}
/* Schedule Type Buttons */
.vc-scheduled-msg-schedule-type-buttons {
display: flex;
gap: 8px;
}
/* DateTime Input */
.vc-scheduled-msg-datetime-input {
width: 100%;
padding: 10px;
border: none;
border-radius: 3px;
background: var(--input-background);
font-size: 16px;
}
.vc-scheduled-msg-datetime-input:focus {
outline: none;
}
/* Dark theme datetime */
.theme-dark .vc-scheduled-msg-datetime-input {
color: #dbdee1 !important;
color-scheme: dark;
}
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-calendar-picker-indicator {
filter: invert(1);
}
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-datetime-edit,
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-fields-wrapper,
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-text,
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-month-field,
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-day-field,
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-year-field,
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-hour-field,
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-minute-field,
.theme-dark .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-ampm-field {
color: #dbdee1 !important;
}
/* Light theme datetime */
.theme-light .vc-scheduled-msg-datetime-input {
color: #313338 !important;
color-scheme: light;
}
.theme-light .vc-scheduled-msg-datetime-input::-webkit-calendar-picker-indicator {
filter: none;
}
.theme-light .vc-scheduled-msg-datetime-input::-webkit-datetime-edit,
.theme-light .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-fields-wrapper,
.theme-light .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-text,
.theme-light .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-month-field,
.theme-light .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-day-field,
.theme-light .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-year-field,
.theme-light .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-hour-field,
.theme-light .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-minute-field,
.theme-light .vc-scheduled-msg-datetime-input::-webkit-datetime-edit-ampm-field {
color: #313338 !important;
}
/* Error Display */
.vc-scheduled-msg-error {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
padding: 12px;
background: var(--info-danger-background);
border-radius: 5px;
color: var(--text-normal);
font-size: 14px;
}
.vc-scheduled-msg-error svg {
flex-shrink: 0;
color: var(--status-danger);
}
/* Empty State */
.vc-scheduled-msg-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 32px;
color: var(--text-muted);
}
.vc-scheduled-msg-empty-state svg {
opacity: 0.5;
}
/* Message List */
.vc-scheduled-msg-message-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 400px;
overflow-y: auto;
}
.vc-scheduled-msg-message-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: var(--background-secondary);
border-radius: 8px;
}
.vc-scheduled-msg-message-info {
flex: 1;
min-width: 0;
}
.vc-scheduled-msg-message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.vc-scheduled-msg-message-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.vc-scheduled-msg-message-channel {
font-weight: 600;
color: var(--header-primary);
}
.vc-scheduled-msg-message-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 8px;
}
.vc-scheduled-msg-message-content {
overflow-wrap: break-word;
white-space: pre-wrap;
}
/* Dark theme message content */
.theme-dark .vc-scheduled-msg-message-content {
color: #dbdee1 !important;
}
/* Light theme message content */
.theme-light .vc-scheduled-msg-message-content {
color: #313338 !important;
}
/* Phantom Message Styles */
.vc-scheduled-msg-phantom {
opacity: 0.5 !important;
}
.vc-scheduled-msg-phantom:hover {
opacity: 0.7 !important;
}
/* Scheduled message accessory */
.vc-scheduled-msg-accessory {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 4px 8px;
background: var(--background-secondary);
border-radius: 4px;
font-size: 12px;
color: var(--text-muted);
pointer-events: none;
width: fit-content;
}

View file

@ -0,0 +1,57 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Message } from "@vencord/discord-types";
export interface ScheduledReaction {
emoji: {
id: string | null;
name: string;
animated?: boolean;
};
count: number;
}
export interface ScheduledAttachment {
filename: string;
data: string;
type: string;
}
export interface ScheduledMessage {
id: string;
channelId: string;
content: string;
scheduledTime: number;
createdAt: number;
reactions?: ScheduledReaction[];
attachments?: ScheduledAttachment[];
}
export interface PhantomMessageData {
scheduledTime: number;
messageId: string;
channelId: string;
}
export interface FluxReactionEvent {
type: string;
messageId?: string;
channelId?: string;
emoji?: {
id: string | null;
name: string;
animated?: boolean;
};
optimistic?: boolean;
}
export interface ExtendedMessage extends Message {
scheduledMessageData?: {
scheduledTime: number;
messageId: string;
};
}

View file

@ -0,0 +1,567 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import * as DataStore from "@api/DataStore";
import { Logger } from "@utils/Logger";
import { CloudUpload, Message } from "@vencord/discord-types";
import { CloudUploadPlatform } from "@vencord/discord-types/enums";
import { findLazy } from "@webpack";
import { ChannelStore, Constants, FluxDispatcher, GuildStore, IconUtils, MessageActions, MessageStore, RestAPI, showToast, SnowflakeUtils, Toasts, UserStore } from "@webpack/common";
import { settings } from ".";
import { PhantomMessageData, ScheduledAttachment, ScheduledMessage, ScheduledReaction } from "./types";
const logger = new Logger("ScheduledMessages");
const CloudUploader = findLazy(m => m.prototype?.trackUploadFinished) as typeof CloudUpload;
const STORAGE_KEY = "ScheduledMessages_queue";
let scheduledMessages: ScheduledMessage[] = [];
let checkInterval: ReturnType<typeof setInterval> | null = null;
let isProcessingMessages = false;
export const phantomMessageMap = new Map<string, PhantomMessageData>();
const pendingReactions = new Map<string, ScheduledReaction[]>();
const recentReactionChanges = new Map<string, { action: string; timestamp: number; }>();
const REACTION_COOLDOWN_MS = 2000;
const pendingRecreations = new Map<string, ReturnType<typeof setTimeout>>();
const RECREATE_DEBOUNCE_MS = 300;
export async function loadScheduledMessages(): Promise<void> {
const saved = await DataStore.get<ScheduledMessage[]>(STORAGE_KEY);
scheduledMessages = Array.isArray(saved) ? saved : [];
}
async function saveScheduledMessages(): Promise<void> {
await DataStore.set(STORAGE_KEY, scheduledMessages);
}
export function getScheduledMessages(): ScheduledMessage[] {
return [...scheduledMessages];
}
export function getChannelDisplayInfo(channelId: string): { name: string; avatar: string; } {
const channel = ChannelStore.getChannel(channelId);
if (!channel) return { name: "Unknown", avatar: "" };
if (channel.isDM()) {
const user = channel.recipients?.[0] ? UserStore.getUser(channel.recipients[0]) : null;
return user
? { name: user.globalName ?? user.username, avatar: IconUtils.getUserAvatarURL(user, true, 64) }
: { name: "DM", avatar: "" };
}
if (channel.isGroupDM() || channel.isMultiUserDM()) {
return { name: channel.name || "Group DM", avatar: IconUtils.getChannelIconURL(channel) ?? "" };
}
const guild = GuildStore.getGuild(channel.guild_id);
return {
name: channel.name || "Channel",
avatar: guild ? IconUtils.getGuildIconURL({ id: guild.id, icon: guild.icon, canAnimate: true, size: 512 }) ?? "" : ""
};
}
function getImageDimensions(dataUrl: string): Promise<{ width: number; height: number; }> {
return new Promise(resolve => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => resolve({ width: 400, height: 300 });
img.src = dataUrl;
});
}
function getVideoPreview(dataUrl: string): Promise<{ width: number; height: number; previewUrl: string; } | null> {
return new Promise(resolve => {
const video = document.createElement("video");
video.preload = "metadata";
video.muted = true;
video.playsInline = true;
const timeout = setTimeout(() => { video.src = ""; resolve(null); }, 5000);
video.onloadeddata = () => { clearTimeout(timeout); video.currentTime = 0; };
video.onerror = () => { clearTimeout(timeout); resolve(null); };
video.onseeked = () => {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) { resolve(null); video.src = ""; return; }
ctx.drawImage(video, 0, 0);
const size = Math.min(canvas.width, canvas.height) * 0.2;
const cx = canvas.width / 2, cy = canvas.height / 2;
ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
ctx.beginPath();
ctx.arc(cx, cy, size, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "white";
ctx.beginPath();
ctx.moveTo(cx - size * 0.3, cy - size * 0.4);
ctx.lineTo(cx - size * 0.3, cy + size * 0.4);
ctx.lineTo(cx + size * 0.5, cy);
ctx.closePath();
ctx.fill();
resolve({ width: video.videoWidth, height: video.videoHeight, previewUrl: canvas.toDataURL("image/png") });
video.src = "";
};
video.src = dataUrl;
});
}
async function buildPhantomAttachments(attachments: ScheduledAttachment[]) {
const result: { id: string; filename: string; size: number; content_type: string; url?: string; proxy_url?: string; width?: number; height?: number; }[] = [];
for (let idx = 0; idx < attachments.length; idx++) {
const att = attachments[idx];
const dataUrl = `data:${att.type};base64,${att.data}`;
const attachment: typeof result[0] = {
id: String(idx),
filename: att.filename,
size: Math.ceil(att.data.length * 0.75),
content_type: att.type
};
if (att.type.startsWith("image/")) {
const dims = await getImageDimensions(dataUrl);
Object.assign(attachment, { url: dataUrl, proxy_url: dataUrl, ...dims });
} else if (["video/mp4", "video/webm", "video/ogg"].includes(att.type)) {
const preview = await getVideoPreview(dataUrl);
if (preview) {
Object.assign(attachment, {
content_type: "image/png",
url: preview.previewUrl,
proxy_url: preview.previewUrl,
width: preview.width,
height: preview.height,
filename: att.filename.replace(/\.[^.]+$/, "_preview.png")
});
}
}
result.push(attachment);
}
return result;
}
export async function createPhantomMessage(msg: ScheduledMessage): Promise<void> {
if (!settings.store.showPhantomMessages) return;
const currentUser = UserStore.getCurrentUser();
if (!currentUser) return;
const messageId = `scheduled-${msg.id}`;
phantomMessageMap.set(messageId, { scheduledTime: msg.scheduledTime, messageId: msg.id, channelId: msg.channelId });
if (msg.reactions?.length) pendingReactions.set(msg.id, [...msg.reactions]);
const attachments = msg.attachments?.length ? await buildPhantomAttachments(msg.attachments) : [];
const initialReactions = (msg.reactions ?? []).map(r => ({
emoji: r.emoji,
count: r.count,
count_details: { burst: 0, normal: r.count },
me: true,
me_burst: false,
burst_count: 0,
burst_colors: [],
burst_me: false
}));
const messagesLoaded = MessageStore.hasPresent(msg.channelId)
? Promise.resolve()
: MessageActions.fetchMessages({ channelId: msg.channelId });
messagesLoaded.then(() => {
FluxDispatcher.dispatch({
type: "MESSAGE_CREATE",
channelId: msg.channelId,
message: {
id: messageId,
channel_id: msg.channelId,
author: {
id: currentUser.id,
username: currentUser.username,
discriminator: currentUser.discriminator || "0",
avatar: currentUser.avatar,
global_name: currentUser.globalName ?? null,
bot: false
},
content: msg.content,
timestamp: new Date().toISOString(),
edited_timestamp: null,
tts: false,
mention_everyone: false,
mentions: [],
mention_roles: [],
attachments,
embeds: [],
pinned: false,
type: 0,
flags: 0,
components: [],
reactions: initialReactions,
nonce: messageId,
scheduledMessageData: { scheduledTime: msg.scheduledTime, messageId: msg.id }
},
optimistic: true,
sendMessageOptions: {},
isPushNotification: false
});
applyPhantomClassToMessage(msg.channelId, messageId);
}).catch(() => { });
}
function applyPhantomClassToMessage(channelId: string, messageId: string): void {
const tryApply = (retries = 0) => {
const el = document.getElementById(`chat-messages-${channelId}-${messageId}`);
if (el) {
el.classList.add("vc-scheduled-msg-phantom");
return true;
}
if (retries < 20) {
setTimeout(() => tryApply(retries + 1), 100 + retries * 50);
}
return false;
};
setTimeout(() => tryApply(), 50);
}
function removePhantomMessage(msg: ScheduledMessage): void {
const messageId = `scheduled-${msg.id}`;
phantomMessageMap.delete(messageId);
FluxDispatcher.dispatch({ type: "MESSAGE_DELETE", channelId: msg.channelId, id: messageId, mlDeleted: true });
}
function updatePhantomReactions(messageId: string, channelId: string, reactions: ScheduledReaction[]): void {
const existingTimeout = pendingRecreations.get(messageId);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
const timeout = setTimeout(() => {
pendingRecreations.delete(messageId);
doRecreatePhantomMessage(messageId, channelId, reactions);
}, RECREATE_DEBOUNCE_MS);
pendingRecreations.set(messageId, timeout);
}
function doRecreatePhantomMessage(messageId: string, channelId: string, reactions: ScheduledReaction[]): void {
logger.info("doRecreatePhantomMessage: Recreating phantom message with", reactions.length, "reactions");
const phantomData = phantomMessageMap.get(messageId);
if (!phantomData) {
logger.warn("doRecreatePhantomMessage: No phantom data found");
return;
}
const msg = scheduledMessages.find(m => m.id === phantomData.messageId);
if (!msg) {
logger.warn("doRecreatePhantomMessage: No scheduled message found");
return;
}
FluxDispatcher.dispatch({
type: "MESSAGE_DELETE",
channelId,
id: messageId,
mlDeleted: true
});
setTimeout(() => {
msg.reactions = reactions;
createPhantomMessage(msg);
}, 50);
}
async function uploadAttachment(channelId: string, att: ScheduledAttachment): Promise<{ id: string; filename: string; uploaded_filename: string; } | null> {
return new Promise(resolve => {
const bytes = Uint8Array.from(atob(att.data), c => c.charCodeAt(0));
const file = new File([bytes], att.filename, { type: att.type });
const upload = new CloudUploader({ file, platform: CloudUploadPlatform.WEB }, channelId);
upload.on("complete", () => resolve({ id: "", filename: upload.filename, uploaded_filename: upload.uploadedFilename }));
upload.on("error", () => resolve(null));
upload.upload();
});
}
async function postMessage(channelId: string, content: string, attachments?: { id: string; filename: string; uploaded_filename: string; }[]): Promise<void> {
await RestAPI.post({
url: Constants.Endpoints.MESSAGES(channelId),
body: {
content,
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
...(attachments?.length ? { channel_id: channelId, sticker_ids: [], type: 0, attachments } : {})
}
});
}
async function addReactionsToMessage(channelId: string, messageId: string, reactions: ScheduledReaction[]): Promise<void> {
for (const reaction of reactions) {
const emojiStr = reaction.emoji.id
? `${reaction.emoji.name}:${reaction.emoji.id}`
: encodeURIComponent(reaction.emoji.name);
for (let attempt = 0; attempt < 5; attempt++) {
try {
await RestAPI.put({ url: `/channels/${channelId}/messages/${messageId}/reactions/${emojiStr}/@me` });
break;
} catch (e) {
const err = e as { status?: number; body?: { retry_after?: number; }; };
if (err.status === 429 || err.body?.retry_after) {
await new Promise(r => setTimeout(r, (err.body?.retry_after ?? 1) * 1000 + 100));
} else if (err.status === 404) {
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
} else {
break;
}
}
}
await new Promise(r => setTimeout(r, 350));
}
}
async function sendScheduledMessage(msg: ScheduledMessage): Promise<boolean> {
try {
if (!ChannelStore.getChannel(msg.channelId)) return false;
const reactions = pendingReactions.get(msg.id) ?? msg.reactions ?? [];
removePhantomMessage(msg);
pendingReactions.delete(msg.id);
if (msg.attachments?.length) {
const uploaded = (await Promise.all(msg.attachments.map((att, i) =>
uploadAttachment(msg.channelId, att).then(r => r ? { ...r, id: String(i) } : null)
))).filter(Boolean) as { id: string; filename: string; uploaded_filename: string; }[];
await postMessage(msg.channelId, msg.content, uploaded.length ? uploaded : undefined);
} else {
await postMessage(msg.channelId, msg.content);
}
if (reactions.length) {
await new Promise(r => setTimeout(r, 1500));
const msgArray = (MessageStore.getMessages(msg.channelId) as { _array?: Message[]; })?._array ?? [];
const currentUserId = UserStore.getCurrentUser()?.id;
for (let i = msgArray.length - 1; i >= Math.max(0, msgArray.length - 10); i--) {
const m = msgArray[i];
if (m?.author?.id === currentUserId && m?.content === msg.content && !m?.id?.startsWith("scheduled-")) {
await addReactionsToMessage(msg.channelId, m.id, reactions);
break;
}
}
}
if (settings.store.showNotifications) {
showToast(`Scheduled message sent to ${getChannelDisplayInfo(msg.channelId).name}`, Toasts.Type.SUCCESS);
}
return true;
} catch {
if (settings.store.showNotifications) showToast("Failed to send scheduled message", Toasts.Type.FAILURE);
return false;
}
}
export async function addScheduledMessage(
channelId: string,
content: string,
scheduledTime: number,
attachments?: ScheduledAttachment[]
): Promise<{ success: boolean; error?: string; }> {
const minuteStart = Math.floor(scheduledTime / 60000) * 60000;
const count = scheduledMessages.filter(m =>
m.channelId === channelId && m.scheduledTime >= minuteStart && m.scheduledTime < minuteStart + 60000
).length;
if (count >= settings.store.maxMessagesPerMinute) {
return { success: false, error: `Maximum of ${settings.store.maxMessagesPerMinute} messages per channel per minute reached` };
}
const newMessage: ScheduledMessage = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
channelId,
content,
scheduledTime,
createdAt: Date.now(),
attachments
};
scheduledMessages.push(newMessage);
scheduledMessages.sort((a, b) => a.scheduledTime - b.scheduledTime);
await saveScheduledMessages();
createPhantomMessage(newMessage);
return { success: true };
}
export async function removeScheduledMessage(id: string): Promise<void> {
const msg = scheduledMessages.find(m => m.id === id);
if (msg) removePhantomMessage(msg);
scheduledMessages = scheduledMessages.filter(m => m.id !== id);
await saveScheduledMessages();
}
export async function clearAllScheduledMessages(): Promise<void> {
for (const msg of scheduledMessages) {
removePhantomMessage(msg);
}
scheduledMessages = [];
await saveScheduledMessages();
}
async function checkAndSendMessages(): Promise<void> {
if (isProcessingMessages) return;
isProcessingMessages = true;
try {
const now = Date.now();
const dueMessages = scheduledMessages.filter(m => m.scheduledTime <= now);
for (const msg of dueMessages) {
await removeScheduledMessage(msg.id);
await sendScheduledMessage(msg);
}
} finally {
isProcessingMessages = false;
}
}
export function startScheduler(): void {
if (checkInterval) return;
checkAndSendMessages();
checkInterval = setInterval(checkAndSendMessages, settings.store.checkIntervalSeconds * 1000);
}
export function stopScheduler(): void {
if (checkInterval) { clearInterval(checkInterval); checkInterval = null; }
}
export async function recreatePhantomMessages(): Promise<void> {
for (const msg of scheduledMessages) await createPhantomMessage(msg);
}
export function cleanupAllPhantomMessages(): void {
for (const msg of scheduledMessages) removePhantomMessage(msg);
}
function modifyReaction(messageId: string, channelId: string, emoji: { id: string | null; name: string; animated?: boolean; }, delta: number): void {
logger.info("modifyReaction called:", { messageId, emoji: emoji.name, delta });
const phantomData = phantomMessageMap.get(messageId);
if (!phantomData) {
logger.warn("modifyReaction: No phantom data found for messageId =", messageId);
return;
}
const msg = scheduledMessages.find(m => m.id === phantomData.messageId);
if (!msg) {
logger.warn("modifyReaction: No scheduled message found for id =", phantomData.messageId);
return;
}
const reactions = pendingReactions.get(phantomData.messageId) ?? [];
const idx = reactions.findIndex(r => r.emoji.name === emoji.name && r.emoji.id === emoji.id);
logger.info("modifyReaction: Current reactions count =", reactions.length, "found at idx =", idx);
if (delta > 0) {
if (idx >= 0) reactions[idx].count += delta;
else reactions.push({ emoji: { id: emoji.id ?? null, name: emoji.name, animated: emoji.animated }, count: 1 });
} else if (idx >= 0) {
reactions[idx].count += delta;
if (reactions[idx].count <= 0) reactions.splice(idx, 1);
}
pendingReactions.set(phantomData.messageId, reactions);
msg.reactions = reactions;
saveScheduledMessages();
logger.info("modifyReaction: Updated reactions count =", reactions.length, "calling updatePhantomReactions");
updatePhantomReactions(messageId, channelId, reactions);
}
function getReactionKey(messageId: string, emoji: { id: string | null; name: string; }): string {
return `${messageId}:${emoji.name}:${emoji.id ?? ""}`;
}
export function handleReactionAdd(messageId: string, channelId: string, emoji: { id: string | null; name: string; animated?: boolean; }): void {
const key = getReactionKey(messageId, emoji);
const recent = recentReactionChanges.get(key);
const now = Date.now();
if (recent && recent.action === "add" && now - recent.timestamp < REACTION_COOLDOWN_MS) {
logger.info("handleReactionAdd: Ignoring duplicate ADD within cooldown", { messageId, emoji: emoji.name });
return;
}
if (recent && recent.action === "remove" && now - recent.timestamp < REACTION_COOLDOWN_MS) {
logger.info("handleReactionAdd: Ignoring Discord revert (ADD after our REMOVE)", { messageId, emoji: emoji.name });
resyncPhantomReactions(messageId, channelId);
return;
}
logger.info("handleReactionAdd: Processing", { messageId, emoji: emoji.name });
recentReactionChanges.set(key, { action: "add", timestamp: now });
modifyReaction(messageId, channelId, emoji, 1);
}
export function handleReactionRemove(messageId: string, channelId: string, emoji: { id: string | null; name: string; }): void {
const key = getReactionKey(messageId, emoji);
const recent = recentReactionChanges.get(key);
const now = Date.now();
if (recent && recent.action === "remove" && now - recent.timestamp < REACTION_COOLDOWN_MS) {
logger.info("handleReactionRemove: Ignoring duplicate REMOVE within cooldown", { messageId, emoji: emoji.name });
return;
}
if (recent && recent.action === "add" && now - recent.timestamp < REACTION_COOLDOWN_MS) {
logger.info("handleReactionRemove: Ignoring Discord revert (REMOVE after our ADD)", { messageId, emoji: emoji.name });
resyncPhantomReactions(messageId, channelId);
return;
}
logger.info("handleReactionRemove: Processing", { messageId, emoji: emoji.name });
recentReactionChanges.set(key, { action: "remove", timestamp: now });
modifyReaction(messageId, channelId, emoji, -1);
}
export function isPhantomMessage(messageId: string): boolean {
return phantomMessageMap.has(messageId);
}
export function resyncPhantomReactions(messageId: string, channelId: string): void {
logger.info("resyncPhantomReactions:", { messageId });
const phantomData = phantomMessageMap.get(messageId);
if (!phantomData) {
logger.warn("resyncPhantomReactions: No phantom data for messageId =", messageId);
return;
}
const reactions = pendingReactions.get(phantomData.messageId) ?? [];
logger.info("resyncPhantomReactions: Found", reactions.length, "reactions to sync");
updatePhantomReactions(messageId, channelId, reactions);
}