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
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
351 lines
14 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|