Files
cinny/src/app/features/room/ScheduledMessagesTray.tsx
T
jaredandClaude Opus 4.8 82eb65b822
CI / Build & Quality Checks (push) Successful in 10m50s
CI / Trigger Desktop Build (push) Successful in 9s
fix(scheduled): clear stale send-now error when a row is edited
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>
2026-07-10 14:51:15 -04:00

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