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( '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 ( ); } 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 ( {/* Room identity + remove */} {nameInitials(displayRoomName)}} /> {displayRoomName} {author ? `${author} · ${timeAgo}` : timeAgo} onRemove(bookmark.eventId)} aria-label="Remove saved message" > {/* Message preview — clicking jumps to the message */} ); } 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 = ( ); } 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 ( ); } 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 ( ); } 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>(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) => { 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(); 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 ? ( ) : ( ); }, [mx, handleJump, removeBookmark], ); const sortedItems = useMemo( () => (sort === 'room' ? filtered : sortBookmarks(filtered, sort)), [filtered, sort], ); const groups = useMemo( () => (sort === 'room' ? groupBookmarksByRoom(filtered) : []), [filtered, sort], ); return (
Saved Messages
{/* Search */} } after={ filter.length > 0 ? ( setFilter('')} > ) : undefined } /> {bookmarks.length > 0 && ( <> {filtered.length === bookmarks.length ? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}` : `${filtered.length} of ${bookmarks.length} messages`} {SORT_OPTIONS.map((opt) => ( setSort(opt.value)} /> ))} )} {/* List */} {filtered.length === 0 ? ( {bookmarks.length === 0 ? 'No saved messages yet. Right-click any message to save it.' : 'No saved messages match your search.'} ) : ( {sort === 'room' ? groups.map((group) => { const isCollapsed = collapsed.has(group.roomId); const contentId = `bookmark-group-${group.roomId}`; return ( toggleGroup(group.roomId)} /> {!isCollapsed && ( {group.items.map((bk) => renderItem(bk))} )} ); }) : sortedItems.map((bk) => renderItem(bk))} )}
); }