check:prettier was not part of my gate routine, so formatting drift accumulated across the session's touched files (and a few older ones). Run prettier --write to bring the repo back to 'All matched files use Prettier code style!'. Formatting only — no logic changes. tsc/tests/build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
506 lines
16 KiB
TypeScript
506 lines
16 KiB
TypeScript
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { Room } from 'matrix-js-sdk';
|
|
import { useAtom } from 'jotai';
|
|
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
|
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 {
|
|
BookmarkSort,
|
|
isBookmarkSort,
|
|
sortBookmarks,
|
|
groupBookmarksByRoom,
|
|
} from '../../utils/bookmarks';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
|
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { RoomAvatar } from '../../components/room-avatar';
|
|
import { getRoomAvatarUrl, getMemberName } 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();
|
|
}
|
|
|
|
// Remember the last-chosen sort across panel opens (the panel unmounts on close).
|
|
// getOnInit reads localStorage synchronously at init so the persisted sort is
|
|
// applied on the very first render (no flash from the 'newest' default).
|
|
const bookmarkSortAtom = atomWithStorage<BookmarkSort>(
|
|
'cinny_bookmarks_sort_v1',
|
|
'newest',
|
|
createJSONStorage(() => localStorage),
|
|
{ getOnInit: true },
|
|
);
|
|
|
|
const SORT_OPTIONS: { value: BookmarkSort; label: string }[] = [
|
|
{ value: 'newest', label: 'Newest' },
|
|
{ value: 'oldest', label: 'Oldest' },
|
|
{ value: 'room', label: 'By room' },
|
|
];
|
|
|
|
// Segmented sort button — mirrors MediaGallery's tab styling for house consistency.
|
|
function SortButton({
|
|
label,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<Button
|
|
size="300"
|
|
variant={active ? 'Primary' : 'Secondary'}
|
|
fill={active ? 'Solid' : 'Soft'}
|
|
radii="300"
|
|
aria-pressed={active}
|
|
onClick={onClick}
|
|
>
|
|
<Text size="B300">{label}</Text>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
type BookmarkItemProps = {
|
|
bookmark: Bookmark;
|
|
onJump: (roomId: string, eventId: string) => void;
|
|
onRemove: (eventId: string) => void;
|
|
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
|
|
preview?: ReactNode;
|
|
// Live-resolved author name; falls back to the stored snapshot when absent.
|
|
senderName?: string;
|
|
};
|
|
|
|
function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: 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;
|
|
// Prefer a live-resolved author name, then the stored snapshot.
|
|
const author = senderName ?? bookmark.senderName;
|
|
const timeAgo = formatTimeAgo(bookmark.savedAt);
|
|
|
|
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" truncate>
|
|
{author ? `${author} · ${timeAgo}` : timeAgo}
|
|
</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">
|
|
{preview ?? (bookmark.previewText || '(no preview)')}
|
|
</Text>
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
|
|
|
|
// Renders the same layout as BookmarkItem, but resolves the message body live so
|
|
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
|
|
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
|
|
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
|
|
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
|
|
room.findEventById(bookmark.eventId),
|
|
);
|
|
|
|
const snapshot = bookmark.previewText || '(no preview)';
|
|
let preview: ReactNode = snapshot;
|
|
|
|
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
|
|
if (liveEvent) {
|
|
if (liveEvent.isRedacted()) {
|
|
preview = (
|
|
<MessageDeletedContent
|
|
reason={liveEvent.getUnsigned().redacted_because?.content?.reason as string | undefined}
|
|
/>
|
|
);
|
|
} else {
|
|
// body is already the edited text since useRoomEvent applied m.replace.
|
|
const { body } = liveEvent.getContent();
|
|
preview = typeof body === 'string' && body ? body : snapshot;
|
|
}
|
|
}
|
|
|
|
// Resolve the author's current display name from the live event when available.
|
|
const liveSender = liveEvent?.getSender();
|
|
const senderName = liveSender ? getMemberName(room, liveSender) : undefined;
|
|
|
|
return (
|
|
<BookmarkItem
|
|
bookmark={bookmark}
|
|
onJump={onJump}
|
|
onRemove={onRemove}
|
|
preview={preview}
|
|
senderName={senderName}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type RoomGroupHeaderProps = {
|
|
roomId: string;
|
|
roomName: string;
|
|
count: number;
|
|
collapsed: boolean;
|
|
contentId: string;
|
|
onToggle: () => void;
|
|
};
|
|
|
|
// Collapsible section header for the "By room" grouping. Uses the live room name
|
|
// / avatar when the room is joined, falling back to the stored snapshot name.
|
|
function RoomGroupHeader({
|
|
roomId,
|
|
roomName,
|
|
count,
|
|
collapsed,
|
|
contentId,
|
|
onToggle,
|
|
}: RoomGroupHeaderProps) {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const room = mx.getRoom(roomId) ?? undefined;
|
|
const displayRoomName = room?.name ?? roomName;
|
|
const avatarUrl = room
|
|
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
|
|
: undefined;
|
|
|
|
return (
|
|
<Button
|
|
variant="Secondary"
|
|
fill="None"
|
|
size="300"
|
|
radii="300"
|
|
onClick={onToggle}
|
|
aria-expanded={!collapsed}
|
|
aria-controls={contentId}
|
|
// Explicit name avoids the avatar alt + visible name being announced twice,
|
|
// and gives the bare count meaning for screen readers.
|
|
aria-label={`${displayRoomName}, ${count} saved message${count !== 1 ? 's' : ''}`}
|
|
style={{ justifyContent: 'flex-start', padding: config.space.S200 }}
|
|
>
|
|
<Box grow="Yes" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
|
<Icon size="100" src={collapsed ? Icons.ChevronRight : Icons.ChevronBottom} />
|
|
<Avatar size="200" radii="300">
|
|
<RoomAvatar
|
|
roomId={roomId}
|
|
src={avatarUrl}
|
|
alt=""
|
|
renderFallback={() => <Text size="H6">{nameInitials(displayRoomName)}</Text>}
|
|
/>
|
|
</Avatar>
|
|
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
|
|
{displayRoomName}
|
|
</Text>
|
|
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
|
{count}
|
|
</Text>
|
|
</Box>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
type BookmarksPanelProps = {
|
|
onClose: () => void;
|
|
};
|
|
|
|
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
|
const mx = useMatrixClient();
|
|
const { bookmarks, removeBookmark } = useBookmarks();
|
|
const { navigateRoom } = useRoomNavigate();
|
|
const [filter, setFilter] = useState('');
|
|
const [storedSort, setSort] = useAtom(bookmarkSortAtom);
|
|
// Normalize a stale/corrupt persisted value so exactly one sort is always active.
|
|
const sort: BookmarkSort = isBookmarkSort(storedSort) ? storedSort : 'newest';
|
|
// roomIds whose group section is collapsed (only relevant in "By room" mode).
|
|
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
|
|
const toggleGroup = useCallback((roomId: string) => {
|
|
setCollapsed((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(roomId)) next.delete(roomId);
|
|
else next.add(roomId);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// 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[] = useMemo(
|
|
() =>
|
|
query.length === 0
|
|
? bookmarks
|
|
: bookmarks.filter(
|
|
(bk) =>
|
|
bk.previewText.toLowerCase().includes(query) ||
|
|
bk.roomName.toLowerCase().includes(query) ||
|
|
(bk.senderName?.toLowerCase().includes(query) ?? false),
|
|
),
|
|
[bookmarks, query],
|
|
);
|
|
|
|
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
|
|
// later doesn't reappear pre-collapsed and the Set can't grow unbounded.
|
|
useEffect(() => {
|
|
setCollapsed((prev) => {
|
|
if (prev.size === 0) return prev;
|
|
const live = new Set(bookmarks.map((bk) => bk.roomId));
|
|
let changed = false;
|
|
const next = new Set<string>();
|
|
prev.forEach((roomId) => {
|
|
if (live.has(roomId)) next.add(roomId);
|
|
else changed = true;
|
|
});
|
|
return changed ? next : prev;
|
|
});
|
|
}, [bookmarks]);
|
|
|
|
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
|
// otherwise fall back to the stored snapshot for rooms we've left. Shared by
|
|
// both the flat (newest/oldest) and grouped (by room) render paths.
|
|
const renderItem = useCallback(
|
|
(bk: Bookmark) => {
|
|
const room = mx.getRoom(bk.roomId);
|
|
return room ? (
|
|
<LiveBookmarkItem
|
|
key={bk.eventId}
|
|
room={room}
|
|
bookmark={bk}
|
|
onJump={handleJump}
|
|
onRemove={removeBookmark}
|
|
/>
|
|
) : (
|
|
<BookmarkItem
|
|
key={bk.eventId}
|
|
bookmark={bk}
|
|
onJump={handleJump}
|
|
onRemove={removeBookmark}
|
|
/>
|
|
);
|
|
},
|
|
[mx, handleJump, removeBookmark],
|
|
);
|
|
|
|
const sortedItems = useMemo(
|
|
() => (sort === 'room' ? filtered : sortBookmarks(filtered, sort)),
|
|
[filtered, sort],
|
|
);
|
|
const groups = useMemo(
|
|
() => (sort === 'room' ? groupBookmarksByRoom(filtered) : []),
|
|
[filtered, sort],
|
|
);
|
|
|
|
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" truncate>
|
|
{filtered.length === bookmarks.length
|
|
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
|
|
: `${filtered.length} of ${bookmarks.length} messages`}
|
|
</Text>
|
|
<Box as="div" role="group" aria-label="Sort saved messages" gap="100">
|
|
{SORT_OPTIONS.map((opt) => (
|
|
<SortButton
|
|
key={opt.value}
|
|
label={opt.label}
|
|
active={sort === opt.value}
|
|
onClick={() => setSort(opt.value)}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</>
|
|
)}
|
|
</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">
|
|
{sort === 'room'
|
|
? groups.map((group) => {
|
|
const isCollapsed = collapsed.has(group.roomId);
|
|
const contentId = `bookmark-group-${group.roomId}`;
|
|
return (
|
|
<Box key={group.roomId} direction="Column" gap="200">
|
|
<RoomGroupHeader
|
|
roomId={group.roomId}
|
|
roomName={group.roomName}
|
|
count={group.items.length}
|
|
collapsed={isCollapsed}
|
|
contentId={contentId}
|
|
onToggle={() => toggleGroup(group.roomId)}
|
|
/>
|
|
{!isCollapsed && (
|
|
<Box id={contentId} direction="Column" gap="200">
|
|
{group.items.map((bk) => renderItem(bk))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
})
|
|
: sortedItems.map((bk) => renderItem(bk))}
|
|
</Box>
|
|
)}
|
|
</Scroll>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|