From e01b87b21423ff34982fa751d4555f76fd83014f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 15:37:12 -0400 Subject: [PATCH] feat(bookmarks): show who wrote each saved message Saved-message cards showed the room and time but not the author, so in a busy room you couldn't tell who said it without jumping. Now each card shows "{sender} - {time ago}": - Bookmark gains optional senderId/senderName (snapshotted at save time in Message.tsx from the already-computed sender display name); optional so existing stored bookmarks stay valid. - The panel re-resolves the author's current display name live from the event when the room is joined, falling back to the stored snapshot for left rooms. - Search now also matches the author name. Co-Authored-By: Claude Opus 4.8 --- LOTUS_FEATURES.md | 1 + src/app/features/bookmarks/BookmarksPanel.tsx | 30 +++++++++++++++---- src/app/features/room/message/Message.tsx | 2 ++ src/app/hooks/useBookmarks.ts | 5 ++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 434daca85..19de1fef8 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -733,6 +733,7 @@ Redacted events display "This message has been deleted" along with the redaction - Maximum of 500 bookmarked entries - `BookmarksPanel.tsx` is a sidebar panel accessible from the navigation rail - Live-renders edits/redactions, text search, jump-to-message, and remove +- **Author attribution**: each saved-message card shows who wrote it (`{sender} · {time ago}`). The author is snapshotted at save time (`senderId`/`senderName` on the bookmark, optional for backward compatibility) and re-resolved live from the event when the room is joined; search also matches the author name. - **Sort & group**: a Newest / Oldest / By-room segmented control sorts the list; "By room" renders collapsible per-room sections (groups ordered by most-recent save). The chosen sort persists across panel opens (`cinny_bookmarks_sort_v1`). Ordering/grouping logic is pure and unit-tested in `src/app/utils/bookmarks.ts` (`bookmarks.test.ts`). - Hook: `src/app/hooks/useBookmarks.ts` diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx index 67fc77009..279f7b2f4 100644 --- a/src/app/features/bookmarks/BookmarksPanel.tsx +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -37,7 +37,7 @@ import { MessageDeletedContent } from '../../components/message/content/Fallback import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { RoomAvatar } from '../../components/room-avatar'; -import { getRoomAvatarUrl } from '../../utils/room'; +import { getRoomAvatarUrl, getMemberName } from '../../utils/room'; import { nameInitials } from '../../utils/common'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { stopPropagation } from '../../utils/keyboard'; @@ -102,9 +102,11 @@ type BookmarkItemProps = { 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 }: BookmarkItemProps) { +function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: BookmarkItemProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const room = mx.getRoom(bookmark.roomId) ?? undefined; @@ -112,6 +114,9 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps 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 ( {displayRoomName} - - {formatTimeAgo(bookmark.savedAt)} + + {author ? `${author} · ${timeAgo}` : timeAgo} ; + // 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 = { @@ -320,7 +337,8 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) { : bookmarks.filter( (bk) => bk.previewText.toLowerCase().includes(query) || - bk.roomName.toLowerCase().includes(query), + bk.roomName.toLowerCase().includes(query) || + (bk.senderName?.toLowerCase().includes(query) ?? false), ), [bookmarks, query], ); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 3ce5645cb..49925cfdb 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -1190,6 +1190,8 @@ export const Message = React.memo( savedAt: Date.now(), previewText: body.slice(0, 120), roomName: room.name, + senderId, + senderName: senderDisplayName, }); } closeMenu(); diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts index 3c8c4341d..db8689687 100644 --- a/src/app/hooks/useBookmarks.ts +++ b/src/app/hooks/useBookmarks.ts @@ -8,6 +8,11 @@ export type Bookmark = { savedAt: number; previewText: string; roomName: string; + // Author of the saved message. Optional for backward compatibility with + // bookmarks stored before attribution was added; the panel resolves a live + // name when the room is joined and falls back to these snapshots otherwise. + senderId?: string; + senderName?: string; }; const BOOKMARKS_KEY = 'io.lotus.bookmarks';