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:
parent
faf342312e
commit
ce04141b7a
9 changed files with 1497 additions and 0 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
57
src/equicordplugins/scheduledMessages/components/Icons.tsx
Normal file
57
src/equicordplugins/scheduledMessages/components/Icons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
|
@ -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)} />
|
||||
));
|
||||
}
|
||||
181
src/equicordplugins/scheduledMessages/index.tsx
Normal file
181
src/equicordplugins/scheduledMessages/index.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
276
src/equicordplugins/scheduledMessages/styles.css
Normal file
276
src/equicordplugins/scheduledMessages/styles.css
Normal 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;
|
||||
}
|
||||
57
src/equicordplugins/scheduledMessages/types.ts
Normal file
57
src/equicordplugins/scheduledMessages/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
567
src/equicordplugins/scheduledMessages/utils.ts
Normal file
567
src/equicordplugins/scheduledMessages/utils.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue