2026-06-04 10:26:08 -04:00
|
|
|
import React, { ChangeEvent, useState } from 'react';
|
2026-06-04 12:07:12 -04:00
|
|
|
import {
|
|
|
|
|
Box,
|
|
|
|
|
Button,
|
|
|
|
|
Header,
|
|
|
|
|
Icon,
|
|
|
|
|
IconButton,
|
|
|
|
|
Icons,
|
|
|
|
|
Input,
|
|
|
|
|
Scroll,
|
|
|
|
|
Text,
|
|
|
|
|
color,
|
|
|
|
|
config,
|
|
|
|
|
} from 'folds';
|
2026-06-04 10:26:08 -04:00
|
|
|
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`;
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box
|
|
|
|
|
direction="Column"
|
2026-06-04 12:07:12 -04:00
|
|
|
gap="200"
|
2026-06-04 10:26:08 -04:00
|
|
|
style={{
|
2026-06-04 12:07:12 -04:00
|
|
|
padding: `${config.space.S300} ${config.space.S300}`,
|
|
|
|
|
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
|
|
|
|
background: color.Surface.Container,
|
|
|
|
|
transition: 'background 0.1s',
|
2026-06-04 10:26:08 -04:00
|
|
|
}}
|
|
|
|
|
>
|
2026-06-04 12:07:12 -04:00
|
|
|
{/* 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
|
2026-06-04 10:26:08 -04:00
|
|
|
style={{
|
2026-06-04 12:07:12 -04:00
|
|
|
background: color.SurfaceVariant.Container,
|
|
|
|
|
borderRadius: config.radii.R300,
|
|
|
|
|
borderLeft: `3px solid ${color.Primary.Main}`,
|
|
|
|
|
padding: `${config.space.S100} ${config.space.S200}`,
|
2026-06-04 10:26:08 -04:00
|
|
|
}}
|
|
|
|
|
>
|
2026-06-04 12:07:12 -04:00
|
|
|
<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 */}
|
2026-06-04 10:26:08 -04:00
|
|
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
2026-06-04 12:07:12 -04:00
|
|
|
<Text size="T200" style={{ opacity: 0.5 }}>
|
|
|
|
|
{formatTimeAgo(bookmark.savedAt)}
|
2026-06-04 10:26:08 -04:00
|
|
|
</Text>
|
|
|
|
|
<Box gap="100" shrink="No">
|
|
|
|
|
<Button
|
|
|
|
|
size="300"
|
2026-06-04 12:07:12 -04:00
|
|
|
variant="Primary"
|
2026-06-04 10:26:08 -04:00
|
|
|
fill="Soft"
|
|
|
|
|
radii="300"
|
2026-06-04 12:07:12 -04:00
|
|
|
onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
|
2026-06-04 10:26:08 -04:00
|
|
|
before={<Icon size="100" src={Icons.ArrowRight} />}
|
|
|
|
|
>
|
|
|
|
|
<Text size="T300">Jump</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="300"
|
2026-06-04 12:07:12 -04:00
|
|
|
variant="Surface"
|
2026-06-04 10:26:08 -04:00
|
|
|
fill="None"
|
|
|
|
|
radii="300"
|
2026-06-04 12:07:12 -04:00
|
|
|
onClick={() => onRemove(bookmark.eventId)}
|
2026-06-04 10:26:08 -04:00
|
|
|
aria-label="Remove bookmark"
|
|
|
|
|
>
|
2026-06-04 12:07:12 -04:00
|
|
|
<Icon size="100" src={Icons.Delete} />
|
2026-06-04 10:26:08 -04:00
|
|
|
</IconButton>
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type BookmarksPanelProps = {
|
|
|
|
|
onClose: () => void;
|
2026-06-17 20:26:43 -04:00
|
|
|
isMobile?: boolean;
|
2026-06-04 10:26:08 -04:00
|
|
|
};
|
|
|
|
|
|
2026-06-17 20:26:43 -04:00
|
|
|
export function BookmarksPanel({ onClose, isMobile }: BookmarksPanelProps) {
|
2026-06-04 10:26:08 -04:00
|
|
|
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<HTMLInputElement>) => {
|
|
|
|
|
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 (
|
|
|
|
|
<Box
|
|
|
|
|
direction="Column"
|
|
|
|
|
style={{
|
2026-06-17 20:26:43 -04:00
|
|
|
width: isMobile ? '100%' : '266px',
|
2026-06-04 10:26:08 -04:00
|
|
|
height: '100%',
|
2026-06-17 20:26:43 -04:00
|
|
|
position: isMobile ? 'absolute' : 'static',
|
|
|
|
|
top: isMobile ? 0 : 'auto',
|
|
|
|
|
left: isMobile ? 0 : 'auto',
|
|
|
|
|
zIndex: isMobile ? 100 : 'auto',
|
2026-06-04 10:26:08 -04:00
|
|
|
flexShrink: 0,
|
2026-06-17 20:26:43 -04:00
|
|
|
borderLeft: isMobile ? 'none' : `1px solid ${color.Surface.ContainerLine}`,
|
2026-06-04 12:07:12 -04:00
|
|
|
background: color.Surface.Container,
|
2026-06-04 10:26:08 -04:00
|
|
|
overflow: 'hidden',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-06-04 12:07:12 -04:00
|
|
|
{/* Header */}
|
2026-06-04 10:26:08 -04:00
|
|
|
<Header
|
|
|
|
|
style={{
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
2026-06-04 12:07:12 -04:00
|
|
|
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
2026-06-04 10:26:08 -04:00
|
|
|
}}
|
2026-06-04 12:07:12 -04:00
|
|
|
variant="Surface"
|
2026-06-04 10:26:08 -04:00
|
|
|
size="600"
|
|
|
|
|
>
|
|
|
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
|
|
|
<Icon src={Icons.Star} size="200" />
|
|
|
|
|
<Box grow="Yes">
|
|
|
|
|
<Text size="H5">Saved Messages</Text>
|
|
|
|
|
</Box>
|
2026-06-04 12:07:12 -04:00
|
|
|
<IconButton
|
|
|
|
|
size="300"
|
|
|
|
|
variant="Surface"
|
|
|
|
|
radii="300"
|
|
|
|
|
aria-label="Close saved messages"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
>
|
|
|
|
|
<Icon src={Icons.Cross} size="100" />
|
2026-06-04 10:26:08 -04:00
|
|
|
</IconButton>
|
|
|
|
|
</Box>
|
|
|
|
|
</Header>
|
|
|
|
|
|
2026-06-04 12:07:12 -04:00
|
|
|
{/* Search */}
|
2026-06-04 10:26:08 -04:00
|
|
|
<Box
|
|
|
|
|
style={{
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
padding: config.space.S200,
|
2026-06-04 12:07:12 -04:00
|
|
|
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
|
|
|
|
background: color.SurfaceVariant.Container,
|
2026-06-04 10:26:08 -04:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Input
|
2026-06-04 12:07:12 -04:00
|
|
|
variant="Surface"
|
|
|
|
|
size="400"
|
|
|
|
|
radii="400"
|
|
|
|
|
placeholder="Search saved messages…"
|
2026-06-04 10:26:08 -04:00
|
|
|
value={filter}
|
|
|
|
|
onChange={handleFilterChange}
|
|
|
|
|
before={<Icon size="200" src={Icons.Search} />}
|
2026-06-04 12:07:12 -04:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-04 10:26:08 -04:00
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
|
2026-06-04 12:07:12 -04:00
|
|
|
{/* 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 */}
|
2026-06-04 10:26:08 -04:00
|
|
|
<Scroll variant="Background" size="300" style={{ flexGrow: 1, minHeight: 0 }}>
|
|
|
|
|
{filtered.length === 0 ? (
|
|
|
|
|
<Box
|
|
|
|
|
direction="Column"
|
|
|
|
|
alignItems="Center"
|
|
|
|
|
justifyContent="Center"
|
2026-06-04 12:07:12 -04:00
|
|
|
gap="300"
|
|
|
|
|
style={{ padding: config.space.S600, textAlign: 'center' }}
|
2026-06-04 10:26:08 -04:00
|
|
|
>
|
2026-06-04 12:07:12 -04:00
|
|
|
<Icon size="600" src={Icons.Star} style={{ opacity: 0.3 }} />
|
2026-06-04 10:26:08 -04:00
|
|
|
<Text size="T300" priority="300" align="Center">
|
|
|
|
|
{bookmarks.length === 0
|
2026-06-04 12:07:12 -04:00
|
|
|
? 'No saved messages yet.\nRight-click any message to bookmark it.'
|
|
|
|
|
: 'No bookmarks match your search.'}
|
2026-06-04 10:26:08 -04:00
|
|
|
</Text>
|
|
|
|
|
</Box>
|
|
|
|
|
) : (
|
|
|
|
|
<Box direction="Column">
|
|
|
|
|
{filtered.map((bk) => (
|
|
|
|
|
<BookmarkItem
|
|
|
|
|
key={bk.eventId}
|
|
|
|
|
bookmark={bk}
|
|
|
|
|
onJump={handleJump}
|
|
|
|
|
onRemove={removeBookmark}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</Box>
|
|
|
|
|
)}
|
|
|
|
|
</Scroll>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|