Skip to content

Commit 36231cc

Browse files
committed
feat(bookmarks): add message bookmarks (MSC4438) with reminder infrastructure
1 parent 8387218 commit 36231cc

37 files changed

Lines changed: 3540 additions & 19 deletions

.changeset/message-bookmarks.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Add message bookmarks (MSC4438). Users can bookmark messages for easy retrieval via a new Bookmarks section in the home sidebar. Gated by an operator `config.json` experiment flag (`experiments.messageBookmarks`) and a per-user experimental settings toggle.

knip.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://unpkg.com/knip@5/schema.json",
33
"entry": ["src/sw.ts", "scripts/normalize-imports.js"],
4-
"ignore": ["oxlint.config.ts", "oxfmt.config.ts"],
4+
"ignore": ["oxlint.config.ts", "oxfmt.config.ts", "src/app/features/bookmarks/BookmarksPanel.tsx"],
55
"ignoreExportsUsedInFile": {
66
"interface": true,
77
"type": true
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { Avatar, Box, Chip, Icon, IconButton, Icons, Line, Text, config } from 'folds';
2+
import { useAtomValue } from 'jotai';
3+
import {
4+
useBookmarks,
5+
useArchivedBookmarks,
6+
toggleBookmark,
7+
restoreBookmark,
8+
permanentlyDeleteBookmark,
9+
} from '$hooks/useBookmarks';
10+
import { useMatrixClient } from '$hooks/useMatrixClient';
11+
import { useRoomNavigate } from '$hooks/useRoomNavigate';
12+
import { useGetRoom, useAllJoinedRoomsSet } from '$hooks/useGetRoom';
13+
import { getMemberAvatarMxc, getMemberDisplayName } from '$utils/room';
14+
import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
15+
import { UserAvatar } from '$components/user-avatar';
16+
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
17+
import { SequenceCard } from '$components/sequence-card';
18+
import { AvatarBase, ModernLayout, Time, Username, UsernameBold } from '$components/message';
19+
import { ContainerColor } from '$styles/ContainerColor.css';
20+
import { EncryptedContent } from '$features/room/message';
21+
import { nicknamesAtom } from '$state/nicknames';
22+
import { useSetting } from '$state/hooks/settings';
23+
import { settingsAtom } from '$state/settings';
24+
25+
type BookmarksListProps = {
26+
onNavigate?: () => void;
27+
};
28+
29+
export function BookmarksList({ onNavigate }: BookmarksListProps) {
30+
const mx = useMatrixClient();
31+
const bookmarks = useBookmarks();
32+
const archived = useArchivedBookmarks();
33+
const { navigateRoom } = useRoomNavigate();
34+
const useAuthentication = useMediaAuthentication();
35+
const allRoomsSet = useAllJoinedRoomsSet();
36+
const getRoom = useGetRoom(allRoomsSet);
37+
const nicknames = useAtomValue(nicknamesAtom);
38+
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
39+
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
40+
41+
const handleOpen = (roomId: string, eventId: string) => {
42+
navigateRoom(roomId, eventId);
43+
onNavigate?.();
44+
};
45+
46+
const handleRemove = (roomId: string, eventId: string) => {
47+
toggleBookmark(mx, roomId, eventId, bookmarks).catch(() => {});
48+
};
49+
50+
const handleRestore = (entry: (typeof archived)[number]) => {
51+
restoreBookmark(mx, entry).catch(() => {});
52+
};
53+
54+
const handlePermanentDelete = (entry: (typeof archived)[number]) => {
55+
const allIds = [...bookmarks.map((b) => b.id), ...archived.map((b) => b.id)];
56+
permanentlyDeleteBookmark(mx, entry, allIds).catch(() => {});
57+
};
58+
59+
if (bookmarks.length === 0 && archived.length === 0) {
60+
return (
61+
<Box
62+
className={ContainerColor({ variant: 'SurfaceVariant' })}
63+
style={{
64+
padding: config.space.S300,
65+
borderRadius: config.radii.R400,
66+
}}
67+
direction="Column"
68+
gap="200"
69+
>
70+
<Text>No Bookmarks</Text>
71+
<Text size="T200">Bookmark messages from the message menu to save them here.</Text>
72+
</Box>
73+
);
74+
}
75+
76+
return (
77+
<Box direction="Column" gap="100">
78+
{bookmarks.map((bookmark) => {
79+
const room = getRoom(bookmark.room_id);
80+
const event = room
81+
?.getTimelineForEvent(bookmark.event_id)
82+
?.getEvents()
83+
.find((e) => e.getId() === bookmark.event_id);
84+
85+
const senderId = event?.getSender() ?? '';
86+
const displayName =
87+
(room && senderId ? getMemberDisplayName(room, senderId, nicknames) : undefined) ??
88+
getMxIdLocalPart(senderId) ??
89+
senderId;
90+
const senderAvatarMxc = room && senderId ? getMemberAvatarMxc(room, senderId) : undefined;
91+
const senderAvatarUrl = senderAvatarMxc
92+
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
93+
: undefined;
94+
95+
return (
96+
<SequenceCard
97+
key={bookmark.event_id}
98+
style={{ padding: config.space.S400 }}
99+
variant="SurfaceVariant"
100+
direction="Column"
101+
>
102+
<ModernLayout
103+
before={
104+
<AvatarBase>
105+
<Avatar size="300">
106+
<UserAvatar
107+
userId={senderId}
108+
src={senderAvatarUrl}
109+
alt={displayName}
110+
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
111+
/>
112+
</Avatar>
113+
</AvatarBase>
114+
}
115+
>
116+
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
117+
<Box gap="200" alignItems="Baseline">
118+
<Box alignItems="Center" gap="200">
119+
<Username>
120+
<Text as="span" truncate>
121+
<UsernameBold>{displayName || 'Unknown'}</UsernameBold>
122+
</Text>
123+
</Username>
124+
</Box>
125+
{event && (
126+
<Time
127+
ts={event.getTs()}
128+
hour24Clock={hour24Clock}
129+
dateFormatString={dateFormatString}
130+
/>
131+
)}
132+
</Box>
133+
<Box shrink="No" gap="200" alignItems="Center">
134+
<Chip
135+
onClick={() => handleOpen(bookmark.room_id, bookmark.event_id)}
136+
variant="Secondary"
137+
radii="400"
138+
>
139+
<Text size="T200">Open</Text>
140+
</Chip>
141+
<IconButton
142+
size="300"
143+
radii="300"
144+
variant="SurfaceVariant"
145+
onClick={() => handleRemove(bookmark.room_id, bookmark.event_id)}
146+
aria-label="Remove bookmark"
147+
>
148+
<Icon src={Icons.Cross} size="100" />
149+
</IconButton>
150+
</Box>
151+
</Box>
152+
<Text size="T200" priority="300" truncate>
153+
in {room?.name ?? bookmark.room_id}
154+
</Text>
155+
{event ? (
156+
<EncryptedContent mEvent={event}>
157+
{() => {
158+
const content = event.getContent<{ body?: string }>();
159+
return (
160+
<Text size="T200" priority="300">
161+
{content.body ?? 'Unknown content'}
162+
</Text>
163+
);
164+
}}
165+
</EncryptedContent>
166+
) : (
167+
<Text size="T200" priority="300">
168+
Event not in local timeline
169+
</Text>
170+
)}
171+
</ModernLayout>
172+
</SequenceCard>
173+
);
174+
})}
175+
{archived.length > 0 && (
176+
<>
177+
<Box
178+
style={{ paddingTop: config.space.S300, paddingBottom: config.space.S100 }}
179+
alignItems="Center"
180+
gap="200"
181+
>
182+
<Line size="300" style={{ flex: 1 }} />
183+
<Box alignItems="Center" gap="100">
184+
<Icon src={Icons.Inbox} size="100" />
185+
<Text size="L400" priority="300">
186+
Archived
187+
</Text>
188+
</Box>
189+
<Line size="300" style={{ flex: 1 }} />
190+
</Box>
191+
{archived.map((entry) => {
192+
const room = getRoom(entry.room_id);
193+
const event = room
194+
?.getTimelineForEvent(entry.event_id)
195+
?.getEvents()
196+
.find((e) => e.getId() === entry.event_id);
197+
198+
const senderId = event?.getSender() ?? '';
199+
const displayName =
200+
(room && senderId ? getMemberDisplayName(room, senderId, nicknames) : undefined) ??
201+
getMxIdLocalPart(senderId) ??
202+
senderId;
203+
const senderAvatarMxc =
204+
room && senderId ? getMemberAvatarMxc(room, senderId) : undefined;
205+
const senderAvatarUrl = senderAvatarMxc
206+
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
207+
: undefined;
208+
209+
return (
210+
<SequenceCard
211+
key={entry.event_id}
212+
style={{ padding: config.space.S400, opacity: 0.7 }}
213+
variant="SurfaceVariant"
214+
direction="Column"
215+
>
216+
<ModernLayout
217+
before={
218+
<AvatarBase>
219+
<Avatar size="300">
220+
<UserAvatar
221+
userId={senderId}
222+
src={senderAvatarUrl}
223+
alt={displayName}
224+
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
225+
/>
226+
</Avatar>
227+
</AvatarBase>
228+
}
229+
>
230+
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
231+
<Box gap="200" alignItems="Baseline">
232+
<Box alignItems="Center" gap="200">
233+
<Username>
234+
<Text as="span" truncate>
235+
<UsernameBold>{displayName || 'Unknown'}</UsernameBold>
236+
</Text>
237+
</Username>
238+
</Box>
239+
{event && (
240+
<Time
241+
ts={event.getTs()}
242+
hour24Clock={hour24Clock}
243+
dateFormatString={dateFormatString}
244+
/>
245+
)}
246+
</Box>
247+
<Box shrink="No" gap="200" alignItems="Center">
248+
<Chip
249+
onClick={() => handleOpen(entry.room_id, entry.event_id)}
250+
variant="Secondary"
251+
radii="400"
252+
>
253+
<Text size="T200">Open</Text>
254+
</Chip>
255+
<IconButton
256+
size="300"
257+
radii="300"
258+
variant="SurfaceVariant"
259+
onClick={() => handleRestore(entry)}
260+
aria-label="Restore bookmark"
261+
title="Restore"
262+
>
263+
<Icon src={Icons.ReplyArrow} size="100" />
264+
</IconButton>
265+
<IconButton
266+
size="300"
267+
radii="300"
268+
variant="SurfaceVariant"
269+
onClick={() => handlePermanentDelete(entry)}
270+
aria-label="Permanently delete bookmark"
271+
title="Delete permanently"
272+
>
273+
<Icon src={Icons.Delete} size="100" />
274+
</IconButton>
275+
</Box>
276+
</Box>
277+
<Text size="T200" priority="300" truncate>
278+
in {room?.name ?? entry.room_id}
279+
</Text>
280+
{event ? (
281+
<EncryptedContent mEvent={event}>
282+
{() => {
283+
const content = event.getContent<{ body?: string }>();
284+
return (
285+
<Text size="T200" priority="300">
286+
{content.body ?? 'Unknown content'}
287+
</Text>
288+
);
289+
}}
290+
</EncryptedContent>
291+
) : (
292+
<Text size="T200" priority="300">
293+
Event not in local timeline
294+
</Text>
295+
)}
296+
</ModernLayout>
297+
</SequenceCard>
298+
);
299+
})}
300+
</>
301+
)}
302+
</Box>
303+
);
304+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Box, color, Dialog, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
2+
import { BookmarksList } from './BookmarksList';
3+
4+
export { BookmarksList } from './BookmarksList';
5+
6+
type BookmarksPanelProps = {
7+
requestClose: () => void;
8+
};
9+
10+
export function BookmarksPanel({ requestClose }: BookmarksPanelProps) {
11+
return (
12+
<Dialog
13+
variant="Surface"
14+
style={{
15+
height: '100%',
16+
display: 'flex',
17+
flexDirection: 'column',
18+
borderRadius: 0,
19+
borderRight: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
20+
boxShadow: '4px 0 24px rgba(0,0,0,0.18)',
21+
}}
22+
>
23+
<Header
24+
style={{
25+
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
26+
borderBottomWidth: config.borderWidth.B300,
27+
}}
28+
variant="Surface"
29+
size="500"
30+
>
31+
<Box grow="Yes" alignItems="Center" gap="200">
32+
<Icon src={Icons.Bookmark} size="200" />
33+
<Text size="H4">Bookmarks</Text>
34+
</Box>
35+
<IconButton size="300" onClick={requestClose} radii="300">
36+
<Icon src={Icons.Cross} />
37+
</IconButton>
38+
</Header>
39+
<Box grow="Yes" style={{ overflow: 'hidden' }}>
40+
<Scroll hideTrack>
41+
<Box style={{ padding: config.space.S300 }}>
42+
<BookmarksList onNavigate={requestClose} />
43+
</Box>
44+
</Scroll>
45+
</Box>
46+
</Dialog>
47+
);
48+
}

0 commit comments

Comments
 (0)