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
@@ -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"