feat: bookmarks, message scheduling, image compression, room insights
CI / Build & Quality Checks (push) Failing after 5m48s

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 9e9b021611
commit 6c2f8e0d8e
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} />
)}
@@ -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>
);
}
@@ -0,0 +1,442 @@
import React, { useMemo } from 'react';
import { Avatar, Box, Icon, IconButton, Icons, Scroll, Text, color, config } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page';
import { useRoom } from '../../hooks/useRoom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
// ── Section header ────────────────────────────────────────────────────────────
function SectionHeader({ label }: { label: string }) {
return (
<Text
size="L400"
style={{
textTransform: 'uppercase',
letterSpacing: '0.06em',
opacity: 0.6,
}}
>
{label}
</Text>
);
}
// ── Stat tile ─────────────────────────────────────────────────────────────────
function StatTile({ emoji, count, label }: { emoji: string; count: number; label: string }) {
return (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
flex: 1,
minWidth: 64,
padding: `${config.space.S300} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Text size="H4">{emoji}</Text>
<Text size="H4" style={{ fontWeight: 700 }}>
{count}
</Text>
<Text size="T200" priority="300" align="Center">
{label}
</Text>
</Box>
);
}
// ── Main component ────────────────────────────────────────────────────────────
type RoomInsightsProps = {
requestClose: () => void;
};
export function RoomInsights({ requestClose }: RoomInsightsProps) {
const mx = useMatrixClient();
const room = useRoom();
const useAuthentication = useMediaAuthentication();
const stats = useMemo(() => {
const events = room.getLiveTimeline().getEvents();
// ── A. Message count by member ──────────────────────────────────────────
const msgCounts = new Map<string, number>();
for (const ev of events) {
if (ev.getType() === EventType.RoomMessage && !ev.isDecryptionFailure()) {
const sender = ev.getSender();
if (sender) msgCounts.set(sender, (msgCounts.get(sender) ?? 0) + 1);
}
}
const top5 = [...msgCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
// ── B. Top 5 reactions ──────────────────────────────────────────────────
const reactionCounts = new Map<string, number>();
for (const ev of events) {
if (ev.getType() === EventType.Reaction) {
const key = ev.getContent()['m.relates_to']?.key as string | undefined;
if (key) reactionCounts.set(key, (reactionCounts.get(key) ?? 0) + 1);
}
}
const top5Reactions = [...reactionCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
// ── C. Media breakdown ──────────────────────────────────────────────────
const mediaCounts = { image: 0, video: 0, audio: 0, file: 0 };
for (const ev of events) {
if (ev.getType() !== EventType.RoomMessage) continue;
const msgtype = ev.getContent().msgtype as string | undefined;
if (msgtype === 'm.image') mediaCounts.image++;
else if (msgtype === 'm.video') mediaCounts.video++;
else if (msgtype === 'm.audio') mediaCounts.audio++;
else if (msgtype === 'm.file') mediaCounts.file++;
}
// ── D. Activity heatmap — messages per hour ─────────────────────────────
const hourBuckets = new Array<number>(24).fill(0);
for (const ev of events) {
if (ev.getType() === EventType.RoomMessage) {
hourBuckets[new Date(ev.getTs()).getHours()]++;
}
}
// ── E. Summary stats ────────────────────────────────────────────────────
const totalMessages = [...msgCounts.values()].reduce((a, b) => a + b, 0);
const uniqueParticipants = msgCounts.size;
const msgEvents = events.filter((ev) => ev.getType() === EventType.RoomMessage);
const allTs = msgEvents.map((ev) => ev.getTs());
const oldestTs = allTs.length > 0 ? Math.min(...allTs) : null;
const newestTs = allTs.length > 0 ? Math.max(...allTs) : null;
return {
top5,
top5Reactions,
mediaCounts,
hourBuckets,
totalMessages,
uniqueParticipants,
oldestTs,
newestTs,
totalCached: events.length,
};
}, [room]);
const maxHour = Math.max(...stats.hourBuckets, 1);
const maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1;
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Info} size="200" />
<Text as="h2" size="H3" truncate>
Insights
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="500">
{/* ── Disclaimer banner ── */}
<Box
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Warning.Main}`,
background: color.Warning.Container,
}}
>
<Icon src={Icons.Warning} size="200" />
<Box direction="Column" gap="100">
<Text size="T300" style={{ color: color.Warning.OnContainer }}>
<strong>
Based on {stats.totalMessages} locally cached message
{stats.totalMessages !== 1 ? 's' : ''}
</strong>
</Text>
{stats.oldestTs !== null && stats.newestTs !== null && (
<Text size="T200" style={{ color: color.Warning.OnContainer, opacity: 0.8 }}>
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
</Text>
)}
</Box>
</Box>
{/* ── Summary row ── */}
<Box direction="Column" gap="200">
<SectionHeader label="Summary" />
<Box gap="200" wrap="Wrap">
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
flex: 1,
minWidth: 80,
padding: `${config.space.S300} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Text size="H4" style={{ fontWeight: 700 }}>
{stats.totalMessages}
</Text>
<Text size="T200" priority="300" align="Center">
Messages
</Text>
</Box>
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
flex: 1,
minWidth: 80,
padding: `${config.space.S300} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Text size="H4" style={{ fontWeight: 700 }}>
{stats.uniqueParticipants}
</Text>
<Text size="T200" priority="300" align="Center">
Participants
</Text>
</Box>
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
flex: 1,
minWidth: 80,
padding: `${config.space.S300} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
<Text size="H4" style={{ fontWeight: 700 }}>
{stats.totalCached}
</Text>
<Text size="T200" priority="300" align="Center">
Cached events
</Text>
</Box>
</Box>
</Box>
{/* ── Media shared ── */}
<Box direction="Column" gap="200">
<SectionHeader label="Media Shared" />
<Box gap="200" wrap="Wrap">
<StatTile emoji="🖼️" count={stats.mediaCounts.image} label="Images" />
<StatTile emoji="🎬" count={stats.mediaCounts.video} label="Videos" />
<StatTile emoji="🎵" count={stats.mediaCounts.audio} label="Audio" />
<StatTile emoji="📎" count={stats.mediaCounts.file} label="Files" />
</Box>
</Box>
{/* ── Most active members ── */}
{stats.top5.length > 0 && (
<Box direction="Column" gap="200">
<SectionHeader label="Most Active Members" />
<Box direction="Column" gap="200">
{stats.top5.map(([userId, count], index) => {
const displayName =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ??
undefined)
: undefined;
const barWidth = `${Math.max(4, (count / maxMsgCount) * 100)}%`;
return (
<Box key={userId} alignItems="Center" gap="200">
{/* Rank */}
<Text
size="T300"
priority="300"
style={{ width: 16, flexShrink: 0, textAlign: 'center' }}
>
{index + 1}
</Text>
{/* Avatar */}
<Box shrink="No">
<Avatar size="200" radii="300">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={displayName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</Box>
{/* Name + bar */}
<Box
grow="Yes"
direction="Column"
gap="100"
style={{ overflow: 'hidden' }}
>
<Text size="T300" truncate style={{ lineHeight: 1 }}>
{displayName}
</Text>
<Box alignItems="Center" gap="200">
<div
style={{
height: 6,
width: barWidth,
background: color.Primary.Main,
borderRadius: 3,
transition: 'width 0.3s ease',
flexShrink: 0,
}}
/>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{count}
</Text>
</Box>
</Box>
</Box>
);
})}
</Box>
</Box>
)}
{/* ── Top reactions ── */}
{stats.top5Reactions.length > 0 && (
<Box direction="Column" gap="200">
<SectionHeader label="Top Reactions" />
<Box gap="200" wrap="Wrap">
{stats.top5Reactions.map(([emoji, count]) => (
<Box
key={emoji}
alignItems="Center"
gap="100"
style={{
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
flexShrink: 0,
}}
>
<Text size="H5">{emoji}</Text>
<Text size="T300" priority="300">
{count}
</Text>
</Box>
))}
</Box>
</Box>
)}
{/* ── Activity by hour ── */}
<Box direction="Column" gap="200">
<SectionHeader label="Activity by Hour" />
<Box
direction="Column"
gap="100"
style={{
padding: config.space.S300,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
{/* Bars */}
<Box
alignItems="End"
style={{
height: 60,
gap: 2,
}}
>
{stats.hourBuckets.map((count, h) => (
<Box
key={h}
direction="Column"
alignItems="Center"
style={{ flex: 1, height: '100%', justifyContent: 'flex-end' }}
>
<div
title={`${h}:00 — ${count} message${count !== 1 ? 's' : ''}`}
style={{
width: '100%',
height: `${Math.max(2, (count / maxHour) * 48)}px`,
background:
count > 0 && count === maxHour
? color.Primary.Main
: color.SurfaceVariant.Container,
borderRadius: '2px 2px 0 0',
transition: 'height 0.2s ease',
}}
/>
</Box>
))}
</Box>
{/* Hour labels: show 0, 6, 12, 18 */}
<Box style={{ gap: 0 }}>
{stats.hourBuckets.map((_, h) => (
<Box key={h} justifyContent="Center" style={{ flex: 1 }}>
{h % 6 === 0 ? (
<Text size="T200" priority="300" align="Center" style={{ fontSize: 9 }}>
{h}
</Text>
) : null}
</Box>
))}
</Box>
</Box>
<Text size="T200" priority="300">
Hour of day (local time, 0 = midnight)
</Text>
</Box>
{/* Bottom padding */}
<Box style={{ height: config.space.S200 }} />
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}
@@ -20,6 +20,7 @@ import { DeveloperTools } from '../common-settings/developer-tools';
import { ExportRoomHistory } from './ExportRoomHistory';
import { RoomActivityLog } from './RoomActivityLog';
import { RoomServerACL } from './RoomServerACL';
import { RoomInsights } from './RoomInsights';
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { StateEvent } from '../../../types/matrix/room';
@@ -66,6 +67,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
name: 'Activity',
icon: Icons.RecentClock,
},
{
page: RoomSettingsPage.InsightsPage,
name: 'Insights',
icon: Icons.Info,
},
];
const SERVER_ACL_MENU_ITEM: RoomSettingsMenuItem = {
@@ -218,6 +224,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
{activePage === RoomSettingsPage.ServerACLPage && (
<RoomServerACL requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.InsightsPage && (
<RoomInsights requestClose={handlePageRequestClose} />
)}
</PageRoot>
);
}
+147 -5
View File
@@ -7,7 +7,7 @@ import React, {
useRef,
useState,
} from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
@@ -66,6 +66,7 @@ import {
getMxIdLocalPart,
mxcUrlToHttp,
} from '../../utils/matrix';
import { compressImage } from '../../utils/imageCompression';
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
import { useFilePicker } from '../../hooks/useFilePicker';
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
@@ -124,6 +125,9 @@ import { useComposingCheck } from '../../hooks/useComposingCheck';
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
import { PollCreator } from './PollCreator';
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
const GifPicker = React.lazy(() =>
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
@@ -167,6 +171,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setCharCount(0);
}, [roomId]);
const [pollOpen, setPollOpen] = useState(false);
const [scheduleOpen, setScheduleOpen] = useState(false);
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
const alive = useAlive();
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
@@ -401,16 +408,55 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const fileItem = selectedFiles.find((f) => f.file === upload.file);
if (!fileItem) throw new Error('Broken upload');
// Resolve the MXC URL to use — may be overridden if compression is enabled
let mxc = upload.mxc;
if (
fileItem.metadata.compressImage &&
fileItem.originalFile.type.startsWith('image') &&
(fileItem.originalFile.type === 'image/jpeg' ||
fileItem.originalFile.type === 'image/png')
) {
// Use the cached compression result if available, otherwise compute it now
let compressionResult = fileItem.metadata.compressionResult;
if (compressionResult === undefined) {
compressionResult = await compressImage(fileItem.originalFile as File);
}
if (compressionResult) {
const originalFile = fileItem.originalFile as File;
const compressedFile = new File([compressionResult.blob], originalFile.name, {
type: 'image/jpeg',
});
const uploadRes = await mx.uploadContent(compressedFile, {
name: originalFile.name,
type: 'image/jpeg',
});
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) {
mxc = compressedMxc;
// Build a synthetic fileItem that refers to the compressed file so
// getImageMsgContent picks up the correct dimensions and type.
const compressedItem = {
...fileItem,
file: compressedFile,
originalFile: compressedFile,
};
return getImageMsgContent(mx, compressedItem, mxc);
}
}
}
if (fileItem.file.type.startsWith('image')) {
return getImageMsgContent(mx, fileItem, upload.mxc);
return getImageMsgContent(mx, fileItem, mxc);
}
if (fileItem.file.type.startsWith('video')) {
return getVideoMsgContent(mx, fileItem, upload.mxc);
return getVideoMsgContent(mx, fileItem, mxc);
}
if (fileItem.file.type.startsWith('audio')) {
return getAudioMsgContent(fileItem, upload.mxc);
return getAudioMsgContent(fileItem, mxc);
}
return getFileMsgContent(fileItem, upload.mxc);
return getFileMsgContent(fileItem, mxc);
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
@@ -501,6 +547,80 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
/**
* Build a text message content object from the current editor state.
* Returns null if the editor is empty or the input is a command.
*/
const buildCurrentTextContent = useCallback((): IContent | null => {
const commandName = getBeginCommand(editor);
// Don't schedule commands
if (commandName) return null;
const plainText = toPlainText(editor.children, isMarkdown).trim();
const customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
allowTextFormatting: true,
allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
}),
);
if (plainText === '') return null;
const body = plainText;
const formattedBody = customHtml;
const mentionData = getMentions(mx, roomId, editor);
const content: IContent = {
msgtype: MsgType.Text,
body,
};
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
mentionData.users.add(replyDraft.userId);
}
content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room);
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
content.format = 'org.matrix.custom.html';
content.formatted_body = formattedBody;
}
if (replyDraft) {
content['m.relates_to'] = {
'm.in_reply_to': { event_id: replyDraft.eventId },
};
if (replyDraft.relation?.rel_type === RelationType.Thread) {
content['m.relates_to'].event_id = replyDraft.relation.event_id;
content['m.relates_to'].rel_type = RelationType.Thread;
content['m.relates_to'].is_falling_back = false;
}
}
return content;
}, [editor, isMarkdown, mx, roomId, replyDraft]);
const handleScheduleClick = useCallback(() => {
const content = buildCurrentTextContent();
if (!content) return;
setScheduleContent(content);
setScheduleOpen(true);
}, [buildCurrentTextContent]);
const handleScheduled = useCallback(
(delayId: string, sendAt: number, content: IContent) => {
setScheduledMessages((prev) => {
const next = new Map(prev);
const current = next.get(roomId) ?? [];
next.set(roomId, [...current, { delayId, roomId, content, sendAt }]);
return next;
});
resetEditor(editor);
resetEditorHistory(editor);
localStorage.removeItem(`draft-msg-${roomId}`);
setReplyDraft(undefined);
sendTypingStatus(false);
},
[setScheduledMessages, roomId, editor, setReplyDraft, sendTypingStatus],
);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
if (
@@ -750,6 +870,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text>
</Box>
)}
<ScheduledMessagesTray roomId={roomId} />
<CustomEditor
editableName="RoomInput"
editor={editor}
@@ -1019,6 +1140,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{charCount}
</Text>
)}
<IconButton
onClick={handleScheduleClick}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Schedule message"
title="Schedule message"
>
<Icon src={Icons.Clock} size="100" />
</IconButton>
<IconButton
onClick={submit}
variant="SurfaceVariant"
@@ -1040,6 +1171,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
/>
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
{scheduleOpen && scheduleContent && (
<ScheduleMessageModal
roomId={roomId}
content={scheduleContent}
onScheduled={handleScheduled}
onClose={() => {
setScheduleOpen(false);
setScheduleContent(null);
}}
/>
)}
</div>
);
},
@@ -0,0 +1,299 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
color,
config,
} from 'folds';
import { IContent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { scheduleMessage } from '../../utils/scheduledMessages';
interface ScheduleMessageModalProps {
roomId: string;
content: IContent;
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
onClose: () => void;
}
function formatRelativeTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours > 0 && minutes > 0) return `in ${hours}h ${minutes}m`;
if (hours > 0) return `in ${hours}h`;
if (minutes > 0) return `in ${minutes}m`;
return 'in less than a minute';
}
function formatSendAt(sendAt: Date): string {
const now = new Date();
const isToday =
sendAt.getFullYear() === now.getFullYear() &&
sendAt.getMonth() === now.getMonth() &&
sendAt.getDate() === now.getDate();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isTomorrow =
sendAt.getFullYear() === tomorrow.getFullYear() &&
sendAt.getMonth() === tomorrow.getMonth() &&
sendAt.getDate() === tomorrow.getDate();
const timeStr = sendAt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (isToday) return `Today at ${timeStr}`;
if (isTomorrow) return `Tomorrow at ${timeStr}`;
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
}
function toLocalDatetimeValue(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
);
}
export function ScheduleMessageModal({
roomId,
content,
onScheduled,
onClose,
}: ScheduleMessageModalProps) {
const mx = useMatrixClient();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Default: 1 hour from now, rounded to nearest 5 minutes
const defaultDate = () => {
const d = new Date(Date.now() + 60 * 60 * 1000);
d.setSeconds(0, 0);
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
return d;
};
const [datetimeValue, setDatetimeValue] = useState<string>(() =>
toLocalDatetimeValue(defaultDate()),
);
const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
const updatePreview = useCallback((value: string) => {
if (!value) {
setPreview(null);
return;
}
const sendAt = new Date(value);
const now = Date.now();
const diffMs = sendAt.getTime() - now;
if (Number.isNaN(sendAt.getTime()) || diffMs < 60_000) {
setPreview(null);
return;
}
setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) });
}, []);
useEffect(() => {
updatePreview(datetimeValue);
}, [datetimeValue, updatePreview]);
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
if (submitting) return;
if (!datetimeValue) {
setError('Please select a date and time.');
return;
}
const sendAt = new Date(datetimeValue);
if (Number.isNaN(sendAt.getTime())) {
setError('Invalid date/time.');
return;
}
const diffMs = sendAt.getTime() - Date.now();
if (diffMs < 60_000) {
setError('Scheduled time must be at least 1 minute in the future.');
return;
}
setError(null);
setSubmitting(true);
try {
const delayId = await scheduleMessage(mx, roomId, content, sendAt.getTime());
onScheduled(delayId, sendAt.getTime(), content);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
setSubmitting(false);
}
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Box
as="form"
role="dialog"
aria-modal="true"
aria-labelledby="schedule-message-title"
onSubmit={handleSubmit}
direction="Column"
style={{
background: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: color.Other.Shadow,
width: '100vw',
maxWidth: 400,
overflow: 'hidden',
}}
>
{/* Header */}
<Header
variant="Surface"
size="500"
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Clock} size="100" />
<Text id="schedule-message-title" size="H4">
Schedule Message
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
{/* Body */}
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{/* Message preview */}
{typeof content.body === 'string' && content.body.trim() !== '' && (
<Box
direction="Column"
gap="100"
style={{
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
padding: config.space.S200,
}}
>
<Text size="L400">Message</Text>
<Text
size="T300"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.8,
}}
>
{content.body as string}
</Text>
</Box>
)}
{/* Datetime picker */}
<Box direction="Column" gap="100">
<Text as="label" htmlFor="schedule-datetime" size="L400">
Send at
</Text>
<input
id="schedule-datetime"
type="datetime-local"
value={datetimeValue}
onChange={(e) => setDatetimeValue(e.target.value)}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
padding: `${config.space.S200} ${config.space.S300}`,
fontSize: '0.875rem',
width: '100%',
boxSizing: 'border-box',
outline: 'none',
}}
/>
</Box>
{/* Preview */}
{preview ? (
<Box direction="Column" gap="100">
<Text size="T300" style={{ opacity: 0.7 }}>
{preview.label}
</Text>
<Text size="T200" style={{ opacity: 0.5 }}>
({preview.relative})
</Text>
</Box>
) : (
datetimeValue && (
<Text size="T200" style={{ color: 'var(--tc-danger-normal)' }}>
Must be at least 1 minute in the future
</Text>
)
)}
{/* Error */}
{error && (
<Text size="T300" style={{ color: 'var(--tc-danger-normal)' }}>
{error}
</Text>
)}
</Box>
{/* Footer */}
<Box
gap="300"
justifyContent="End"
style={{
padding: `${config.space.S200} ${config.space.S400} ${config.space.S400}`,
}}
>
<Button
type="button"
variant="Secondary"
fill="None"
radii="300"
onClick={onClose}
disabled={submitting}
>
<Text size="B400">Cancel</Text>
</Button>
<Button
type="submit"
variant="Primary"
radii="300"
disabled={submitting || !preview}
before={submitting ? <Spinner variant="Primary" size="100" /> : undefined}
>
<Text size="B400">Schedule</Text>
</Button>
</Box>
</Box>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
@@ -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>
);
}
+44
View File
@@ -79,6 +79,7 @@ import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { ForwardMessageDialog } from './ForwardMessageDialog';
import { useBookmarks } from '../../../hooks/useBookmarks';
// Delivery status indicator for own messages
function DeliveryStatus({
@@ -792,6 +793,7 @@ export const Message = React.memo(
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
const [forwardOpen, setForwardOpen] = useState(false);
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
@@ -1128,6 +1130,48 @@ export const Message = React.memo(
</Text>
</MenuItem>
)}
{!mEvent.isRedacted() && mEvent.getId() && (
<MenuItem
size="300"
after={
<Icon
size="100"
src={Icons.Star}
filled={isBookmarked(mEvent.getId()!)}
/>
}
radii="300"
onClick={() => {
const eventId = mEvent.getId()!;
if (isBookmarked(eventId)) {
removeBookmark(eventId);
} else {
const content = mEvent.getContent();
const body: string =
(content?.body as string | undefined) ?? '';
addBookmark({
roomId: room.roomId,
eventId,
savedAt: Date.now(),
previewText: body.slice(0, 120),
roomName: room.name,
});
}
closeMenu();
}}
>
<Text
className={css.MessageMenuItemText}
as="span"
size="T300"
truncate
>
{isBookmarked(mEvent.getId()!)
? 'Remove Bookmark'
: 'Bookmark Message'}
</Text>
</MenuItem>
)}
{!isThreadedMessage && (
<MenuItem
size="300"
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
export type Bookmark = {
roomId: string;
eventId: string;
savedAt: number;
previewText: string;
roomName: string;
};
const BOOKMARKS_KEY = 'io.lotus.bookmarks';
const MAX_BOOKMARKS = 500;
type BookmarksContent = {
bookmarks: Bookmark[];
};
function readBookmarks(mx: MatrixClient): Bookmark[] {
return (
(mx.getAccountData(BOOKMARKS_KEY as any)?.getContent() as BookmarksContent | undefined)
?.bookmarks ?? []
);
}
export function useBookmarks(): {
bookmarks: Bookmark[];
addBookmark: (b: Bookmark) => Promise<void>;
removeBookmark: (eventId: string) => Promise<void>;
isBookmarked: (eventId: string) => boolean;
} {
const mx = useMatrixClient();
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => readBookmarks(mx));
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === BOOKMARKS_KEY) {
setBookmarks(evt.getContent<BookmarksContent>()?.bookmarks ?? []);
}
},
[setBookmarks],
),
);
// Re-read on mx change
useEffect(() => {
setBookmarks(readBookmarks(mx));
}, [mx]);
const addBookmark = useCallback(
async (b: Bookmark) => {
const current = readBookmarks(mx);
// Avoid duplicates
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
let next = [b, ...filtered];
if (next.length > MAX_BOOKMARKS) {
next = next.slice(0, MAX_BOOKMARKS);
}
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
},
[mx],
);
const removeBookmark = useCallback(
async (eventId: string) => {
const current = readBookmarks(mx);
const next = current.filter((bk) => bk.eventId !== eventId);
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
},
[mx],
);
const isBookmarked = useCallback(
(eventId: string) => bookmarks.some((bk) => bk.eventId === eventId),
[bookmarks],
);
return { bookmarks, addBookmark, removeBookmark, isBookmarked };
}
+14 -1
View File
@@ -1,11 +1,18 @@
import React, { ReactNode } from 'react';
import { Box } from 'folds';
import { Box, Line } from 'folds';
import { useAtom } from 'jotai';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
import { BookmarksPanel } from '../../features/bookmarks/BookmarksPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
type ClientLayoutProps = {
nav: ReactNode;
children: ReactNode;
};
export function ClientLayout({ nav, children }: ClientLayoutProps) {
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
const screenSize = useScreenSizeContext();
return (
<>
<a
@@ -37,6 +44,12 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
<Box grow="Yes" as="main" id="main-content">
{children}
</Box>
{bookmarksOpen && screenSize === ScreenSize.Desktop && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<BookmarksPanel onClose={() => setBookmarksOpen(false)} />
</>
)}
</Box>
</>
);
+2
View File
@@ -16,6 +16,7 @@ import {
SettingsTab,
UnverifiedTab,
SearchTab,
BookmarksTab,
} from './sidebar';
import { CreateTab } from './sidebar/CreateTab';
@@ -44,6 +45,7 @@ export function SidebarNav() {
<SidebarStackSeparator />
<SidebarStack>
<SearchTab />
<BookmarksTab />
<UnverifiedTab />
<InboxTab />
<SettingsTab />
@@ -0,0 +1,23 @@
import React from 'react';
import { Icon, Icons } from 'folds';
import { useAtom } from 'jotai';
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
import { bookmarksPanelAtom } from '../../../state/bookmarksPanel';
export function BookmarksTab() {
const [opened, setOpen] = useAtom(bookmarksPanelAtom);
const toggle = () => setOpen((v) => !v);
return (
<SidebarItem active={opened}>
<SidebarItemTooltip tooltip="Saved Messages">
{(triggerRef) => (
<SidebarAvatar as="button" ref={triggerRef} outlined onClick={toggle}>
<Icon src={Icons.Star} filled={opened} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
);
}
+1
View File
@@ -6,3 +6,4 @@ export * from './ExploreTab';
export * from './SettingsTab';
export * from './UnverifiedTab';
export * from './SearchTab';
export * from './BookmarksTab';
+3
View File
@@ -0,0 +1,3 @@
import { atom } from 'jotai';
export const bookmarksPanelAtom = atom<boolean>(false);
+5
View File
@@ -6,10 +6,15 @@ import { IEventRelation } from 'matrix-js-sdk';
import { createUploadAtomFamily } from '../upload';
import { TUploadContent } from '../../utils/matrix';
import { createListAtom } from '../list';
import { CompressionResult } from '../../utils/imageCompression';
export type TUploadMetadata = {
markedAsSpoiler: boolean;
caption?: string;
/** User has opted in to compressing this image before upload */
compressImage?: boolean;
/** Cached compression result (populated in the background when compressImage is set to true) */
compressionResult?: CompressionResult | null;
};
export type TUploadItem = {
+1
View File
@@ -9,6 +9,7 @@ export enum RoomSettingsPage {
ExportPage,
ActivityLogPage,
ServerACLPage,
InsightsPage,
}
export type RoomSettingsState = {
+16
View File
@@ -0,0 +1,16 @@
import { atom } from 'jotai';
import { IContent } from 'matrix-js-sdk';
export type ScheduledMessage = {
delayId: string;
roomId: string;
content: IContent;
sendAt: number; // Unix timestamp ms
};
/**
* Global atom: Map<roomId, ScheduledMessage[]>
* Stores all locally-tracked scheduled messages across rooms.
* MSC4140 has no list endpoint, so we track them ourselves.
*/
export const scheduledMessagesAtom = atom<Map<string, ScheduledMessage[]>>(new Map());
+69
View File
@@ -0,0 +1,69 @@
export type CompressionResult = {
blob: Blob;
originalSize: number;
compressedSize: number;
width: number;
height: number;
};
const COMPRESSIBLE_TYPES = ['image/jpeg', 'image/png'];
const COMPRESSION_SKIP_THRESHOLD = 200 * 1024; // 200 KB
/** Returns true if this file type can be compressed AND the file is large enough to bother. */
export function isCompressible(file: File): boolean {
return COMPRESSIBLE_TYPES.includes(file.type) && file.size >= COMPRESSION_SKIP_THRESHOLD;
}
/**
* Compress an image file via canvas.toBlob.
* Returns null if the file type is not compressible (GIF, SVG, WebP, video, audio, etc.).
*/
export async function compressImage(file: File, quality = 0.82): Promise<CompressionResult | null> {
if (!COMPRESSIBLE_TYPES.includes(file.type)) return null;
const img = await loadImage(file);
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
if (!blob) {
resolve(null);
return;
}
resolve({
blob,
originalSize: file.size,
compressedSize: blob.size,
width: img.naturalWidth,
height: img.naturalHeight,
});
},
'image/jpeg',
quality,
);
});
}
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = reject;
img.src = url;
});
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
+45
View File
@@ -0,0 +1,45 @@
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
/**
* Schedule a message via MSC4140 (delayed messages).
* @param mx - Matrix client instance
* @param roomId - The room to send the message in
* @param content - The message event content
* @param sendAtMs - Unix timestamp (ms) when the message should be sent
* @returns The delay_id returned by the server (use to cancel/restart)
*/
export async function scheduleMessage(
mx: MatrixClient,
roomId: string,
content: IContent,
sendAtMs: number,
): Promise<string> {
const delayMs = sendAtMs - Date.now();
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const path = `/_matrix/client/unstable/org.matrix.msc4140/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}?delay=${Math.max(1000, Math.round(delayMs))}`;
const res = (await mx.http.authedRequest(Method.Put, path, undefined, content, {
prefix: '',
})) as { delay_id: string };
return res.delay_id;
}
/**
* Cancel a scheduled message via MSC4140.
* @param mx - Matrix client instance
* @param delayId - The delay_id from scheduleMessage
*/
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(Method.Post, path, undefined, { action: 'cancel' }, { prefix: '' });
}
/**
* Restart (refresh heartbeat) a scheduled message via MSC4140.
* Resets the delay timer from now.
* @param mx - Matrix client instance
* @param delayId - The delay_id from scheduleMessage
*/
export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(Method.Post, path, undefined, { action: 'restart' }, { prefix: '' });
}