From 9273eb5f2e0c52e427471b4867cd8a61652ce276 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 4 Jun 2026 10:26:08 -0400 Subject: [PATCH] feat: bookmarks, message scheduling, image compression, room insights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../upload-card/UploadCardRenderer.tsx | 107 ++++- src/app/features/bookmarks/BookmarksPanel.tsx | 210 +++++++++ .../features/room-settings/RoomInsights.tsx | 442 ++++++++++++++++++ .../features/room-settings/RoomSettings.tsx | 9 + src/app/features/room/RoomInput.tsx | 152 +++++- .../features/room/ScheduleMessageModal.tsx | 299 ++++++++++++ .../features/room/ScheduledMessagesTray.tsx | 175 +++++++ src/app/features/room/message/Message.tsx | 44 ++ src/app/hooks/useBookmarks.ts | 83 ++++ src/app/pages/client/ClientLayout.tsx | 15 +- src/app/pages/client/SidebarNav.tsx | 2 + src/app/pages/client/sidebar/BookmarksTab.tsx | 23 + src/app/pages/client/sidebar/index.ts | 1 + src/app/state/bookmarksPanel.ts | 3 + src/app/state/room/roomInputDrafts.ts | 5 + src/app/state/roomSettings.ts | 1 + src/app/state/scheduledMessages.ts | 16 + src/app/utils/imageCompression.ts | 69 +++ src/app/utils/scheduledMessages.ts | 45 ++ 19 files changed, 1694 insertions(+), 7 deletions(-) create mode 100644 src/app/features/bookmarks/BookmarksPanel.tsx create mode 100644 src/app/features/room-settings/RoomInsights.tsx create mode 100644 src/app/features/room/ScheduleMessageModal.tsx create mode 100644 src/app/features/room/ScheduledMessagesTray.tsx create mode 100644 src/app/hooks/useBookmarks.ts create mode 100644 src/app/pages/client/sidebar/BookmarksTab.tsx create mode 100644 src/app/state/bookmarksPanel.ts create mode 100644 src/app/state/scheduledMessages.ts create mode 100644 src/app/utils/imageCompression.ts create mode 100644 src/app/utils/scheduledMessages.ts diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index 98ef56dec..5028046b6 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -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 | null>(null); + + if (!isCompressible(originalFile)) return null; + + const handleChange = async (e: React.ChangeEvent) => { + 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 ( + + + + + {compressing && ( + + estimating… + + )} + + {checked && !compressing && result !== undefined && ( + 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'} + + )} + {checked && !compressing && result === undefined && ( + + Original: {formatFileSize(originalFile.size)} + + )} + + ); +} + type UploadCardRendererProps = { isEncrypted?: boolean; fileItem: TUploadItem; @@ -204,6 +304,11 @@ export function UploadCardRenderer({ }} /> )} + {upload.status === UploadStatus.Idle && !fileSizeExceeded && ( )} diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx new file mode 100644 index 000000000..ce5ead1ea --- /dev/null +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -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 ( + + + {displayRoomName} + + + {bookmark.previewText || '(no preview)'} + + + + Saved {formatTimeAgo(bookmark.savedAt)} + + + + + + + + + + ); +} + +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) => { + 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 ( + +
+ + + + Saved Messages + + + + + +
+ + + } + /> + + + + {filtered.length === 0 ? ( + + + + {bookmarks.length === 0 + ? 'No saved messages yet. Right-click any message to bookmark it.' + : 'No bookmarks match your filter.'} + + + ) : ( + + {filtered.map((bk) => ( + + ))} + + )} + +
+ ); +} diff --git a/src/app/features/room-settings/RoomInsights.tsx b/src/app/features/room-settings/RoomInsights.tsx new file mode 100644 index 000000000..7da71bb9d --- /dev/null +++ b/src/app/features/room-settings/RoomInsights.tsx @@ -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 ( + + {label} + + ); +} + +// ── Stat tile ───────────────────────────────────────────────────────────────── + +function StatTile({ emoji, count, label }: { emoji: string; count: number; label: string }) { + return ( + + {emoji} + + {count} + + + {label} + + + ); +} + +// ── 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(); + 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(); + 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(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 ( + + + + + + + Insights + + + + + + + + + + + + + + + {/* ── Disclaimer banner ── */} + + + + + + Based on {stats.totalMessages} locally cached message + {stats.totalMessages !== 1 ? 's' : ''} + + + {stats.oldestTs !== null && stats.newestTs !== null && ( + + from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)} + + )} + + + + {/* ── Summary row ── */} + + + + + + {stats.totalMessages} + + + Messages + + + + + {stats.uniqueParticipants} + + + Participants + + + + + {stats.totalCached} + + + Cached events + + + + + + {/* ── Media shared ── */} + + + + + + + + + + + {/* ── Most active members ── */} + {stats.top5.length > 0 && ( + + + + {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 ( + + {/* Rank */} + + {index + 1} + + + {/* Avatar */} + + + } + /> + + + + {/* Name + bar */} + + + {displayName} + + +
+ + {count} + + + + + ); + })} + + + )} + + {/* ── Top reactions ── */} + {stats.top5Reactions.length > 0 && ( + + + + {stats.top5Reactions.map(([emoji, count]) => ( + + {emoji} + + {count} + + + ))} + + + )} + + {/* ── Activity by hour ── */} + + + + {/* Bars */} + + {stats.hourBuckets.map((count, h) => ( + +
0 && count === maxHour + ? color.Primary.Main + : color.SurfaceVariant.Container, + borderRadius: '2px 2px 0 0', + transition: 'height 0.2s ease', + }} + /> + + ))} + + + {/* Hour labels: show 0, 6, 12, 18 */} + + {stats.hourBuckets.map((_, h) => ( + + {h % 6 === 0 ? ( + + {h} + + ) : null} + + ))} + + + + Hour of day (local time, 0 = midnight) + + + + {/* Bottom padding */} + + + + + + + ); +} diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index f5ed8ae5d..e8d1f2a2a 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -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 && ( )} + {activePage === RoomSettingsPage.InsightsPage && ( + + )} ); } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 56a445725..429cdd972 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( setCharCount(0); }, [roomId]); const [pollOpen, setPollOpen] = useState(false); + const [scheduleOpen, setScheduleOpen] = useState(false); + const [scheduleContent, setScheduleContent] = useState(null); + const setScheduledMessages = useSetAtom(scheduledMessagesAtom); const alive = useAlive(); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); @@ -401,16 +408,55 @@ export const RoomInput = forwardRef( 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( 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( )} + ( {charCount} )} + + + ( } /> {pollOpen && setPollOpen(false)} />} + {scheduleOpen && scheduleContent && ( + { + setScheduleOpen(false); + setScheduleContent(null); + }} + /> + )}
); }, diff --git a/src/app/features/room/ScheduleMessageModal.tsx b/src/app/features/room/ScheduleMessageModal.tsx new file mode 100644 index 000000000..5fcfbffe9 --- /dev/null +++ b/src/app/features/room/ScheduleMessageModal.tsx @@ -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(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(() => + 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 = 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 ( + }> + + + + {/* Header */} +
+ + + + Schedule Message + + + + + +
+ + {/* Body */} + + {/* Message preview */} + {typeof content.body === 'string' && content.body.trim() !== '' && ( + + Message + + {content.body as string} + + + )} + + {/* Datetime picker */} + + + Send at + + 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', + }} + /> + + + {/* Preview */} + {preview ? ( + + + {preview.label} + + + ({preview.relative}) + + + ) : ( + datetimeValue && ( + + Must be at least 1 minute in the future + + ) + )} + + {/* Error */} + {error && ( + + {error} + + )} + + + {/* Footer */} + + + + +
+
+
+
+ ); +} diff --git a/src/app/features/room/ScheduledMessagesTray.tsx b/src/app/features/room/ScheduledMessagesTray.tsx new file mode 100644 index 000000000..e34e7466e --- /dev/null +++ b/src/app/features/room/ScheduledMessagesTray.tsx @@ -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>(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 ( + + {/* Tray header */} + setExpanded((v) => !v)} + as="button" + aria-expanded={expanded} + aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`} + > + + + {messages.length} scheduled message{messages.length !== 1 ? 's' : ''} + + + + + {/* Tray items */} + {expanded && ( + + {messages.map((msg) => ( + + + {typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)'} + + + {formatSendAt(msg.sendAt)} + + { + e.stopPropagation(); + handleCancel(msg); + }} + > + + + + ))} + + )} + + ); +} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 1691063a1..0df416d62 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); 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( )} + {!mEvent.isRedacted() && 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(); + }} + > + + {isBookmarked(mEvent.getId()!) + ? 'Remove Bookmark' + : 'Bookmark Message'} + + + )} {!isThreadedMessage && ( Promise; + removeBookmark: (eventId: string) => Promise; + isBookmarked: (eventId: string) => boolean; +} { + const mx = useMatrixClient(); + const [bookmarks, setBookmarks] = useState(() => readBookmarks(mx)); + + useAccountDataCallback( + mx, + useCallback( + (evt) => { + if (evt.getType() === BOOKMARKS_KEY) { + setBookmarks(evt.getContent()?.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 }; +} diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 68cb3615a..3c37a29ee 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -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 ( <> {children}
+ {bookmarksOpen && screenSize === ScreenSize.Desktop && ( + <> + + setBookmarksOpen(false)} /> + + )}
); diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 9ee04d315..c0ea3951b 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -16,6 +16,7 @@ import { SettingsTab, UnverifiedTab, SearchTab, + BookmarksTab, } from './sidebar'; import { CreateTab } from './sidebar/CreateTab'; @@ -44,6 +45,7 @@ export function SidebarNav() { + diff --git a/src/app/pages/client/sidebar/BookmarksTab.tsx b/src/app/pages/client/sidebar/BookmarksTab.tsx new file mode 100644 index 000000000..d69ef2f1d --- /dev/null +++ b/src/app/pages/client/sidebar/BookmarksTab.tsx @@ -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 ( + + + {(triggerRef) => ( + + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index d44cfaa26..f52d3b03e 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -6,3 +6,4 @@ export * from './ExploreTab'; export * from './SettingsTab'; export * from './UnverifiedTab'; export * from './SearchTab'; +export * from './BookmarksTab'; diff --git a/src/app/state/bookmarksPanel.ts b/src/app/state/bookmarksPanel.ts new file mode 100644 index 000000000..73c8a1576 --- /dev/null +++ b/src/app/state/bookmarksPanel.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const bookmarksPanelAtom = atom(false); diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 40d06dc87..e82c6a0a4 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -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 = { diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts index 64a594125..19d2b6673 100644 --- a/src/app/state/roomSettings.ts +++ b/src/app/state/roomSettings.ts @@ -9,6 +9,7 @@ export enum RoomSettingsPage { ExportPage, ActivityLogPage, ServerACLPage, + InsightsPage, } export type RoomSettingsState = { diff --git a/src/app/state/scheduledMessages.ts b/src/app/state/scheduledMessages.ts new file mode 100644 index 000000000..410a45937 --- /dev/null +++ b/src/app/state/scheduledMessages.ts @@ -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 + * Stores all locally-tracked scheduled messages across rooms. + * MSC4140 has no list endpoint, so we track them ourselves. + */ +export const scheduledMessagesAtom = atom>(new Map()); diff --git a/src/app/utils/imageCompression.ts b/src/app/utils/imageCompression.ts new file mode 100644 index 000000000..9d83d801d --- /dev/null +++ b/src/app/utils/imageCompression.ts @@ -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 { + 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 { + 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`; +} diff --git a/src/app/utils/scheduledMessages.ts b/src/app/utils/scheduledMessages.ts new file mode 100644 index 000000000..701e67782 --- /dev/null +++ b/src/app/utils/scheduledMessages.ts @@ -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 { + 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 { + 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 { + const path = `/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`; + await mx.http.authedRequest(Method.Post, path, undefined, { action: 'restart' }, { prefix: '' }); +}