c54cb126ff
Fix settings modal regression: Modal500 was wrapped in useModalStyle(560), forcing maxWidth 560px and squishing the two-pane Settings layout (folds size="500" is ~50rem). Restore desktop width to the folds recipe while keeping mobile fullscreen. N-series fixes: - N13 ScheduledMessagesTray header: <Box as="button"> -> folds <Button> - N28 composer char counter: drop undefined --tc-surface-low + opacity, use priority="300" and config.space token - N31 collapsible "Read more" toggle: padded <Button> -> flush inline-button pattern matching (edited) link - N41 UserPrivateNotes "Saving..." now shows a folds <Spinner> - N43 Night Light slider: add accentColor; label opacity -> priority - N44 mention-highlight Reset: bare <button> -> folds <Button> (drops undefined --border-interactive-normal); Boot button kept (TDS-only) - N45 SelectTheme trigger variant -> Secondary to match SettingsSelect - N49 RoomInsights StatTile emoji -> folds <Icon> (Photo/VideoCamera/ Headphone/File) - N54/N57 PiP overlay badges + fullscreen button: token discipline (config.radii/space, folds Text); dark scrim kept for video legibility - N60 knock badge: match Pinned Messages pattern (no wrapper div, toRem insets, no hardcoded size overrides) - N62 unverified-device banner: 3px left-accent -> standard border via color.Warning.ContainerLine; drop opacity hacks - N65 Edit History: real "Load more" pagination (accumulate next_batch, de-dupe by id, re-sort by ts) replacing passive text - N66 search date fields: raw <input type="date"> -> folds <Input> - N67 SeasonalEffect z-index 9999 -> 9997 (below Night Light + modals) - N73 Pending Requests header uses css.MembersGroupLabel - N74 remove raw em-sized emoji <span> in RoomNavItem name - N85/N86 RemindMeDialog: <Box role="dialog"> -> folds <Dialog>; preset MenuItems -> Buttons (fixes invalid menuitem-in-dialog ARIA) Document deliberate WON'T FIX rationale for N9, N51, N61, N71, N75, N77. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
176 lines
5.9 KiB
TypeScript
176 lines
5.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 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));
|
|
try {
|
|
await cancelScheduledMessage(mx, msg.delayId);
|
|
} catch {
|
|
// If cancellation fails on the server, still remove locally
|
|
// since the user intends to remove it
|
|
} finally {
|
|
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;
|
|
});
|
|
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}
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{
|
|
padding: `${config.space.S100} ${config.space.S300}`,
|
|
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
|
}}
|
|
>
|
|
<Text
|
|
size="T200"
|
|
style={{
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
opacity: 0.8,
|
|
}}
|
|
>
|
|
{typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'}
|
|
</Text>
|
|
<Text size="T200" style={{ opacity: 0.6, 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>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|