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); +}