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,210 @@
|
||||
import React, { ChangeEvent, useState } from 'react';
|
||||
import { Box, Button, Header, Icon, IconButton, Icons, Input, Scroll, Text, config } from 'folds';
|
||||
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 5) return `${weeks}w ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
type BookmarkItemProps = {
|
||||
bookmark: Bookmark;
|
||||
onJump: (roomId: string, eventId: string) => void;
|
||||
onRemove: (eventId: string) => void;
|
||||
};
|
||||
|
||||
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = mx.getRoom(bookmark.roomId);
|
||||
const displayRoomName = room?.name ?? bookmark.roomName;
|
||||
|
||||
const handleJump = () => {
|
||||
onJump(bookmark.roomId, bookmark.eventId);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onRemove(bookmark.eventId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: 'var(--bg-surface-border)',
|
||||
}}
|
||||
>
|
||||
<Text size="L400" truncate>
|
||||
{displayRoomName}
|
||||
</Text>
|
||||
<Text
|
||||
size="T300"
|
||||
priority="300"
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{bookmark.previewText || '(no preview)'}
|
||||
</Text>
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="T200" priority="300">
|
||||
Saved {formatTimeAgo(bookmark.savedAt)}
|
||||
</Text>
|
||||
<Box gap="100" shrink="No">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={handleJump}
|
||||
before={<Icon size="100" src={Icons.ArrowRight} />}
|
||||
>
|
||||
<Text size="T300">Jump</Text>
|
||||
</Button>
|
||||
<IconButton
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
onClick={handleRemove}
|
||||
aria-label="Remove bookmark"
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type BookmarksPanelProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
const { bookmarks, removeBookmark } = useBookmarks();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const handleJump = (roomId: string, eventId: string) => {
|
||||
navigateRoom(roomId, eventId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleFilterChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(e.target.value);
|
||||
};
|
||||
|
||||
const filtered: Bookmark[] =
|
||||
filter.trim().length === 0
|
||||
? bookmarks
|
||||
: bookmarks.filter((bk) => {
|
||||
const q = filter.toLowerCase();
|
||||
return bk.previewText.toLowerCase().includes(q) || bk.roomName.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
width: '266px',
|
||||
height: '100%',
|
||||
flexShrink: 0,
|
||||
borderLeftWidth: config.borderWidth.B300,
|
||||
borderLeftStyle: 'solid',
|
||||
borderLeftColor: 'var(--bg-surface-border)',
|
||||
backgroundColor: 'var(--bg-background)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Background"
|
||||
size="600"
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Star} size="200" />
|
||||
<Box grow="Yes">
|
||||
<Text size="H5">Saved Messages</Text>
|
||||
</Box>
|
||||
<IconButton variant="Background" aria-label="Close saved messages" onClick={onClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: config.space.S200,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: 'var(--bg-surface-border)',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
variant="Background"
|
||||
placeholder="Filter saved messages..."
|
||||
value={filter}
|
||||
onChange={handleFilterChange}
|
||||
before={<Icon size="200" src={Icons.Search} />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Scroll variant="Background" size="300" style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
{filtered.length === 0 ? (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="200"
|
||||
style={{ padding: config.space.S500 }}
|
||||
>
|
||||
<Icon size="600" src={Icons.Star} />
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
{bookmarks.length === 0
|
||||
? 'No saved messages yet. Right-click any message to bookmark it.'
|
||||
: 'No bookmarks match your filter.'}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box direction="Column">
|
||||
{filtered.map((bk) => (
|
||||
<BookmarkItem
|
||||
key={bk.eventId}
|
||||
bookmark={bk}
|
||||
onJump={handleJump}
|
||||
onRemove={removeBookmark}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Scroll>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user