fix: schedule button, compression visibility, activity log, insights overflow, bookmarks UI

Schedule message: modal now always opens (even with empty composer);
includes its own message textarea pre-filled from editor content;
removed null-content early-return guard from handleScheduleClick;
fixed error text to use color.Critical.Main not CSS var

Image compression: removed 200KB size threshold — checkbox now shows
for all JPEG/PNG uploads (not just large ones); 'no significant saving'
message handles already-small files gracefully

Activity log: auto-paginate on mount — state events are absent from
initial sync window, so the log was always empty until Load More was
clicked manually

Insights summary: Text size H4→H5 with toLocaleString() formatting and
overflow:ellipsis so large numbers don't push tiles off screen

Bookmarks panel: replaced var(--bg-*) CSS vars (undefined in folds
themes) with color.Surface/SurfaceVariant/Primary folds tokens; added
left accent border on message preview block, count badge, clear button
in search, improved empty state, cleaner button hierarchy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:07:12 -04:00
parent 8f5afcda08
commit c6760b0ba4
6 changed files with 202 additions and 91 deletions
+121 -54
View File
@@ -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 (
<Box
direction="Column"
gap="100"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
borderBottomStyle: 'solid',
borderBottomColor: 'var(--bg-surface-border)',
padding: `${config.space.S300} ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
transition: 'background 0.1s',
}}
>
<Text size="L400" truncate>
{displayRoomName}
</Text>
<Text
size="T300"
priority="300"
{/* Room name row */}
<Box alignItems="Center" gap="100">
<Icon src={Icons.Hash} size="50" style={{ opacity: 0.5, flexShrink: 0 }} />
<Text
size="T200"
style={{
color: color.Primary.Main,
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{displayRoomName}
</Text>
</Box>
{/* Message preview */}
<Box
style={{
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
borderLeft: `3px solid ${color.Primary.Main}`,
padding: `${config.space.S100} ${config.space.S200}`,
}}
>
{bookmark.previewText || '(no preview)'}
</Text>
<Text
size="T300"
style={{
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
opacity: 0.9,
}}
>
{bookmark.previewText || '(no preview)'}
</Text>
</Box>
{/* Footer row */}
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" priority="300">
Saved {formatTimeAgo(bookmark.savedAt)}
<Text size="T200" style={{ opacity: 0.5 }}>
{formatTimeAgo(bookmark.savedAt)}
</Text>
<Box gap="100" shrink="No">
<Button
size="300"
variant="Secondary"
variant="Primary"
fill="Soft"
radii="300"
onClick={handleJump}
onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
before={<Icon size="100" src={Icons.ArrowRight} />}
>
<Text size="T300">Jump</Text>
</Button>
<IconButton
size="300"
variant="Critical"
variant="Surface"
fill="None"
radii="300"
onClick={handleRemove}
onClick={() => onRemove(bookmark.eventId)}
aria-label="Remove bookmark"
>
<Icon size="100" src={Icons.Cross} />
<Icon size="100" src={Icons.Delete} />
</IconButton>
</Box>
</Box>
@@ -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 */}
<Header
style={{
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
variant="Background"
variant="Surface"
size="600"
>
<Box grow="Yes" alignItems="Center" gap="200">
@@ -152,44 +179,84 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
<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
size="300"
variant="Surface"
radii="300"
aria-label="Close saved messages"
onClick={onClose}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
</Box>
</Header>
{/* Search */}
<Box
style={{
flexShrink: 0,
padding: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
borderBottomStyle: 'solid',
borderBottomColor: 'var(--bg-surface-border)',
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Input
variant="Background"
placeholder="Filter saved messages..."
variant="Surface"
size="400"
radii="400"
placeholder="Search saved messages…"
value={filter}
onChange={handleFilterChange}
before={<Icon size="200" src={Icons.Search} />}
after={
filter.length > 0 ? (
<IconButton
size="300"
variant="Surface"
radii="300"
aria-label="Clear search"
onClick={() => setFilter('')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
) : undefined
}
/>
</Box>
{/* Count badge */}
{bookmarks.length > 0 && (
<Box
style={{
flexShrink: 0,
padding: `${config.space.S100} ${config.space.S300}`,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Text size="T200" style={{ opacity: 0.6 }}>
{filtered.length === bookmarks.length
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
: `${filtered.length} of ${bookmarks.length} messages`}
</Text>
</Box>
)}
{/* List */}
<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 }}
gap="300"
style={{ padding: config.space.S600, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Star} />
<Icon size="600" src={Icons.Star} style={{ opacity: 0.3 }} />
<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.'}
? 'No saved messages yet.\nRight-click any message to bookmark it.'
: 'No bookmarks match your search.'}
</Text>
</Box>
) : (
@@ -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);
@@ -208,8 +208,17 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
background: color.Surface.Container,
}}
>
<Text size="H4" style={{ fontWeight: 700 }}>
{stats.totalMessages}
<Text
size="H5"
style={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{stats.totalMessages.toLocaleString()}
</Text>
<Text size="T200" priority="300" align="Center">
Messages
@@ -228,8 +237,17 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
background: color.Surface.Container,
}}
>
<Text size="H4" style={{ fontWeight: 700 }}>
{stats.uniqueParticipants}
<Text
size="H5"
style={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{stats.uniqueParticipants.toLocaleString()}
</Text>
<Text size="T200" priority="300" align="Center">
Participants
@@ -248,8 +266,17 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
background: color.Surface.Container,
}}
>
<Text size="H4" style={{ fontWeight: 700 }}>
{stats.totalCached}
<Text
size="H5"
style={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{stats.totalCached.toLocaleString()}
</Text>
<Text size="T200" priority="300" align="Center">
Cached events
+5 -3
View File
@@ -598,8 +598,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, [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<HTMLDivElement, RoomInputProps>(
}
/>
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
{scheduleOpen && scheduleContent && (
{scheduleOpen && (
<ScheduleMessageModal
roomId={roomId}
content={scheduleContent}
initialBody={
typeof scheduleContent?.body === 'string' ? scheduleContent.body : undefined
}
onScheduled={handleScheduled}
onClose={() => {
setScheduleOpen(false);
+34 -25
View File
@@ -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<string | null>(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 */}
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{/* Message preview */}
{typeof content.body === 'string' && content.body.trim() !== '' && (
<Box
direction="Column"
gap="100"
{/* Message input */}
<Box direction="Column" gap="100">
<Text as="label" htmlFor="schedule-message-body" size="L400">
Message
</Text>
<textarea
id="schedule-message-body"
rows={3}
placeholder="Type your message here…"
value={messageText}
onChange={(e) => setMessageText(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,
padding: `${config.space.S200} ${config.space.S300}`,
fontSize: '0.875rem',
width: '100%',
boxSizing: 'border-box',
outline: 'none',
resize: 'vertical',
fontFamily: 'inherit',
}}
>
<Text size="L400">Message</Text>
<Text
size="T300"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.8,
}}
>
{content.body as string}
</Text>
</Box>
)}
/>
</Box>
{/* Datetime picker */}
<Box direction="Column" gap="100">
@@ -249,7 +258,7 @@ export function ScheduleMessageModal({
</Box>
) : (
datetimeValue && (
<Text size="T200" style={{ color: 'var(--tc-danger-normal)' }}>
<Text size="T200" style={{ color: color.Critical.Main }}>
Must be at least 1 minute in the future
</Text>
)
@@ -257,7 +266,7 @@ export function ScheduleMessageModal({
{/* Error */}
{error && (
<Text size="T300" style={{ color: 'var(--tc-danger-normal)' }}>
<Text size="T300" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
+2 -3
View File
@@ -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);
}
/**