import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useAtom } from 'jotai'; import { IContent } from 'matrix-js-sdk'; import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages'; import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages'; import { ScheduleMessageModal } from './ScheduleMessageModal'; interface ScheduledMessagesTrayProps { roomId: string; } function formatSendAt(sendAt: number): string { const date = new Date(sendAt); const now = new Date(); const isToday = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); const isTomorrow = date.getFullYear() === tomorrow.getFullYear() && date.getMonth() === tomorrow.getMonth() && date.getDate() === tomorrow.getDate(); const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); if (isToday) return `Today ${timeStr}`; if (isTomorrow) return `Tomorrow ${timeStr}`; return `${date.toLocaleDateString()} ${timeStr}`; } export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { const mx = useMatrixClient(); const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom); const [expanded, setExpanded] = useState(false); const [cancelling, setCancelling] = useState>(new Set()); const [cancelErrors, setCancelErrors] = useState>(new Set()); const [sendErrors, setSendErrors] = useState>(new Set()); const [editing, setEditing] = useState(null); const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]); // Remove scheduled messages whose time has passed useEffect(() => { if (messages.length === 0) return undefined; const nearestSendAt = Math.min(...messages.map((m) => m.sendAt)); const delay = nearestSendAt - Date.now(); const timer = setTimeout( () => { const now = Date.now(); setScheduledMessages((prev) => { const next = new Map(prev); const current = next.get(roomId) ?? []; const remaining = current.filter((m) => m.sendAt > now); if (remaining.length === 0) { next.delete(roomId); } else { next.set(roomId, remaining); } return next; }); }, Math.max(0, delay) + 2000, ); // 2s grace after scheduled time return () => clearTimeout(timer); }, [messages, roomId, setScheduledMessages]); const handleCancel = useCallback( async (msg: ScheduledMessage) => { if (cancelling.has(msg.delayId)) return; setCancelling((prev) => new Set(prev).add(msg.delayId)); setCancelErrors((prev) => { if (!prev.has(msg.delayId)) return prev; const next = new Set(prev); next.delete(msg.delayId); return next; }); try { await cancelScheduledMessage(mx, msg.delayId); // Only prune local state once the server confirms cancellation. If we // removed it optimistically the still-live delayed event would fire and // the "cancelled" message would send anyway. setScheduledMessages((prev) => { const next = new Map(prev); const current = next.get(roomId) ?? []; const remaining = current.filter((m) => m.delayId !== msg.delayId); if (remaining.length === 0) { next.delete(roomId); } else { next.set(roomId, remaining); } return next; }); } catch { // Keep the item (still cancellable) and surface an inline error; the // delayed event is still scheduled on the server. setCancelErrors((prev) => new Set(prev).add(msg.delayId)); } finally { setCancelling((prev) => { const next = new Set(prev); next.delete(msg.delayId); return next; }); } }, [mx, roomId, cancelling, setScheduledMessages], ); const handleSendNow = useCallback( async (msg: ScheduledMessage) => { if (cancelling.has(msg.delayId)) return; setCancelling((prev) => new Set(prev).add(msg.delayId)); setSendErrors((prev) => { if (!prev.has(msg.delayId)) return prev; const next = new Set(prev); next.delete(msg.delayId); return next; }); try { await sendScheduledMessageNow(mx, msg.delayId); // Only prune once the server confirms the send. The delayed event is now // consumed and appears in the timeline; dropping it before confirmation // could hide a still-live event that never actually sent. setScheduledMessages((prev) => { const next = new Map(prev); const current = next.get(roomId) ?? []; const remaining = current.filter((m) => m.delayId !== msg.delayId); if (remaining.length === 0) { next.delete(roomId); } else { next.set(roomId, remaining); } return next; }); } catch { // Keep the item (still sendable/editable/cancellable) and surface an // inline error; the delayed event is still scheduled on the server. setSendErrors((prev) => new Set(prev).add(msg.delayId)); } finally { setCancelling((prev) => { const next = new Set(prev); next.delete(msg.delayId); return next; }); } }, [mx, roomId, cancelling, setScheduledMessages], ); // Editing = cancel-old + schedule-new (MSC4140 has no in-place edit). The modal has // already scheduled the NEW message by the time this fires; add it, then cancel the // old one — removing the old from state only once the server confirms, so a failed // cancel leaves it visible (and retriable) instead of letting it silently fire. const handleEdit = useCallback( (oldMsg: ScheduledMessage, newDelayId: string, sendAt: number, content: IContent) => { // Add the newly-scheduled message up front (nothing lost yet). setScheduledMessages((prev) => { const next = new Map(prev); const current = (next.get(roomId) ?? []).filter((m) => m.delayId !== newDelayId); next.set(roomId, [{ delayId: newDelayId, roomId, content, sendAt }, ...current]); return next; }); // Mark the old message as cancelling so its row's Edit/Cancel buttons are // disabled while we tear it down. Without this the old row stays live during // the in-flight cancel and a second edit could orphan a still-scheduled event // (both would fire). Also clear any stale error from a prior failed cancel. setCancelling((prev) => new Set(prev).add(oldMsg.delayId)); setCancelErrors((prev) => { if (!prev.has(oldMsg.delayId)) return prev; const next = new Set(prev); next.delete(oldMsg.delayId); return next; }); setSendErrors((prev) => { if (!prev.has(oldMsg.delayId)) return prev; const next = new Set(prev); next.delete(oldMsg.delayId); return next; }); setEditing(null); cancelScheduledMessage(mx, oldMsg.delayId) .then(() => { setScheduledMessages((prev) => { const next = new Map(prev); const remaining = (next.get(roomId) ?? []).filter((m) => m.delayId !== oldMsg.delayId); if (remaining.length === 0) next.delete(roomId); else next.set(roomId, remaining); return next; }); }) .catch(() => { // Cancel failed — the old delayed event is still live server-side, so it // must stay visible and retriable. Re-insert it if auto-prune removed the // row while the modal was open, otherwise the failure (and the duplicate // it will send) would be invisible. setScheduledMessages((prev) => { const next = new Map(prev); const current = next.get(roomId) ?? []; if (!current.some((m) => m.delayId === oldMsg.delayId)) { next.set(roomId, [...current, oldMsg]); } return next; }); setCancelErrors((prev) => new Set(prev).add(oldMsg.delayId)); }) .finally(() => { setCancelling((prev) => { const next = new Set(prev); next.delete(oldMsg.delayId); return next; }); }); }, [mx, roomId, setScheduledMessages], ); if (messages.length === 0 && !editing) return null; return ( <> {editing && ( handleEdit(editing, newDelayId, sendAt, content) } onClose={() => setEditing(null)} /> )} {/* Tray header */} {/* Tray items */} {expanded && ( {messages.map((msg) => { const bodyPreview = typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'; const rowDesc = `${bodyPreview} at ${formatSendAt(msg.sendAt)}`; return ( {bodyPreview} {formatSendAt(msg.sendAt)} { e.stopPropagation(); handleSendNow(msg); }} > { e.stopPropagation(); setEditing(msg); }} > { e.stopPropagation(); handleCancel(msg); }} > {cancelErrors.has(msg.delayId) && ( Could not cancel this message. Try again. )} {sendErrors.has(msg.delayId) && ( Could not send now. Try again. )} ); })} )} ); }