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:
2026-06-04 10:26:08 -04:00
parent ad508ac61e
commit 9273eb5f2e
19 changed files with 1694 additions and 7 deletions
@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect } from 'react';
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
@@ -12,6 +12,7 @@ import {
} from '../../state/room/roomInputDrafts';
import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { compressImage, formatFileSize, isCompressible } from '../../utils/imageCompression';
type PreviewImageProps = {
fileItem: TUploadItem;
@@ -97,6 +98,105 @@ function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
) : null;
}
type CompressionCheckboxProps = {
fileItem: TUploadItem;
metadata: TUploadMetadata;
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
};
function CompressionCheckbox({ fileItem, metadata, setMetadata }: CompressionCheckboxProps) {
const originalFile = fileItem.originalFile as File;
const [compressing, setCompressing] = useState(false);
const compressPromiseRef = useRef<Promise<void> | null>(null);
if (!isCompressible(originalFile)) return null;
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked;
if (!checked) {
setMetadata(fileItem, { ...metadata, compressImage: false, compressionResult: undefined });
return;
}
// Optimistically mark as enabled; kick off compression in background
setMetadata(fileItem, { ...metadata, compressImage: true, compressionResult: undefined });
setCompressing(true);
const p = compressImage(originalFile).then((result) => {
setCompressing(false);
setMetadata(fileItem, { ...metadata, compressImage: true, compressionResult: result });
});
compressPromiseRef.current = p;
};
const checked = !!metadata.compressImage;
const result = metadata.compressionResult;
const savingPct =
result && result.originalSize > 0
? Math.round(((result.originalSize - result.compressedSize) / result.originalSize) * 100)
: null;
return (
<Box
direction="Column"
gap="100"
style={{
marginTop: '4px',
padding: '6px 8px',
background: 'var(--bg-surface-low)',
borderRadius: '6px',
border: '1px solid var(--bg-surface-border)',
fontSize: '0.8rem',
}}
>
<Box alignItems="Center" gap="200">
<input
id={`compress-${originalFile.name}-${originalFile.size}`}
type="checkbox"
checked={checked}
onChange={handleChange}
style={{ cursor: 'pointer', accentColor: 'var(--bg-secondary)' }}
/>
<label
htmlFor={`compress-${originalFile.name}-${originalFile.size}`}
style={{ cursor: 'pointer', color: 'var(--text-primary)', userSelect: 'none' }}
>
Compress before uploading
</label>
{compressing && (
<Text size="T200" style={{ color: 'var(--text-secondary)', marginLeft: '4px' }}>
estimating
</Text>
)}
</Box>
{checked && !compressing && result !== undefined && (
<Text
size="T200"
style={{
color: result
? savingPct !== null && savingPct > 0
? 'var(--tc-success-normal, #2e7d32)'
: 'var(--text-secondary)'
: 'var(--tc-danger-normal)',
paddingLeft: '20px',
}}
>
{result
? savingPct !== null && savingPct > 0
? `→ ~${formatFileSize(result.compressedSize)} (${savingPct}% smaller)`
: `${formatFileSize(result.compressedSize)} (no significant saving)`
: 'Compression not available for this file'}
</Text>
)}
{checked && !compressing && result === undefined && (
<Text size="T200" style={{ color: 'var(--text-secondary)', paddingLeft: '20px' }}>
Original: {formatFileSize(originalFile.size)}
</Text>
)}
</Box>
);
}
type UploadCardRendererProps = {
isEncrypted?: boolean;
fileItem: TUploadItem;
@@ -204,6 +304,11 @@ export function UploadCardRenderer({
}}
/>
)}
<CompressionCheckbox
fileItem={fileItem}
metadata={metadata}
setMetadata={setMetadata}
/>
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
)}