handleEdit already clears cancelErrors for the old row; also clear sendErrors so a prior failed "Send now" doesn't leave a stale inline error after editing. Cosmetic hygiene, matching the existing cancelErrors handling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
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<Set<string>>(new Set());
|
|
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
|
|
const [sendErrors, setSendErrors] = useState<Set<string>>(new Set());
|
|
const [editing, setEditing] = useState<ScheduledMessage | null>(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 && (
|
|
<ScheduleMessageModal
|
|
roomId={roomId}
|
|
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
|
|
initialSendAt={editing.sendAt}
|
|
title="Edit scheduled message"
|
|
submitLabel="Reschedule"
|
|
onScheduled={(newDelayId, sendAt, content) =>
|
|
handleEdit(editing, newDelayId, sendAt, content)
|
|
}
|
|
onClose={() => setEditing(null)}
|
|
/>
|
|
)}
|
|
<Box
|
|
direction="Column"
|
|
style={{
|
|
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
|
background: color.SurfaceVariant.Container,
|
|
}}
|
|
>
|
|
{/* Tray header */}
|
|
<Button
|
|
variant="Secondary"
|
|
fill="None"
|
|
radii="0"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
aria-expanded={expanded}
|
|
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
|
|
before={<Icon src={Icons.Clock} size="50" />}
|
|
after={<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
|
|
style={{
|
|
padding: `${config.space.S100} ${config.space.S300}`,
|
|
justifyContent: 'flex-start',
|
|
}}
|
|
>
|
|
<Text size="T200" style={{ flex: 1, fontWeight: 600, textAlign: 'left' }}>
|
|
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
|
|
</Text>
|
|
</Button>
|
|
|
|
{/* Tray items */}
|
|
{expanded && (
|
|
<Box direction="Column">
|
|
{messages.map((msg) => {
|
|
const bodyPreview =
|
|
typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)';
|
|
const rowDesc = `${bodyPreview} at ${formatSendAt(msg.sendAt)}`;
|
|
return (
|
|
<Box
|
|
key={msg.delayId}
|
|
direction="Column"
|
|
style={{
|
|
padding: `${config.space.S100} ${config.space.S300}`,
|
|
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
|
}}
|
|
>
|
|
<Box alignItems="Center" gap="200">
|
|
<Text
|
|
size="T200"
|
|
priority="400"
|
|
style={{
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{bodyPreview}
|
|
</Text>
|
|
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
|
{formatSendAt(msg.sendAt)}
|
|
</Text>
|
|
<IconButton
|
|
size="300"
|
|
radii="300"
|
|
variant="SurfaceVariant"
|
|
aria-label={`Send scheduled message now: ${rowDesc}`}
|
|
disabled={cancelling.has(msg.delayId)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleSendNow(msg);
|
|
}}
|
|
>
|
|
<Icon src={Icons.Send} size="50" />
|
|
</IconButton>
|
|
<IconButton
|
|
size="300"
|
|
radii="300"
|
|
variant="SurfaceVariant"
|
|
aria-label={`Edit scheduled message: ${rowDesc}`}
|
|
disabled={cancelling.has(msg.delayId)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditing(msg);
|
|
}}
|
|
>
|
|
<Icon src={Icons.Pencil} size="50" />
|
|
</IconButton>
|
|
<IconButton
|
|
size="300"
|
|
radii="300"
|
|
variant="SurfaceVariant"
|
|
aria-label={`Cancel scheduled message: ${rowDesc}`}
|
|
disabled={cancelling.has(msg.delayId)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleCancel(msg);
|
|
}}
|
|
>
|
|
<Icon src={Icons.Cross} size="50" />
|
|
</IconButton>
|
|
</Box>
|
|
{cancelErrors.has(msg.delayId) && (
|
|
<Text
|
|
size="T200"
|
|
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
|
>
|
|
Could not cancel this message. Try again.
|
|
</Text>
|
|
)}
|
|
{sendErrors.has(msg.delayId) && (
|
|
<Text
|
|
size="T200"
|
|
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
|
>
|
|
Could not send now. Try again.
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</>
|
|
);
|
|
}
|