CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Audit of every rendered time found five families of ad-hoc formatting: the shared Time component + copies of its today/yesterday branch (forwarded header, thread summary, read receipts, device tile, moderation alerts, edit history), locale-default toLocale*String calls that ignored the user's 12/24 h and date-format settings (scheduled tray, reminders, schedule preview, notification snooze, bookmarks, threads list, search cache line, room insights, media gallery), a hard-coded en-US date in the activity log, and three relative-age variants. utils/formatTimestamp.ts now holds the rules — today → time; yesterday / tomorrow → day word + time; last 6 days → weekday + time; older → date + time in dateFormatString — plus autoDate / time / date / dateTime styles, formatDayDivider (full weekday), formatShortAge (room list) and formatRelativeAge (list rows). useTimestampFormatter binds them to the settings. 11 unit tests with an injected 'now'. Visible changes are limited to consistency: 12 h times keep the existing zero-padded hh:mm A; the a11y label and Created-by line use the user's date format instead of a fixed long month. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
507 lines
17 KiB
TypeScript
507 lines
17 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';
|
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
|
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
|
|
|
// 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;
|
|
// E2EE-room bookmarks store no roomName; fall back past the '' placeholder.
|
|
const displayRoomName = room?.name || bookmark.roomName || 'Unknown room';
|
|
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 { prefs } = useTimestampFormatter();
|
|
const timeAgo = formatRelativeAge(bookmark.savedAt, prefs);
|
|
|
|
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 || 'Message unavailable')}
|
|
</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; bookmarks from E2EE rooms have no snapshot at all (account data is
|
|
// server-readable), so the live event is their only source of text.
|
|
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
|
|
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
|
|
room.findEventById(bookmark.eventId),
|
|
);
|
|
|
|
const snapshot =
|
|
bookmark.previewText || (liveEvent === undefined ? 'Loading…' : 'Message unavailable');
|
|
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 || 'Unknown room';
|
|
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) => {
|
|
// E2EE-room bookmarks have no stored text: match against the locally
|
|
// cached event body / live room name instead (nothing is fetched here).
|
|
const room = mx.getRoom(bk.roomId);
|
|
const localBody = room?.findEventById(bk.eventId)?.getContent()?.body;
|
|
return (
|
|
(bk.previewText?.toLowerCase().includes(query) ?? false) ||
|
|
(typeof localBody === 'string' && localBody.toLowerCase().includes(query)) ||
|
|
bk.roomName.toLowerCase().includes(query) ||
|
|
(room?.name.toLowerCase().includes(query) ?? false) ||
|
|
(bk.senderName?.toLowerCase().includes(query) ?? false)
|
|
);
|
|
}),
|
|
[mx, 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>
|
|
);
|
|
}
|