Files
cinny/src/app/features/room/ScheduledMessagesTray.tsx
T
jared 9273eb5f2e feat: bookmarks, message scheduling, image compression, room insights
P3-1: Message Bookmarks — right-click any message to bookmark; saved to
io.lotus.bookmarks account data (max 500, syncs across devices); star
icon in sidebar opens BookmarksPanel with filter, Jump-to-message, and
remove buttons; reactive to AccountData events

P3-2: Message Scheduling (MSC4140) — clock button next to send opens
ScheduleMessageModal with datetime-local picker; validates ≥1 min future;
calls PUT org.matrix.msc4140 delayed event API; collapsible
ScheduledMessagesTray above composer lists pending messages with cancel;
local Jotai atom tracks scheduled messages per room

P3-3: File Upload Compression — opt-in checkbox per JPEG/PNG file ≥200KB
in upload preview; canvas API compresses at 0.82 quality; shows before/
after size estimate; compressed blob used in upload when checked

P3-7: Room Insights — new Insights tab in room settings; top 5 active
members (bar chart), top 5 reactions (chips), media breakdown (4 tiles),
24-hour activity heatmap (CSS bar chart); all from local cache only with
disclaimer banner; never the first tab shown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:26:08 -04:00

176 lines
5.9 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtom } from 'jotai';
import { Box, 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 */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S100} ${config.space.S300}`,
cursor: 'pointer',
}}
onClick={() => setExpanded((v) => !v)}
as="button"
aria-expanded={expanded}
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
>
<Icon src={Icons.Clock} size="50" />
<Text size="T200" style={{ flex: 1, fontWeight: 600 }}>
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
</Text>
<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />
</Box>
{/* 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>
);
}