diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx
index ce5ead1ea..bf2b9229a 100644
--- a/src/app/features/bookmarks/BookmarksPanel.tsx
+++ b/src/app/features/bookmarks/BookmarksPanel.tsx
@@ -1,5 +1,17 @@
import React, { ChangeEvent, useState } from 'react';
-import { Box, Button, Header, Icon, IconButton, Icons, Input, Scroll, Text, config } from 'folds';
+import {
+ Box,
+ Button,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Scroll,
+ Text,
+ color,
+ config,
+} from 'folds';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@@ -14,8 +26,6 @@ function formatTimeAgo(ts: number): string {
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();
}
@@ -30,65 +40,83 @@ function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
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}
-
-
+
+
+ {displayRoomName}
+
+
+
+ {/* Message preview */}
+
- {bookmark.previewText || '(no preview)'}
-
+
+ {bookmark.previewText || '(no preview)'}
+
+
+
+ {/* Footer row */}
-
- Saved {formatTimeAgo(bookmark.savedAt)}
+
+ {formatTimeAgo(bookmark.savedAt)}
onRemove(bookmark.eventId)}
aria-label="Remove bookmark"
>
-
+
@@ -129,22 +157,21 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
width: '266px',
height: '100%',
flexShrink: 0,
- borderLeftWidth: config.borderWidth.B300,
- borderLeftStyle: 'solid',
- borderLeftColor: 'var(--bg-surface-border)',
- backgroundColor: 'var(--bg-background)',
+ borderLeft: `1px solid ${color.Surface.ContainerLine}`,
+ background: color.Surface.Container,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
+ {/* Header */}
@@ -152,44 +179,84 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
Saved Messages
-
-
+
+
+ {/* Search */}
}
+ after={
+ filter.length > 0 ? (
+ setFilter('')}
+ >
+
+
+ ) : undefined
+ }
/>
+ {/* Count badge */}
+ {bookmarks.length > 0 && (
+
+
+ {filtered.length === bookmarks.length
+ ? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
+ : `${filtered.length} of ${bookmarks.length} messages`}
+
+
+ )}
+
+ {/* List */}
{filtered.length === 0 ? (
-
+
{bookmarks.length === 0
- ? 'No saved messages yet. Right-click any message to bookmark it.'
- : 'No bookmarks match your filter.'}
+ ? 'No saved messages yet.\nRight-click any message to bookmark it.'
+ : 'No bookmarks match your search.'}
) : (
diff --git a/src/app/features/room-settings/RoomActivityLog.tsx b/src/app/features/room-settings/RoomActivityLog.tsx
index 70a342777..73bb5e361 100644
--- a/src/app/features/room-settings/RoomActivityLog.tsx
+++ b/src/app/features/room-settings/RoomActivityLog.tsx
@@ -316,6 +316,13 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
setEvents(getStateEvents());
}, [getStateEvents]);
+ // Auto-paginate on mount — state events are rarely in the initial sync
+ // window, so we immediately fetch backwards to populate the log.
+ useEffect(() => {
+ handleLoadMore();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const handleLoadMore = useCallback(async () => {
if (loading || !canLoadMore) return;
setLoading(true);
diff --git a/src/app/features/room-settings/RoomInsights.tsx b/src/app/features/room-settings/RoomInsights.tsx
index 7da71bb9d..7b616923f 100644
--- a/src/app/features/room-settings/RoomInsights.tsx
+++ b/src/app/features/room-settings/RoomInsights.tsx
@@ -208,8 +208,17 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
background: color.Surface.Container,
}}
>
-
- {stats.totalMessages}
+
+ {stats.totalMessages.toLocaleString()}
Messages
@@ -228,8 +237,17 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
background: color.Surface.Container,
}}
>
-
- {stats.uniqueParticipants}
+
+ {stats.uniqueParticipants.toLocaleString()}
Participants
@@ -248,8 +266,17 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
background: color.Surface.Container,
}}
>
-
- {stats.totalCached}
+
+ {stats.totalCached.toLocaleString()}
Cached events
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 429cdd972..324fa1843 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -598,8 +598,8 @@ export const RoomInput = forwardRef(
}, [editor, isMarkdown, mx, roomId, replyDraft]);
const handleScheduleClick = useCallback(() => {
+ // Pre-fill from editor if there's content; open blank if editor is empty.
const content = buildCurrentTextContent();
- if (!content) return;
setScheduleContent(content);
setScheduleOpen(true);
}, [buildCurrentTextContent]);
@@ -1171,10 +1171,12 @@ export const RoomInput = forwardRef(
}
/>
{pollOpen && setPollOpen(false)} />}
- {scheduleOpen && scheduleContent && (
+ {scheduleOpen && (
{
setScheduleOpen(false);
diff --git a/src/app/features/room/ScheduleMessageModal.tsx b/src/app/features/room/ScheduleMessageModal.tsx
index 5fcfbffe9..d2f179b5f 100644
--- a/src/app/features/room/ScheduleMessageModal.tsx
+++ b/src/app/features/room/ScheduleMessageModal.tsx
@@ -22,7 +22,8 @@ import { scheduleMessage } from '../../utils/scheduledMessages';
interface ScheduleMessageModalProps {
roomId: string;
- content: IContent;
+ /** Pre-fill the message body from the composer. Pass null/undefined to open blank. */
+ initialBody?: string;
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
onClose: () => void;
}
@@ -66,11 +67,12 @@ function toLocalDatetimeValue(date: Date): string {
export function ScheduleMessageModal({
roomId,
- content,
+ initialBody,
onScheduled,
onClose,
}: ScheduleMessageModalProps) {
const mx = useMatrixClient();
+ const [messageText, setMessageText] = useState(initialBody ?? '');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
@@ -126,6 +128,12 @@ export function ScheduleMessageModal({
return;
}
+ if (!messageText.trim()) {
+ setError('Please enter a message to schedule.');
+ return;
+ }
+
+ const content: IContent = { body: messageText.trim(), msgtype: 'm.text' };
setError(null);
setSubmitting(true);
try {
@@ -187,31 +195,32 @@ export function ScheduleMessageModal({
{/* Body */}
- {/* Message preview */}
- {typeof content.body === 'string' && content.body.trim() !== '' && (
-
+
+ Message
+
+
- )}
+ />
+
{/* Datetime picker */}
@@ -249,7 +258,7 @@ export function ScheduleMessageModal({
) : (
datetimeValue && (
-
+
Must be at least 1 minute in the future
)
@@ -257,7 +266,7 @@ export function ScheduleMessageModal({
{/* Error */}
{error && (
-
+
{error}
)}
diff --git a/src/app/utils/imageCompression.ts b/src/app/utils/imageCompression.ts
index 9d83d801d..757a45eaa 100644
--- a/src/app/utils/imageCompression.ts
+++ b/src/app/utils/imageCompression.ts
@@ -7,11 +7,10 @@ export type CompressionResult = {
};
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. */
+/** Returns true if this file type can be compressed (JPEG or PNG, any size). */
export function isCompressible(file: File): boolean {
- return COMPRESSIBLE_TYPES.includes(file.type) && file.size >= COMPRESSION_SKIP_THRESHOLD;
+ return COMPRESSIBLE_TYPES.includes(file.type);
}
/**