diff --git a/src/equicordplugins/scheduledMessages/components/ChatBarButton.tsx b/src/equicordplugins/scheduledMessages/components/ChatBarButton.tsx
new file mode 100644
index 000000000..a7035cb62
--- /dev/null
+++ b/src/equicordplugins/scheduledMessages/components/ChatBarButton.tsx
@@ -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 (
+ {
+ e.preventDefault();
+ openViewScheduledModal();
+ }}
+ buttonProps={{ "aria-haspopup": "dialog" }}
+ >
+
+
+ );
+};
diff --git a/src/equicordplugins/scheduledMessages/components/Icons.tsx b/src/equicordplugins/scheduledMessages/components/Icons.tsx
new file mode 100644
index 000000000..1dc4c4e03
--- /dev/null
+++ b/src/equicordplugins/scheduledMessages/components/Icons.tsx
@@ -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 (
+
+ );
+}
+
+export function TimerIcon({ width = 20, height = 20, className }: IconProps) {
+ return (
+
+ );
+}
+
+export function ErrorIcon() {
+ return (
+
+ );
+}
diff --git a/src/equicordplugins/scheduledMessages/components/MessageAccessory.tsx b/src/equicordplugins/scheduledMessages/components/MessageAccessory.tsx
new file mode 100644
index 000000000..fe69de80c
--- /dev/null
+++ b/src/equicordplugins/scheduledMessages/components/MessageAccessory.tsx
@@ -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 (
+
+
+ Scheduled for {new Date(scheduledTime).toLocaleString()}{timeLeftStr}
+
+ );
+}
diff --git a/src/equicordplugins/scheduledMessages/components/ScheduleTimeModal.tsx b/src/equicordplugins/scheduledMessages/components/ScheduleTimeModal.tsx
new file mode 100644
index 000000000..4b540e0fa
--- /dev/null
+++ b/src/equicordplugins/scheduledMessages/components/ScheduleTimeModal.tsx
@@ -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 (
+
+
+ Schedule Message
+
+
+
+
+
+ {avatar &&

}
+
+ Scheduling for: {isDM ? name : `#${name}`}
+
+
+
+ Schedule Type
+
+
+
+
+
+ {scheduleType === "delay" ? (
+ <>
+ Delay (minutes)
+
+ >
+ ) : (
+ <>
+ Date & Time
+ setScheduledDateTime(e.target.value)}
+ min={new Date().toISOString().slice(0, 16)}
+ />
+ >
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+export const ScheduleTimeModal = ErrorBoundary.wrap(ScheduleTimeModalInner, { noop: true });
+
+export function openScheduleTimeModal(channelId: string, content: string, attachments?: ScheduledAttachment[]): void {
+ const key = openModal(props => (
+ closeModal(key)}
+ />
+ ));
+}
diff --git a/src/equicordplugins/scheduledMessages/components/ViewScheduledModal.tsx b/src/equicordplugins/scheduledMessages/components/ViewScheduledModal.tsx
new file mode 100644
index 000000000..eb6204967
--- /dev/null
+++ b/src/equicordplugins/scheduledMessages/components/ViewScheduledModal.tsx
@@ -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 (
+
+
+ Scheduled Messages
+ {messages.length > 0 && (
+
+ )}
+
+
+
+
+ {!messages.length ? (
+
+
+ No scheduled messages
+
+ ) : (
+
+ {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 (
+
+
+
+ {avatar &&

}
+
+ {isDM ? name : `#${name}`}
+
+
+
+
+ {new Date(msg.scheduledTime).toLocaleString()}
+
+
{displayContent}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export const ViewScheduledModal = ErrorBoundary.wrap(ViewScheduledModalInner, { noop: true });
+
+export function openViewScheduledModal(): void {
+ const key = openModal(props => (
+ closeModal(key)} />
+ ));
+}
diff --git a/src/equicordplugins/scheduledMessages/index.tsx b/src/equicordplugins/scheduledMessages/index.tsx
new file mode 100644
index 000000000..320058c83
--- /dev/null
+++ b/src/equicordplugins/scheduledMessages/index.tsx
@@ -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) {
+ return ;
+ },
+
+ 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((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();
+ }
+});
diff --git a/src/equicordplugins/scheduledMessages/styles.css b/src/equicordplugins/scheduledMessages/styles.css
new file mode 100644
index 000000000..24478f6fe
--- /dev/null
+++ b/src/equicordplugins/scheduledMessages/styles.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/equicordplugins/scheduledMessages/types.ts b/src/equicordplugins/scheduledMessages/types.ts
new file mode 100644
index 000000000..4e7bdc30f
--- /dev/null
+++ b/src/equicordplugins/scheduledMessages/types.ts
@@ -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;
+ };
+}
diff --git a/src/equicordplugins/scheduledMessages/utils.ts b/src/equicordplugins/scheduledMessages/utils.ts
new file mode 100644
index 000000000..c3b1dca97
--- /dev/null
+++ b/src/equicordplugins/scheduledMessages/utils.ts
@@ -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 | null = null;
+let isProcessingMessages = false;
+
+export const phantomMessageMap = new Map();
+const pendingReactions = new Map();
+
+const recentReactionChanges = new Map();
+const REACTION_COOLDOWN_MS = 2000;
+
+const pendingRecreations = new Map>();
+const RECREATE_DEBOUNCE_MS = 300;
+
+export async function loadScheduledMessages(): Promise {
+ const saved = await DataStore.get(STORAGE_KEY);
+ scheduledMessages = Array.isArray(saved) ? saved : [];
+}
+
+async function saveScheduledMessages(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ for (const msg of scheduledMessages) {
+ removePhantomMessage(msg);
+ }
+ scheduledMessages = [];
+ await saveScheduledMessages();
+}
+
+async function checkAndSendMessages(): Promise {
+ 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 {
+ 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);
+}