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 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 { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
@@ -14,8 +26,6 @@ function formatTimeAgo(ts: number): string {
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (days === 1) return 'yesterday'; if (days === 1) return 'yesterday';
if (days < 7) return `${days}d ago`; 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(); return new Date(ts).toLocaleDateString();
} }
@@ -30,65 +40,83 @@ function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
const room = mx.getRoom(bookmark.roomId); const room = mx.getRoom(bookmark.roomId);
const displayRoomName = room?.name ?? bookmark.roomName; const displayRoomName = room?.name ?? bookmark.roomName;
const handleJump = () => {
onJump(bookmark.roomId, bookmark.eventId);
};
const handleRemove = () => {
onRemove(bookmark.eventId);
};
return ( return (
<Box <Box
direction="Column" direction="Column"
gap="100" gap="200"
style={{ style={{
padding: `${config.space.S200} ${config.space.S300}`, padding: `${config.space.S300} ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300, borderBottom: `1px solid ${color.Surface.ContainerLine}`,
borderBottomStyle: 'solid', background: color.Surface.Container,
borderBottomColor: 'var(--bg-surface-border)', transition: 'background 0.1s',
}} }}
> >
<Text size="L400" truncate> {/* Room name row */}
{displayRoomName} <Box alignItems="Center" gap="100">
</Text> <Icon src={Icons.Hash} size="50" style={{ opacity: 0.5, flexShrink: 0 }} />
<Text <Text
size="T300" size="T200"
priority="300" style={{
color: color.Primary.Main,
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{displayRoomName}
</Text>
</Box>
{/* Message preview */}
<Box
style={{ style={{
overflow: 'hidden', background: color.SurfaceVariant.Container,
display: '-webkit-box', borderRadius: config.radii.R300,
WebkitLineClamp: 2, borderLeft: `3px solid ${color.Primary.Main}`,
WebkitBoxOrient: 'vertical', padding: `${config.space.S100} ${config.space.S200}`,
wordBreak: 'break-word',
}} }}
> >
{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"> <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" priority="300"> <Text size="T200" style={{ opacity: 0.5 }}>
Saved {formatTimeAgo(bookmark.savedAt)} {formatTimeAgo(bookmark.savedAt)}
</Text> </Text>
<Box gap="100" shrink="No"> <Box gap="100" shrink="No">
<Button <Button
size="300" size="300"
variant="Secondary" variant="Primary"
fill="Soft" fill="Soft"
radii="300" radii="300"
onClick={handleJump} onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
before={<Icon size="100" src={Icons.ArrowRight} />} before={<Icon size="100" src={Icons.ArrowRight} />}
> >
<Text size="T300">Jump</Text> <Text size="T300">Jump</Text>
</Button> </Button>
<IconButton <IconButton
size="300" size="300"
variant="Critical" variant="Surface"
fill="None" fill="None"
radii="300" radii="300"
onClick={handleRemove} onClick={() => onRemove(bookmark.eventId)}
aria-label="Remove bookmark" aria-label="Remove bookmark"
> >
<Icon size="100" src={Icons.Cross} /> <Icon size="100" src={Icons.Delete} />
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
@@ -129,22 +157,21 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
width: '266px', width: '266px',
height: '100%', height: '100%',
flexShrink: 0, flexShrink: 0,
borderLeftWidth: config.borderWidth.B300, borderLeft: `1px solid ${color.Surface.ContainerLine}`,
borderLeftStyle: 'solid', background: color.Surface.Container,
borderLeftColor: 'var(--bg-surface-border)',
backgroundColor: 'var(--bg-background)',
overflow: 'hidden', overflow: 'hidden',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}} }}
> >
{/* Header */}
<Header <Header
style={{ style={{
flexShrink: 0, flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`, 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" size="600"
> >
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
@@ -152,44 +179,84 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
<Box grow="Yes"> <Box grow="Yes">
<Text size="H5">Saved Messages</Text> <Text size="H5">Saved Messages</Text>
</Box> </Box>
<IconButton variant="Background" aria-label="Close saved messages" onClick={onClose}> <IconButton
<Icon src={Icons.Cross} /> size="300"
variant="Surface"
radii="300"
aria-label="Close saved messages"
onClick={onClose}
>
<Icon src={Icons.Cross} size="100" />
</IconButton> </IconButton>
</Box> </Box>
</Header> </Header>
{/* Search */}
<Box <Box
style={{ style={{
flexShrink: 0, flexShrink: 0,
padding: config.space.S200, padding: config.space.S200,
borderBottomWidth: config.borderWidth.B300, borderBottom: `1px solid ${color.Surface.ContainerLine}`,
borderBottomStyle: 'solid', background: color.SurfaceVariant.Container,
borderBottomColor: 'var(--bg-surface-border)',
}} }}
> >
<Input <Input
variant="Background" variant="Surface"
placeholder="Filter saved messages..." size="400"
radii="400"
placeholder="Search saved messages…"
value={filter} value={filter}
onChange={handleFilterChange} onChange={handleFilterChange}
before={<Icon size="200" src={Icons.Search} />} 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> </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 }}> <Scroll variant="Background" size="300" style={{ flexGrow: 1, minHeight: 0 }}>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<Box <Box
direction="Column" direction="Column"
alignItems="Center" alignItems="Center"
justifyContent="Center" justifyContent="Center"
gap="200" gap="300"
style={{ padding: config.space.S500 }} 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"> <Text size="T300" priority="300" align="Center">
{bookmarks.length === 0 {bookmarks.length === 0
? 'No saved messages yet. Right-click any message to bookmark it.' ? 'No saved messages yet.\nRight-click any message to bookmark it.'
: 'No bookmarks match your filter.'} : 'No bookmarks match your search.'}
</Text> </Text>
</Box> </Box>
) : ( ) : (
@@ -316,6 +316,13 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
setEvents(getStateEvents()); setEvents(getStateEvents());
}, [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 () => { const handleLoadMore = useCallback(async () => {
if (loading || !canLoadMore) return; if (loading || !canLoadMore) return;
setLoading(true); setLoading(true);
@@ -208,8 +208,17 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
background: color.Surface.Container, background: color.Surface.Container,
}} }}
> >
<Text size="H4" style={{ fontWeight: 700 }}> <Text
{stats.totalMessages} size="H5"
style={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{stats.totalMessages.toLocaleString()}
</Text> </Text>
<Text size="T200" priority="300" align="Center"> <Text size="T200" priority="300" align="Center">
Messages Messages
@@ -228,8 +237,17 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
background: color.Surface.Container, background: color.Surface.Container,
}} }}
> >
<Text size="H4" style={{ fontWeight: 700 }}> <Text
{stats.uniqueParticipants} size="H5"
style={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{stats.uniqueParticipants.toLocaleString()}
</Text> </Text>
<Text size="T200" priority="300" align="Center"> <Text size="T200" priority="300" align="Center">
Participants Participants
@@ -248,8 +266,17 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
background: color.Surface.Container, background: color.Surface.Container,
}} }}
> >
<Text size="H4" style={{ fontWeight: 700 }}> <Text
{stats.totalCached} size="H5"
style={{
fontWeight: 700,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{stats.totalCached.toLocaleString()}
</Text> </Text>
<Text size="T200" priority="300" align="Center"> <Text size="T200" priority="300" align="Center">
Cached events Cached events
+5 -3
View File
@@ -598,8 +598,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, [editor, isMarkdown, mx, roomId, replyDraft]); }, [editor, isMarkdown, mx, roomId, replyDraft]);
const handleScheduleClick = useCallback(() => { const handleScheduleClick = useCallback(() => {
// Pre-fill from editor if there's content; open blank if editor is empty.
const content = buildCurrentTextContent(); const content = buildCurrentTextContent();
if (!content) return;
setScheduleContent(content); setScheduleContent(content);
setScheduleOpen(true); setScheduleOpen(true);
}, [buildCurrentTextContent]); }, [buildCurrentTextContent]);
@@ -1171,10 +1171,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} }
/> />
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />} {pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
{scheduleOpen && scheduleContent && ( {scheduleOpen && (
<ScheduleMessageModal <ScheduleMessageModal
roomId={roomId} roomId={roomId}
content={scheduleContent} initialBody={
typeof scheduleContent?.body === 'string' ? scheduleContent.body : undefined
}
onScheduled={handleScheduled} onScheduled={handleScheduled}
onClose={() => { onClose={() => {
setScheduleOpen(false); setScheduleOpen(false);
+34 -25
View File
@@ -22,7 +22,8 @@ import { scheduleMessage } from '../../utils/scheduledMessages';
interface ScheduleMessageModalProps { interface ScheduleMessageModalProps {
roomId: string; 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; onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
onClose: () => void; onClose: () => void;
} }
@@ -66,11 +67,12 @@ function toLocalDatetimeValue(date: Date): string {
export function ScheduleMessageModal({ export function ScheduleMessageModal({
roomId, roomId,
content, initialBody,
onScheduled, onScheduled,
onClose, onClose,
}: ScheduleMessageModalProps) { }: ScheduleMessageModalProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [messageText, setMessageText] = useState(initialBody ?? '');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -126,6 +128,12 @@ export function ScheduleMessageModal({
return; return;
} }
if (!messageText.trim()) {
setError('Please enter a message to schedule.');
return;
}
const content: IContent = { body: messageText.trim(), msgtype: 'm.text' };
setError(null); setError(null);
setSubmitting(true); setSubmitting(true);
try { try {
@@ -187,31 +195,32 @@ export function ScheduleMessageModal({
{/* Body */} {/* Body */}
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}> <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
{/* Message preview */} {/* Message input */}
{typeof content.body === 'string' && content.body.trim() !== '' && ( <Box direction="Column" gap="100">
<Box <Text as="label" htmlFor="schedule-message-body" size="L400">
direction="Column" Message
gap="100" </Text>
<textarea
id="schedule-message-body"
rows={3}
placeholder="Type your message here…"
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
style={{ style={{
background: color.SurfaceVariant.Container, background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300, 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> </Box>
<Text
size="T300"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
opacity: 0.8,
}}
>
{content.body as string}
</Text>
</Box>
)}
{/* Datetime picker */} {/* Datetime picker */}
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
@@ -249,7 +258,7 @@ export function ScheduleMessageModal({
</Box> </Box>
) : ( ) : (
datetimeValue && ( 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 Must be at least 1 minute in the future
</Text> </Text>
) )
@@ -257,7 +266,7 @@ export function ScheduleMessageModal({
{/* Error */} {/* Error */}
{error && ( {error && (
<Text size="T300" style={{ color: 'var(--tc-danger-normal)' }}> <Text size="T300" style={{ color: color.Critical.Main }}>
{error} {error}
</Text> </Text>
)} )}
+2 -3
View File
@@ -7,11 +7,10 @@ export type CompressionResult = {
}; };
const COMPRESSIBLE_TYPES = ['image/jpeg', 'image/png']; 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 { export function isCompressible(file: File): boolean {
return COMPRESSIBLE_TYPES.includes(file.type) && file.size >= COMPRESSION_SKIP_THRESHOLD; return COMPRESSIBLE_TYPES.includes(file.type);
} }
/** /**