Files
cinny/src/app/features/room/ScheduledMessagesTray.tsx
T
jared 664dcd4cd8 fix(audit): correctness wave — ghost sends, Escape coordination, panel exclusion
- ScheduledMessagesTray: cancel prunes local state ONLY on confirmed server
  cancel; failures keep the item + show an inline error (was: a failed cancel
  looked cancelled but still sent at the scheduled time).
- Escape semantics: the composer consumes Escape (preventDefault+stopPropagation)
  iff autocomplete is open or a reply draft is set; the thread panel and Room's
  markAsRead act only on unconsumed Escape, and markAsRead defers entirely while
  a thread panel is open (listener order made it fire before the panel closed).
- Room: thread panel / media gallery are mutually exclusive (most-recently-
  opened wins); on mobile at most one right panel renders (thread > gallery >
  members) instead of stacked fullscreen overlays.
- RemindMeDialog: busy-disabled presets (no more double-click duplicates),
  try/catch with inline error, close only on success.
- ThreadTimeline: "Jump to Latest" floating chip when scrolled up (RoomTimeline
  idiom).

From the 4-auditor deep-audit wave; reviewer-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:18:51 -04:00

198 lines
6.9 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtom } from 'jotai';
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
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 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],
);
if (messages.length === 0) return null;
return (
<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) => (
<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',
}}
>
{typeof msg.content.body === 'string'
? (msg.content.body as string)
: '(message)'}
</Text>
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label="Cancel scheduled message"
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>
)}
</Box>
))}
</Box>
)}
</Box>
);
}