Files
cinny/src/app/features/room/ScheduledMessagesTray.tsx
T
jaredandClaude Opus 5 4d4a76214a
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
refactor(time): one timestamp formatter honouring the clock/date settings (#139)
Audit of every rendered time found five families of ad-hoc formatting:
the shared Time component + copies of its today/yesterday branch
(forwarded header, thread summary, read receipts, device tile, moderation
alerts, edit history), locale-default toLocale*String calls that ignored
the user's 12/24 h and date-format settings (scheduled tray, reminders,
schedule preview, notification snooze, bookmarks, threads list, search
cache line, room insights, media gallery), a hard-coded en-US date in the
activity log, and three relative-age variants.

utils/formatTimestamp.ts now holds the rules — today → time; yesterday /
tomorrow → day word + time; last 6 days → weekday + time; older → date +
time in dateFormatString — plus autoDate / time / date / dateTime styles,
formatDayDivider (full weekday), formatShortAge (room list) and
formatRelativeAge (list rows). useTimestampFormatter binds them to the
settings. 11 unit tests with an injected 'now'.

Visible changes are limited to consistency: 12 h times keep the existing
zero-padded hh:mm A; the a11y label and Created-by line use the user's
date format instead of a fixed long month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:28:47 -04:00

351 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';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
interface ScheduledMessagesTrayProps {
roomId: string;
}
export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
const mx = useMatrixClient();
const { prefs } = useTimestampFormatter();
const formatSendAt = (sendAt: number) => formatFriendlyDateTime(sendAt, prefs);
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}
initialContent={editing.content}
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>
</>
);
}