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>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user