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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user