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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:37:12 -04:00
co-authored by Claude Opus 4.8
parent f9af363dd6
commit e01b87b214
4 changed files with 32 additions and 6 deletions
+1
View File
@@ -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`
+24 -6
View File
@@ -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 (
<Box
@@ -137,8 +142,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
{displayRoomName}
</Text>
<Text size="T200" priority="300">
{formatTimeAgo(bookmark.savedAt)}
<Text size="T200" priority="300" truncate>
{author ? `${author} · ${timeAgo}` : timeAgo}
</Text>
</Box>
<IconButton
@@ -199,7 +204,19 @@ function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItem
}
}
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
// 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 = {
@@ -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],
);
@@ -1190,6 +1190,8 @@ export const Message = React.memo(
savedAt: Date.now(),
previewText: body.slice(0, 120),
roomName: room.name,
senderId,
senderName: senderDisplayName,
});
}
closeMenu();
+5
View File
@@ -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';