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:
@@ -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} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user