Files
cinny/src/app/features/bookmarks/BookmarksPanel.tsx
T

246 lines
7.7 KiB
TypeScript
Raw Normal View History

import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
import {
Avatar,
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Input,
Scroll,
Text,
color,
config,
} from 'folds';
import classNames from 'classnames';
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { RoomAvatar } from '../../components/room-avatar';
import { getRoomAvatarUrl } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './BookmarksPanel.css';
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 useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined;
const displayRoomName = room?.name ?? bookmark.roomName;
const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined;
return (
<Box
direction="Column"
gap="200"
style={{
padding: config.space.S300,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
{/* Room identity + remove */}
<Box alignItems="Center" gap="200">
<Avatar size="200" radii="300">
<RoomAvatar
roomId={bookmark.roomId}
src={avatarUrl}
alt={displayRoomName}
renderFallback={() => <Text size="H6">{nameInitials(displayRoomName)}</Text>}
/>
</Avatar>
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{displayRoomName}
</Text>
<Text size="T200" priority="300">
{formatTimeAgo(bookmark.savedAt)}
</Text>
</Box>
<IconButton
size="300"
variant="SurfaceVariant"
fill="None"
radii="300"
onClick={() => onRemove(bookmark.eventId)}
aria-label="Remove saved message"
>
<Icon size="100" src={Icons.Delete} />
</IconButton>
</Box>
{/* Message preview — clicking jumps to the message */}
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
onClick={() => onJump(bookmark.roomId, bookmark.eventId)}
aria-label="Jump to saved message"
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
>
<Text className={css.BookmarkPreview} size="T200" priority="400">
{bookmark.previewText || '(no preview)'}
</Text>
</Button>
</Box>
);
}
type BookmarksPanelProps = {
onClose: () => void;
};
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
const { bookmarks, removeBookmark } = useBookmarks();
const { navigateRoom } = useRoomNavigate();
const [filter, setFilter] = useState('');
// Escape closes the panel (parity with the app's other overlays/drawers).
useEffect(() => {
const handleKeyDown = (evt: KeyboardEvent) => {
if (evt.key === 'Escape') {
stopPropagation(evt);
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleJump = useCallback(
(roomId: string, eventId: string) => {
navigateRoom(roomId, eventId);
onClose();
},
[navigateRoom, onClose],
);
const handleFilterChange = (e: ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
};
const query = filter.trim().toLowerCase();
const filtered: Bookmark[] =
query.length === 0
? bookmarks
: bookmarks.filter(
(bk) =>
bk.previewText.toLowerCase().includes(query) ||
bk.roomName.toLowerCase().includes(query),
);
return (
<Box
className={classNames(css.BookmarksPanel, ContainerColor({ variant: 'Background' }))}
shrink="No"
direction="Column"
>
<Header className={css.BookmarksHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Star} size="200" />
<Box grow="Yes">
<Text size="H4" truncate>
Saved Messages
</Text>
</Box>
<IconButton size="300" radii="300" aria-label="Close saved messages" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Header>
{/* Search */}
<Box className={css.BookmarksToolbar} direction="Column" gap="100">
<Input
variant="SurfaceVariant"
size="400"
radii="300"
placeholder="Search saved messages…"
value={filter}
onChange={handleFilterChange}
before={<Icon size="100" src={Icons.Search} />}
after={
filter.length > 0 ? (
<IconButton
size="300"
variant="SurfaceVariant"
fill="None"
radii="300"
aria-label="Clear search"
onClick={() => setFilter('')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
) : undefined
}
/>
{bookmarks.length > 0 && (
<Text size="T200" priority="300">
{filtered.length === bookmarks.length
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
: `${filtered.length} of ${bookmarks.length} messages`}
</Text>
)}
</Box>
{/* List */}
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
{filtered.length === 0 ? (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="300"
style={{ padding: config.space.S700, textAlign: 'center' }}
>
<Icon size="600" src={Icons.Star} style={{ opacity: config.opacity.Disabled }} />
<Text size="T300" priority="300" align="Center">
{bookmarks.length === 0
? 'No saved messages yet. Right-click any message to save it.'
: 'No saved messages match your search.'}
</Text>
</Box>
) : (
<Box className={css.BookmarksContent} direction="Column" gap="200">
{filtered.map((bk) => (
<BookmarkItem
key={bk.eventId}
bookmark={bk}
onJump={handleJump}
onRemove={removeBookmark}
/>
))}
</Box>
)}
</Scroll>
</Box>
</Box>
);
}