9273eb5f2e
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>
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, 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>
|
|
);
|
|
}
|