diff --git a/.changeset/feat_add_bookmarks.md b/.changeset/feat_add_bookmarks.md
new file mode 100644
index 000000000..aec0f5fe7
--- /dev/null
+++ b/.changeset/feat_add_bookmarks.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+Add bookmark functionality using account data
diff --git a/src/app/components/GlobalKeyboardShortcuts.tsx b/src/app/components/GlobalKeyboardShortcuts.tsx
index 4f2e4cf49..e3b577ffc 100644
--- a/src/app/components/GlobalKeyboardShortcuts.tsx
+++ b/src/app/components/GlobalKeyboardShortcuts.tsx
@@ -20,6 +20,7 @@ import {
getDirectRoomPath,
getHomeRoomPath,
getHomeSearchPath,
+ getInboxBookmarksPath,
getSpaceRoomPath,
getSpaceSearchPath,
withSearchParam,
@@ -162,6 +163,17 @@ export function GlobalKeyboardShortcuts() {
[currentRoom, replyDraft, setReplyDraft]
);
+ const handleBookmarkKeyDown = useCallback(
+ (evt: KeyboardEvent) => {
+ if (!isKeyHotkey('mod+b', evt)) return;
+ evt.preventDefault();
+
+ navigate(getInboxBookmarksPath());
+ announce(`Navigated to bookmarks`);
+ },
+ [navigate]
+ );
+
/** Ctrl+F: Search for messages */
const handleSearchMessageInRoom = useCallback(
(evt: KeyboardEvent) => {
@@ -184,6 +196,7 @@ export function GlobalKeyboardShortcuts() {
useKeyDown(window, handleNextUnreadKeyDown);
useKeyDown(window, handleUnreadNavKeyDown);
useKeyDown(window, handleReplyKeyDown);
+ useKeyDown(window, handleBookmarkKeyDown);
useKeyDown(window, handleSearchMessageInRoom);
return null;
diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx
index 7a93733b7..462f1c6a5 100644
--- a/src/app/components/message/modals/Options.tsx
+++ b/src/app/components/message/modals/Options.tsx
@@ -28,8 +28,7 @@ import { MessageSourceCodeItem } from './MessageSource';
import { MessageForwardItem } from './MessageForward';
import * as css from '$features/room/message/styles.css';
-import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
-import { nicknamesAtom, setNicknameAtom } from '$state/nicknames';
+import { useAtom, useSetAtom, useStore } from 'jotai';
import type { Dispatch, MouseEventHandler, ReactNode, SetStateAction } from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { MessageDeleteItem } from './MessageDelete';
@@ -43,6 +42,13 @@ import { useRoomPinnedEvents } from '$hooks/useRoomPinnedEvents';
import { EmojiBoard } from '$components/emoji-board';
import { MemoizedBody, type ReactionHandler } from '$features/room/message';
import { useRecentEmoji } from '$hooks/useRecentEmoji';
+import { BookmarkIcon } from '@phosphor-icons/react';
+import {
+ computeBookmarkId,
+ createBookmarkItem,
+ useBookmarkActions,
+ useIsBookmarked,
+} from '$features/bookmarks';
import { CopyIcon } from '@phosphor-icons/react';
function WrappedMessage({
@@ -207,6 +213,44 @@ export const MessagePinItem = as<
);
});
+export const MessageBookmarkItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const eventId = mEvent.getId() ?? '';
+ const bookmarked = useIsBookmarked(room.roomId, eventId);
+ const { add, remove } = useBookmarkActions();
+
+ const handleClick = async () => {
+ onClose?.();
+ if (bookmarked) {
+ await remove(computeBookmarkId(room.roomId, eventId));
+ } else {
+ const item = createBookmarkItem(room, mEvent);
+ if (item) await add(item);
+ }
+ };
+
+ return (
+
+ );
+});
+
export type OptionEmojiMenuProps = {
mEvent: MatrixEvent;
closeMenu: () => void;
@@ -284,7 +328,6 @@ export function OptionQuickMenu({
hideReadReceipts,
showDeveloperTools,
canPinEvent,
- cleanedDisplayName,
canDelete,
handleOpenMenu,
menuAnchor,
@@ -386,7 +429,6 @@ export function OptionQuickMenu({
hideReadReceipts={hideReadReceipts}
showDeveloperTools={showDeveloperTools}
canPinEvent={canPinEvent}
- cleanedDisplayName={cleanedDisplayName}
canDelete={canDelete}
setIsEmoji={setIsEmoji}
emojiBoardAnchor={menuAnchor}
@@ -433,7 +475,6 @@ export type OptionMenuProps = {
hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
canPinEvent?: boolean;
- cleanedDisplayName?: string;
canDelete?: boolean;
handleOpenMenu?: MouseEventHandler;
menuAnchor?: RectCords | undefined;
@@ -458,7 +499,6 @@ export function OptionMenu({
hideReadReceipts,
showDeveloperTools,
canPinEvent,
- cleanedDisplayName,
canDelete,
imagePackRooms,
setIsEmoji,
@@ -478,12 +518,6 @@ export function OptionMenu({
getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
const isEdited = !!edits?.length;
- const [nickEditOpen, setNickEditOpen] = useState(false);
- const [nickDraft, setNickDraft] = useState('');
- const nicknames = useAtomValue(nicknamesAtom);
- const setNickname = useSetAtom(setNicknameAtom);
- const senderId = mEvent.getSender() ?? '';
-
const onTotalClose = () => {
setModal(null);
closeMenu();
@@ -666,80 +700,13 @@ export function OptionMenu({
)}
+
{canForwardEvent(mEvent) && (
)}
+
{canPinEvent && }
- {cleanedDisplayName &&
- senderId !== mx.getUserId() &&
- (nickEditOpen ? (
-
- Nickname
- setNickDraft(e.target.value)}
- placeholder={cleanedDisplayName}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- setNickname(senderId, nickDraft || undefined, mx);
- closeMenu();
- }
- if (e.key === 'Escape') closeMenu();
- }}
- className={css.MessageNickEditor}
- />
-
-
- {nicknames[senderId] && (
-
- )}
-
-
- ) : (
-
- ))}
{((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
<>
@@ -854,7 +821,6 @@ export function MobileOptionsInternal({ options }: { options: OptionMenuProps })
hideReadReceipts={options.hideReadReceipts}
showDeveloperTools={options.showDeveloperTools}
canPinEvent={options.canPinEvent}
- cleanedDisplayName={options.cleanedDisplayName}
canDelete={options.canDelete}
setIsEmoji={options.setIsEmoji}
ActualMessage={options.ActualMessage}
diff --git a/src/app/features/bookmarks/bookmarkDomain.ts b/src/app/features/bookmarks/bookmarkDomain.ts
new file mode 100644
index 000000000..393ad9653
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkDomain.ts
@@ -0,0 +1,90 @@
+import { MATRIX_SABLE_UNSTABLE_BOOKMARK_ITEM_EVENT_PREFIX } from '$unstable/prefixes';
+import type { MatrixEvent, Room } from 'matrix-js-sdk';
+import type { BookmarkIndexContent, BookmarkItemContent } from '$types/matrix-sdk-events';
+
+export function computeBookmarkId(roomId: string, eventId: string): string {
+ const input = `${roomId}|${eventId}`;
+ let hash = 0;
+ for (let i = 0; i < input.length; i++) {
+ const ch = input.charCodeAt(i);
+ hash = ((hash << 5) - hash + ch) | 0;
+ }
+ const hex = (hash >>> 0).toString(16).padStart(8, '0');
+ return `bmk_${hex}`;
+}
+
+export function bookmarkItemEventType(bookmarkId: string): string {
+ return `${MATRIX_SABLE_UNSTABLE_BOOKMARK_ITEM_EVENT_PREFIX}${bookmarkId}`;
+}
+
+export function buildMatrixURI(roomId: string, eventId: string): string {
+ return `matrix:roomid/${encodeURIComponent(roomId)}/e/${encodeURIComponent(eventId)}`;
+}
+
+export function extractBodyPreview(mEvent: MatrixEvent, maxLength = 120): string {
+ const content = mEvent.getContent();
+ const body = content?.body;
+ if (typeof body !== 'string' || body.length === 0) return '';
+ if (body.length <= maxLength) return body;
+ return `${body.slice(0, maxLength)}…`;
+}
+
+export function createBookmarkItem(
+ room: Room,
+ mEvent: MatrixEvent
+): BookmarkItemContent | undefined {
+ const eventId = mEvent.getId();
+ const { roomId } = room;
+ if (!eventId) return undefined;
+
+ const bookmarkId = computeBookmarkId(roomId, eventId);
+
+ return {
+ version: 1,
+ bookmark_id: bookmarkId,
+ uri: buildMatrixURI(roomId, eventId),
+ room_id: roomId,
+ event_id: eventId,
+ event_ts: mEvent.getTs(),
+ bookmarked_ts: Date.now(),
+ sender: mEvent.getSender(),
+ room_name: room.name,
+ body_preview: mEvent.isEncrypted() ? undefined : extractBodyPreview(mEvent),
+ msgtype: mEvent.getContent()?.msgtype,
+ };
+}
+
+export function isValidIndexContent(content: unknown): content is BookmarkIndexContent {
+ if (typeof content !== 'object' || content === null) return false;
+ const c = content as Record;
+ return (
+ c.version === 1 &&
+ typeof c.revision === 'number' &&
+ typeof c.updated_ts === 'number' &&
+ Array.isArray(c.bookmark_ids) &&
+ c.bookmark_ids.every((id: unknown) => typeof id === 'string')
+ );
+}
+
+export function isValidBookmarkItem(content: unknown): content is BookmarkItemContent {
+ if (typeof content !== 'object' || content === null) return false;
+ const c = content as Record;
+ return (
+ c.version === 1 &&
+ typeof c.bookmark_id === 'string' &&
+ typeof c.uri === 'string' &&
+ typeof c.room_id === 'string' &&
+ typeof c.event_id === 'string' &&
+ typeof c.event_ts === 'number' &&
+ typeof c.bookmarked_ts === 'number'
+ );
+}
+
+export function emptyIndex(): BookmarkIndexContent {
+ return {
+ version: 1,
+ revision: 0,
+ updated_ts: Date.now(),
+ bookmark_ids: [],
+ };
+}
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
new file mode 100644
index 000000000..66f3ff152
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -0,0 +1,91 @@
+import type { AccountDataEvents, MatrixClient } from 'matrix-js-sdk';
+import {
+ bookmarkItemEventType,
+ emptyIndex,
+ isValidBookmarkItem,
+ isValidIndexContent,
+} from './bookmarkDomain';
+import type { BookmarkIndexContent, BookmarkItemContent } from '$types/matrix-sdk-events';
+import { MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT } from '$unstable/prefixes';
+
+function readIndex(mx: MatrixClient): BookmarkIndexContent {
+ const evt = mx.getAccountData(MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT);
+ const content = evt?.getContent();
+ if (isValidIndexContent(content)) return content;
+ return emptyIndex();
+}
+
+async function readIndexFromServer(mx: MatrixClient): Promise {
+ const content = await mx.getAccountDataFromServer(MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT);
+ if (isValidIndexContent(content)) return content;
+ return emptyIndex();
+}
+
+async function readItemFromServer(
+ mx: MatrixClient,
+ bookmarkId: string
+): Promise {
+ const content = await mx.getAccountDataFromServer(
+ bookmarkItemEventType(bookmarkId) as keyof AccountDataEvents
+ );
+ if (isValidBookmarkItem(content) && !content.deleted) return content;
+ return undefined;
+}
+
+async function writeIndex(mx: MatrixClient, index: BookmarkIndexContent): Promise {
+ await mx.setAccountData(MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT, index);
+}
+
+async function writeItem(mx: MatrixClient, item: BookmarkItemContent): Promise {
+ await mx.setAccountData(bookmarkItemEventType(item.bookmark_id) as keyof AccountDataEvents, item);
+}
+
+type IndexMutator = (index: BookmarkIndexContent) => BookmarkIndexContent;
+
+async function mutateIndex(mx: MatrixClient, mutate: IndexMutator): Promise {
+ const currentIndex = await readIndexFromServer(mx);
+ const nextIndex = mutate(currentIndex);
+ await writeIndex(mx, nextIndex);
+}
+
+export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent): Promise {
+ await writeItem(mx, item);
+
+ await mutateIndex(mx, (index) => {
+ const ids = index.bookmark_ids.includes(item.bookmark_id)
+ ? index.bookmark_ids
+ : [item.bookmark_id, ...index.bookmark_ids];
+
+ return {
+ ...index,
+ bookmark_ids: ids,
+ revision: index.revision + 1,
+ updated_ts: Date.now(),
+ };
+ });
+}
+
+export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise {
+ await mutateIndex(mx, (index) => ({
+ ...index,
+ bookmark_ids: index.bookmark_ids.filter((id) => id !== bookmarkId),
+ revision: index.revision + 1,
+ updated_ts: Date.now(),
+ }));
+
+ const existing = await readItemFromServer(mx, bookmarkId);
+ if (existing) {
+ await writeItem(mx, { ...existing, deleted: true });
+ }
+}
+
+export async function listBookmarks(mx: MatrixClient): Promise {
+ const index = await readIndexFromServer(mx);
+ const items = await Promise.all(index.bookmark_ids.map((id) => readItemFromServer(mx, id)));
+ return items.filter((item): item is BookmarkItemContent => item != null);
+}
+
+export function isBookmarked(mx: MatrixClient, bookmarkId: string): boolean {
+ const index = readIndex(mx);
+ return index.bookmark_ids.includes(bookmarkId);
+}
diff --git a/src/app/features/bookmarks/index.ts b/src/app/features/bookmarks/index.ts
new file mode 100644
index 000000000..015c9c22b
--- /dev/null
+++ b/src/app/features/bookmarks/index.ts
@@ -0,0 +1,3 @@
+export * from './bookmarkDomain';
+export * from './bookmarkRepository';
+export * from '../../hooks/useBookmarks';
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 0bf26f9d4..81293f9a1 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -833,7 +833,6 @@ function MessageInternal(
hideReadReceipts: hideReadReceipts,
showDeveloperTools: showDeveloperTools,
canPinEvent: canPinEvent,
- cleanedDisplayName: cleanedDisplayName,
canDelete: canDelete,
setIsEmoji: setIsEmoji,
ActualMessage: (
@@ -939,7 +938,6 @@ function MessageInternal(
hideReadReceipts={hideReadReceipts}
showDeveloperTools={showDeveloperTools}
canPinEvent={canPinEvent}
- cleanedDisplayName={cleanedDisplayName}
canDelete={canDelete}
handleOpenMenu={handleOpenMenu}
menuAnchor={menuAnchor}
diff --git a/src/app/hooks/router/useInbox.ts b/src/app/hooks/router/useInbox.ts
index 639e16dd4..c19c0cc4b 100644
--- a/src/app/hooks/router/useInbox.ts
+++ b/src/app/hooks/router/useInbox.ts
@@ -1,5 +1,10 @@
import { useMatch } from 'react-router-dom';
-import { getInboxInvitesPath, getInboxNotificationsPath, getInboxPath } from '$pages/pathUtils';
+import {
+ getInboxBookmarksPath,
+ getInboxInvitesPath,
+ getInboxNotificationsPath,
+ getInboxPath,
+} from '$pages/pathUtils';
export const useInboxSelected = (): boolean => {
const match = useMatch({
@@ -30,3 +35,13 @@ export const useInboxInvitesSelected = (): boolean => {
return !!match;
};
+
+export const useInboxBookmarksSelected = (): boolean => {
+ const match = useMatch({
+ path: getInboxBookmarksPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts
new file mode 100644
index 000000000..37a4e0223
--- /dev/null
+++ b/src/app/hooks/useBookmarks.ts
@@ -0,0 +1,81 @@
+import { useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
+import { computeBookmarkId } from '$features/bookmarks/bookmarkDomain';
+import {
+ addBookmark as repoAdd,
+ removeBookmark as repoRemove,
+ listBookmarks,
+ isBookmarked as repoIsBookmarked,
+} from '$features/bookmarks/bookmarkRepository';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import {
+ bookmarkIdSetAtom,
+ bookmarkListAtom,
+ bookmarkLoadingAtom,
+ bookmarkRefreshErrorAtom,
+} from '$state/bookmarks';
+import type { BookmarkItemContent } from '$types/matrix-sdk-events';
+
+export function useBookmarkList(): BookmarkItemContent[] {
+ return useAtomValue(bookmarkListAtom);
+}
+
+export function useBookmarkLoading(): boolean {
+ return useAtomValue(bookmarkLoadingAtom);
+}
+
+export function useBookmarkRefreshError(): Error | undefined {
+ return useAtomValue(bookmarkRefreshErrorAtom);
+}
+
+export function useIsBookmarked(roomId: string, eventId: string): boolean {
+ const idSet = useAtomValue(bookmarkIdSetAtom);
+ return idSet.has(computeBookmarkId(roomId, eventId));
+}
+
+export function useBookmarkActions() {
+ const mx = useMatrixClient();
+ const setList = useSetAtom(bookmarkListAtom);
+ const setLoading = useSetAtom(bookmarkLoadingAtom);
+ const setRefreshError = useSetAtom(bookmarkRefreshErrorAtom);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const items = await listBookmarks(mx);
+ setList(items);
+ setRefreshError(undefined);
+ } catch (error) {
+ setRefreshError(error as Error);
+ } finally {
+ setLoading(false);
+ }
+ }, [mx, setList, setLoading, setRefreshError]);
+
+ const add = useCallback(
+ async (item: BookmarkItemContent) => {
+ setList((prev) => {
+ if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev;
+ return [item, ...prev];
+ });
+ await repoAdd(mx, item);
+ },
+ [mx, setList]
+ );
+
+ const remove = useCallback(
+ async (bookmarkId: string) => {
+ setList((prev) => prev.filter((b) => b.bookmark_id !== bookmarkId));
+ await repoRemove(mx, bookmarkId);
+ },
+ [mx, setList]
+ );
+
+ const checkIsBookmarked = useCallback(
+ (roomId: string, eventId: string): boolean =>
+ repoIsBookmarked(mx, computeBookmarkId(roomId, eventId)),
+ [mx]
+ );
+
+ return { refresh, add, remove, checkIsBookmarked };
+}
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 56c24a899..2a01c772c 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -53,6 +53,7 @@ import {
CREATE_PATH,
TO_ROOM_EVENT_PATH,
SETTINGS_PATH,
+ BOOKMARKS_PATH_SEGMENT,
} from './paths';
import {
getAppPathFromHref,
@@ -69,7 +70,7 @@ import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
-import { Notifications, Inbox, Invites } from './client/inbox';
+import { Notifications, Inbox, Invites, Bookmarks } from './client/inbox';
import { setAfterLoginRedirectPath } from './afterLoginRedirectPath';
import { WelcomePage } from './client/WelcomePage';
import { SidebarNav } from './client/SidebarNav';
@@ -370,6 +371,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
)}
} />
} />
+ } />
} />
diff --git a/src/app/pages/client/inbox/Bookmarks.tsx b/src/app/pages/client/inbox/Bookmarks.tsx
new file mode 100644
index 000000000..56595ff5d
--- /dev/null
+++ b/src/app/pages/client/inbox/Bookmarks.tsx
@@ -0,0 +1,911 @@
+import type { ChangeEventHandler, ComponentProps, MouseEventHandler, ReactNode } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { MatrixEvent, Room } from 'matrix-js-sdk';
+import { ClientEvent, EventType, JoinRule, M_POLL_START } from 'matrix-js-sdk';
+import {
+ Avatar,
+ Box,
+ Button,
+ Chip,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Line,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Scroll,
+ Spinner,
+ Text,
+ color,
+ config,
+ toRem,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { useAtomValue } from 'jotai';
+import {
+ Page,
+ PageContent,
+ PageContentCenter,
+ PageHeader,
+ PageHero,
+ PageHeroEmpty,
+ PageHeroSection,
+} from '../../../components/page';
+import {
+ useBookmarkList,
+ useBookmarkLoading,
+ useBookmarkActions,
+} from '../../../hooks/useBookmarks';
+import type { BookmarkItemContent } from '$types/matrix-sdk-events';
+import { SequenceCard } from '../../../components/sequence-card';
+import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
+import type { RenderImageContentProps } from '../../../components/message';
+import {
+ AvatarBase,
+ ImageContent,
+ MessageNotDecryptedContent,
+ MessageUnsupportedContent,
+ ModernLayout,
+ MSticker,
+ RedactedContent,
+ Time,
+ Username,
+ UsernameBold,
+} from '../../../components/message';
+import { UserAvatar } from '../../../components/user-avatar';
+import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
+import {
+ getEditedEvent,
+ getMemberAvatarMxc,
+ getMemberDisplayName,
+ getRoomAvatarUrl,
+} from '../../../utils/room';
+import { useSetting } from '../../../state/hooks/settings';
+import { settingsAtom } from '../../../state/settings';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+import { BackRouteHandler } from '../../../components/BackRouteHandler';
+import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { mDirectAtom } from '../../../state/mDirectList';
+import { stopPropagation } from '../../../utils/keyboard';
+import { highlightText, makeHighlightRegex } from '../../../plugins/react-custom-html-parser';
+import colorMXID from '$utils/colorMXID';
+import { RenderMessageContent } from '$components/RenderMessageContent';
+import type { GetContentCallback } from '$types/matrix/room';
+import { useRoomEvent } from '$hooks/useRoomEvent';
+import type { HTMLReactParserOptions } from 'html-react-parser';
+import type { Opts } from 'linkifyjs';
+import { ImageViewer } from '$components/image-viewer';
+import { Image } from '$components/media';
+import { EncryptedContent } from '$features/room/message';
+import * as customHtmlCss from '$styles/CustomHtml.css';
+import type { IImageContent } from '$types/matrix/common';
+import { useMatrixEventRenderer } from '$hooks/useMatrixEventRenderer';
+import { MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT } from '$unstable/prefixes';
+import { useDebounce } from '$hooks/useDebounce';
+
+type RemoveBookmarkDialogProps = {
+ open: boolean;
+ sender?: string;
+ displayName?: string;
+ senderAvatarMxc?: string;
+ renderMatrixEvent: () => ReactNode;
+ onConfirm: () => void;
+ onClose: () => void;
+};
+function RemoveBookmarkDialog({
+ open,
+ sender,
+ displayName,
+ senderAvatarMxc,
+ renderMatrixEvent,
+ onConfirm,
+ onClose,
+}: RemoveBookmarkDialogProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+
+ return (
+ }>
+
+
+
+
+
+
+ );
+}
+
+type BookmarkItemRowProps = {
+ item: BookmarkItemContent;
+ room?: Room;
+ displayName: string;
+ senderAvatarMxc?: string;
+ usernameColor?: string;
+ hour24Clock: boolean;
+ dateFormatString: string;
+ onOpen: MouseEventHandler;
+ onRemove: (bookmarkId: string) => void;
+ highlightRegex?: RegExp;
+};
+
+type BookmarkItemRowBodyProps = {
+ item: BookmarkItemContent;
+ room: Room;
+ highlightRegex?: RegExp;
+ displayName: string;
+};
+
+type BookmarkItemRowBodyFallbackProps = {
+ item: BookmarkItemContent;
+ highlightRegex?: RegExp;
+};
+
+type bookmarkRendererContext = {
+ mx: ReturnType;
+ room?: Room;
+ mediaAutoLoad: boolean;
+ urlPreview: boolean;
+ htmlReactParserOptions: HTMLReactParserOptions;
+ linkifyOpts: Opts;
+};
+
+function BookmarkLazyImage(props: ComponentProps) {
+ return ;
+}
+
+function renderBookmarkStickerImageContent(
+ mediaAutoLoad: boolean | undefined,
+ props: RenderImageContentProps
+) {
+ return (
+ }
+ />
+ );
+}
+
+function renderBookmarkEncryptedDecrypted(
+ ctx: bookmarkRendererContext,
+ event: MatrixEvent,
+ displayName: string,
+ mEvent: MatrixEvent,
+ evtTimeline: NonNullable>
+) {
+ const eventId = event.getId()!;
+ const eventType = mEvent.getType();
+ const stickerEventType: string = EventType.Sticker;
+ const roomMessageEventType: string = EventType.RoomMessage;
+ const encryptedMessageEventType: string = EventType.RoomMessageEncrypted;
+
+ if (mEvent.isRedacted()) return ;
+ if (eventType === stickerEventType) {
+ return (
+
+ );
+ }
+ if (eventType === roomMessageEventType) {
+ const editedEvent = getEditedEvent(eventId, mEvent, evtTimeline.getTimelineSet());
+ const getContent = (() => {
+ const eventContent = mEvent.getContent();
+ const editContent = editedEvent?.getContent();
+ return (editContent?.['m.new_content'] ?? eventContent) as Record;
+ }) as GetContentCallback;
+
+ return (
+
+ );
+ }
+ if (eventType === encryptedMessageEventType) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+}
+
+function renderBookmarkEncrypted(
+ ctx: bookmarkRendererContext,
+ event: MatrixEvent,
+ displayName: string
+) {
+ const eventId = event.getId()!;
+ const evtTimeline = ctx.room?.getTimelineForEvent(eventId);
+ const mEvent = evtTimeline?.getEvents().find((e: MatrixEvent) => e.getId() === eventId);
+
+ if (!mEvent || !evtTimeline) {
+ return (
+
+
+ {event.getType()}
+ {' event'}
+
+
+ );
+ }
+
+ return (
+
+ {renderBookmarkEncryptedDecrypted.bind(null, ctx, event, displayName, mEvent, evtTimeline)}
+
+ );
+}
+
+function renderBookmarkRoomMessage(
+ ctx: bookmarkRendererContext,
+ event: MatrixEvent,
+ displayName: string,
+ getContent: GetContentCallback
+) {
+ if (event.isRedacted()) {
+ const unsigned = event.getUnsigned();
+ const redactionContent = unsigned.redacted_because?.content as { reason?: string } | undefined;
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+function renderBookmarkSticker(
+ ctx: bookmarkRendererContext,
+ event: MatrixEvent,
+ _displayName: string,
+ getContent: GetContentCallback
+) {
+ if (event.isRedacted()) {
+ const unsigned = event.getUnsigned();
+ const redactionContent = unsigned.redacted_because?.content as
+ | Record
+ | undefined;
+
+ return ;
+ }
+ return (
+
+ );
+}
+
+function renderBookmarkFallback(_ctx: bookmarkRendererContext, event: MatrixEvent) {
+ if (event.isRedacted()) {
+ const unsigned = event.getUnsigned();
+ const redactionContent = unsigned.redacted_because?.content as
+ | Record
+ | undefined;
+ return ;
+ }
+ return (
+
+
+ {event.getType()}
+ {' event'}
+
+
+ );
+}
+
+function BookmarkItemRowBodyFallback({ item, highlightRegex }: BookmarkItemRowBodyFallbackProps) {
+ return (
+
+ {item.body_preview
+ ? highlightRegex
+ ? highlightText(highlightRegex, [item.body_preview])
+ : item.body_preview
+ : 'This bookmark has no preview'}
+
+ );
+}
+
+function BookmarkItemRowBody({
+ room,
+ item,
+ highlightRegex,
+ displayName,
+}: BookmarkItemRowBodyProps) {
+ const mx = useMatrixClient();
+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+ const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
+ const event = useRoomEvent(room, item.event_id); // TODO: only fetch when in view (virtualizer?)
+
+ const getContent = (() => event?.getContent()) as GetContentCallback;
+
+ const rendererContext = useMemo(
+ () => ({
+ mx,
+ room,
+ mediaAutoLoad,
+ urlPreview,
+ htmlReactParserOptions: {},
+ linkifyOpts: {},
+ }),
+ [mx, room, mediaAutoLoad, urlPreview]
+ );
+
+ // TODO: abstract this (code from pin menu) and reuse in a lot of places
+ const matrixEventHandlers = useMemo(
+ () => ({
+ [EventType.RoomMessage]: renderBookmarkRoomMessage.bind(null, rendererContext),
+ [EventType.RoomMessageEncrypted]: renderBookmarkEncrypted.bind(null, rendererContext),
+ [EventType.Sticker]: renderBookmarkSticker.bind(null, rendererContext),
+ [M_POLL_START.name]: renderBookmarkRoomMessage.bind(null, rendererContext),
+ }),
+ [rendererContext]
+ );
+
+ const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>(
+ matrixEventHandlers,
+ undefined,
+ renderBookmarkFallback.bind(null, rendererContext)
+ );
+
+ if (!event) {
+ return ;
+ }
+
+ return renderMatrixEvent(event.getType(), false, event, displayName, getContent);
+}
+
+function BookmarkItemRow({
+ item,
+ room,
+ displayName,
+ senderAvatarMxc,
+ usernameColor,
+ hour24Clock,
+ dateFormatString,
+ onOpen,
+ onRemove,
+ highlightRegex,
+}: BookmarkItemRowProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ const handleConfirmRemove = () => {
+ setConfirmOpen(false);
+ onRemove(item.bookmark_id);
+ };
+
+ return (
+ <>
+ {
+ return room ? (
+
+ ) : (
+
+ );
+ }}
+ onClose={() => setConfirmOpen(false)}
+ />
+
+
+
+ }
+ />
+
+
+ }
+ >
+
+
+
+
+ {displayName}
+
+
+
+
+
+
+
+
+
+
+
+ Jump
+
+ {
+ evt.stopPropagation();
+ setConfirmOpen(true);
+ }}
+ size="300"
+ radii="300"
+ aria-label="Remove bookmark"
+ style={{ color: color.Critical.Main }}
+ >
+
+
+
+
+
+
+
+ {room ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
+ );
+}
+
+type BookmarkResultGroupProps = {
+ roomId: string;
+ roomName?: string;
+ items: BookmarkItemContent[];
+ onOpen: (roomId: string, eventId: string) => void;
+ onRemove: (bookmarkId: string) => void;
+ hour24Clock: boolean;
+ dateFormatString: string;
+ legacyUsernameColor?: boolean;
+ highlightRegex?: RegExp;
+};
+function BookmarkResultGroup({
+ roomId,
+ roomName,
+ items,
+ onOpen,
+ onRemove,
+ hour24Clock,
+ dateFormatString,
+ legacyUsernameColor,
+ highlightRegex,
+}: BookmarkResultGroupProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const room = mx.getRoom(roomId);
+
+ const handleOpenClick: MouseEventHandler = (evt) => {
+ const eventId = evt.currentTarget.getAttribute('data-event-id');
+ if (!eventId) return;
+ onOpen(roomId, eventId);
+ };
+
+ return (
+
+
+
+
+ {room ? (
+ (
+
+ )}
+ />
+ ) : (
+
+ )}
+
+
+ {room?.name ?? roomName ?? roomId}
+
+
+
+
+ {items.map((item) => {
+ const displayName = room
+ ? (getMemberDisplayName(room, item.sender ?? '') ??
+ getMxIdLocalPart(item.sender ?? '') ??
+ item.sender ??
+ 'Unknown')
+ : (getMxIdLocalPart(item.sender ?? '') ?? item.sender ?? 'Unknown');
+ const senderAvatarMxc =
+ room && item.sender ? getMemberAvatarMxc(room, item.sender) : undefined;
+
+ const usernameColor =
+ legacyUsernameColor && item.sender ? colorMXID(item.sender) : undefined;
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+type BookmarkFilterInputProps = {
+ active?: boolean;
+ loading?: boolean;
+ searchInputRef: React.RefObject;
+ onChange: ChangeEventHandler;
+};
+function BookmarkFilterInput({
+ active,
+ loading,
+ searchInputRef,
+ onChange,
+}: BookmarkFilterInputProps) {
+ return (
+
+
+ Search
+
+ ) : (
+
+ )
+ }
+ />
+
+ );
+}
+
+export function Bookmarks() {
+ const mx = useMatrixClient();
+ const bookmarks = useBookmarkList();
+ const loading = useBookmarkLoading();
+ const { refresh, remove } = useBookmarkActions();
+ const { navigateRoom } = useRoomNavigate();
+ const screenSize = useScreenSizeContext();
+ const mDirects = useAtomValue(mDirectAtom);
+
+ const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
+ const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
+ const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
+
+ const searchInputRef = useRef(null);
+ const [filterTerm, setFilterTerm] = useState();
+
+ const handleAccountData = useCallback(
+ (event: MatrixEvent) => {
+ if (event.getType() === MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT) {
+ refresh();
+ }
+ },
+ [refresh]
+ );
+
+ useEffect(() => {
+ refresh();
+ mx.on(ClientEvent.AccountData, handleAccountData);
+ return () => {
+ mx.removeListener(ClientEvent.AccountData, handleAccountData);
+ };
+ }, [mx, refresh, handleAccountData]);
+
+ // Filter bookmarks by search term
+ const filtered = useMemo(() => {
+ if (!filterTerm) return bookmarks;
+ const lower = filterTerm.toLowerCase();
+ return bookmarks.filter(
+ (b) =>
+ (b.body_preview && b.body_preview.toLowerCase().includes(lower)) ||
+ (b.room_name && b.room_name.toLowerCase().includes(lower)) ||
+ (b.sender && b.sender.toLowerCase().includes(lower))
+ );
+ }, [bookmarks, filterTerm]);
+
+ const highlightRegex = useMemo(
+ () => (filterTerm ? makeHighlightRegex([filterTerm]) : undefined),
+ [filterTerm]
+ );
+
+ // Group filtered bookmarks by room
+ const groups = useMemo(() => {
+ const map = filtered.reduce((acc, item) => {
+ const existing = acc.get(item.room_id);
+ if (existing) {
+ existing.push(item);
+ } else {
+ acc.set(item.room_id, [item]);
+ }
+ return acc;
+ }, new Map());
+ return Array.from(map.entries());
+ }, [filtered]);
+
+ const handleOnChange: ChangeEventHandler = useDebounce(
+ (evt) => {
+ if (evt.target.value) setFilterTerm(evt.target.value);
+ else setFilterTerm(undefined);
+ },
+ { wait: 200 }
+ );
+
+ return (
+
+
+
+
+ {screenSize === ScreenSize.Mobile && (
+
+ {(onBack) => (
+
+
+
+ )}
+
+ )}
+
+
+ {screenSize !== ScreenSize.Mobile && }
+
+ Bookmarks
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!filterTerm && bookmarks.length === 0 && !loading && (
+
+
+ }
+ title="Bookmarks"
+ subTitle='Right-click a message and select "Bookmark Message" to save it here.'
+ />
+
+
+ )}
+
+ {loading && bookmarks.length === 0 && (
+
+ {[...Array(4).keys()].map((key) => (
+
+ ))}
+
+ )}
+
+ {filterTerm && filtered.length === 0 && (
+
+
+
+ No bookmarks found for {`"${filterTerm}"`}
+
+
+ )}
+
+ {groups.length > 0 && (
+
+ {filterTerm && (
+
+ {`Bookmarks matching "${filterTerm}"`}
+
+
+ )}
+ {groups.map(([roomId, items]) => (
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx
index 7dca68e16..65d428ea8 100644
--- a/src/app/pages/client/inbox/Inbox.tsx
+++ b/src/app/pages/client/inbox/Inbox.tsx
@@ -1,8 +1,16 @@
import { Avatar, Box, Text, toRem } from 'folds';
import { ChatCircleDots, EnvelopeSimple, Tray, sizedIcon } from '$components/icons/phosphor';
import { NavCategory, NavItem, NavItemContent, NavLink } from '$components/nav';
-import { getInboxInvitesPath, getInboxNotificationsPath } from '$pages/pathUtils';
-import { useInboxInvitesSelected, useInboxNotificationsSelected } from '$hooks/router/useInbox';
+import {
+ getInboxBookmarksPath,
+ getInboxInvitesPath,
+ getInboxNotificationsPath,
+} from '$pages/pathUtils';
+import {
+ useInboxBookmarksSelected,
+ useInboxInvitesSelected,
+ useInboxNotificationsSelected,
+} from '$hooks/router/useInbox';
import { UnreadBadge } from '$components/unread-badge';
import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '$components/page';
@@ -12,6 +20,7 @@ import { settingsAtom } from '$state/settings';
import { useEffect, useState } from 'react';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useInviteCount } from '$hooks/useInviteCount';
+import { BookmarkIcon } from '@phosphor-icons/react';
import { isResizingSidebarAtom } from '$state/isResizingSidebar';
import { useSetAtom } from 'jotai';
@@ -54,6 +63,7 @@ function InvitesNavItem({ hideText }: { hideText?: boolean }) {
export function Inbox() {
useNavToActivePathMapper('inbox');
const notificationsSelected = useInboxNotificationsSelected();
+ const bookmarksSelected = useInboxBookmarksSelected();
const setIsResizingSidebar = useSetAtom(isResizingSidebarAtom);
const [roomSidebarWidth, setRoomSidebarWidth] = useSetting(settingsAtom, 'roomSidebarWidth');
@@ -115,6 +125,24 @@ export function Inbox() {
+
+
+
+
+
+ {sizedIcon(BookmarkIcon, '100', {
+ filled: bookmarksSelected,
+ })}{' '}
+
+
+
+ Bookmarks
+
+
+
+
+
+
diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts
index c8036b471..dc02ccee6 100644
--- a/src/app/pages/client/inbox/index.ts
+++ b/src/app/pages/client/inbox/index.ts
@@ -1,3 +1,4 @@
export * from './Inbox';
export * from './Notifications';
export * from './Invites';
+export * from './Bookmarks';
diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx
index fe3f636e9..28dc3db9c 100644
--- a/src/app/pages/client/sidebar/InboxTab.tsx
+++ b/src/app/pages/client/sidebar/InboxTab.tsx
@@ -12,11 +12,17 @@ import {
getInboxPath,
joinPathComponent,
} from '$pages/pathUtils';
-import { useInboxSelected } from '$hooks/router/useInbox';
+import {
+ useInboxBookmarksSelected,
+ useInboxInvitesSelected,
+ useInboxNotificationsSelected,
+ useInboxSelected,
+} from '$hooks/router/useInbox';
import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
import { useNavToActivePathAtom } from '$state/hooks/navToActivePath';
import { useInviteCount } from '$hooks/useInviteCount';
-import { getPhosphorIconSize, Tray } from '$components/icons/phosphor';
+import { EnvelopeSimple, getPhosphorIconSize, Tray } from '$components/icons/phosphor';
+import { BookmarkIcon, ChatCircleDotsIcon } from '@phosphor-icons/react';
export function InboxTab({ isBottom }: { isBottom?: boolean }) {
const screenSize = useScreenSizeContext();
@@ -24,6 +30,11 @@ export function InboxTab({ isBottom }: { isBottom?: boolean }) {
const navToActivePath = useAtomValue(useNavToActivePathAtom());
const inboxSelected = useInboxSelected();
const inviteCount = useInviteCount();
+ const InboxIconSize = getPhosphorIconSize(isBottom ? 'inline' : 'toolbar');
+
+ const notificationsSelected = useInboxNotificationsSelected();
+ const bookmarksSelected = useInboxBookmarksSelected();
+ const invitesSelected = useInboxInvitesSelected();
const handleInboxClick = () => {
if (screenSize === ScreenSize.Mobile) {
@@ -51,10 +62,11 @@ export function InboxTab({ isBottom }: { isBottom?: boolean }) {
onClick={handleInboxClick}
size={'400'}
>
-
+ {(notificationsSelected && ) ||
+ (bookmarksSelected && ) ||
+ (invitesSelected && ) || (
+
+ )}
)}
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
index 4a95f47fc..8df6b14ac 100644
--- a/src/app/pages/pathUtils.ts
+++ b/src/app/pages/pathUtils.ts
@@ -28,6 +28,7 @@ import {
SPACE_ROOM_PATH,
SPACE_SEARCH_PATH,
CREATE_PATH,
+ INBOX_BOOKMARKS_PATH,
} from './paths';
export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash;
@@ -158,6 +159,7 @@ export const getCreatePath = (): string => CREATE_PATH;
export const getInboxPath = (): string => INBOX_PATH;
export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH;
export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH;
+export const getInboxBookmarksPath = (): string => INBOX_BOOKMARKS_PATH;
export const getSettingsPath = (section?: string, focus?: string): string => {
const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null }));
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts
index 1ac57b756..2770cd3ee 100644
--- a/src/app/pages/paths.ts
+++ b/src/app/pages/paths.ts
@@ -26,6 +26,7 @@ export type SettingsPathSearchParams = {
export const CREATE_PATH_SEGMENT = 'create/';
export const JOIN_PATH_SEGMENT = 'join/';
export const LOBBY_PATH_SEGMENT = 'lobby/';
+export const BOOKMARKS_PATH_SEGMENT = 'bookmarks/';
/**
* array of rooms and senders mxId assigned
* to search param as string should be "," separated
@@ -88,6 +89,7 @@ export type InboxNotificationsPathSearchParams = {
};
export const INBOX_NOTIFICATIONS_PATH = `/inbox/${NOTIFICATIONS_PATH_SEGMENT}`;
export const INBOX_INVITES_PATH = `/inbox/${INVITES_PATH_SEGMENT}`;
+export const INBOX_BOOKMARKS_PATH = `/inbox/${BOOKMARKS_PATH_SEGMENT}`;
export const TO_PATH = '/to';
// Deep-link route used by push notification click-back URLs.
diff --git a/src/app/state/bookmarks.ts b/src/app/state/bookmarks.ts
new file mode 100644
index 000000000..bc9239aad
--- /dev/null
+++ b/src/app/state/bookmarks.ts
@@ -0,0 +1,58 @@
+import { atom, useSetAtom } from 'jotai';
+import type { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
+import { ClientEvent } from 'matrix-js-sdk';
+import { useCallback, useEffect } from 'react';
+import type { BookmarkItemContent } from '$types/matrix-sdk-events';
+import { listBookmarks } from '$features/bookmarks/bookmarkRepository';
+import { MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT } from '$unstable/prefixes';
+
+export const bookmarkListAtom = atom([]);
+export const bookmarkLoadingAtom = atom(false);
+export const bookmarkRefreshErrorAtom = atom(undefined);
+
+export const bookmarksAtom = {
+ list: bookmarkListAtom,
+ loading: bookmarkLoadingAtom,
+ refreshError: bookmarkRefreshErrorAtom,
+};
+
+export const bookmarkIdSetAtom = atom>((get) => {
+ const list = get(bookmarkListAtom);
+ return new Set(list.map((b) => b.bookmark_id));
+});
+
+export const useBindBookmarksAtom = (mx: MatrixClient, bookmarks: typeof bookmarksAtom) => {
+ const setList = useSetAtom(bookmarks.list);
+ const setLoading = useSetAtom(bookmarks.loading);
+ const setRefreshError = useSetAtom(bookmarks.refreshError);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const items = await listBookmarks(mx);
+ setList(items);
+ setRefreshError(undefined);
+ } catch (error) {
+ setRefreshError(error as Error);
+ } finally {
+ setLoading(false);
+ }
+ }, [mx, setList, setLoading, setRefreshError]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ useEffect(() => {
+ const handleAccountData = (event: MatrixEvent) => {
+ if (event.getType() === MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT) {
+ refresh();
+ }
+ };
+
+ mx.on(ClientEvent.AccountData, handleAccountData);
+ return () => {
+ mx.removeListener(ClientEvent.AccountData, handleAccountData);
+ };
+ }, [mx, refresh]);
+};
diff --git a/src/app/state/hooks/useBindAtoms.ts b/src/app/state/hooks/useBindAtoms.ts
index 6f6392dc9..58e6390d9 100644
--- a/src/app/state/hooks/useBindAtoms.ts
+++ b/src/app/state/hooks/useBindAtoms.ts
@@ -2,12 +2,14 @@ import type { MatrixClient } from '$types/matrix-sdk';
import { allInvitesAtom, useBindAllInvitesAtom } from '$state/room-list/inviteList';
import { allRoomsAtom, useBindAllRoomsAtom } from '$state/room-list/roomList';
import { mDirectAtom, useBindMDirectAtom } from '$state/mDirectList';
+import { bookmarksAtom, useBindBookmarksAtom } from '$state/bookmarks';
import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '$state/room/roomToUnread';
import { roomToParentsAtom, useBindRoomToParentsAtom } from '$state/room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '$state/typingMembers';
export const useBindAtoms = (mx: MatrixClient) => {
useBindMDirectAtom(mx, mDirectAtom);
+ useBindBookmarksAtom(mx, bookmarksAtom);
useBindAllInvitesAtom(mx, allInvitesAtom);
useBindAllRoomsAtom(mx, allRoomsAtom);
useBindRoomToParentsAtom(mx, roomToParentsAtom);
diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts
index 8aeb7bc9f..3b716dba3 100644
--- a/src/types/matrix-sdk-events.d.ts
+++ b/src/types/matrix-sdk-events.d.ts
@@ -37,6 +37,29 @@ type RoomCosmeticsPronounsEventContent = {
type RoomBannerContent = {
url?: string;
};
+
+type BookmarkIndexContent = {
+ version: 1;
+ revision: number;
+ updated_ts: number;
+ bookmark_ids: string[];
+};
+
+type BookmarkItemContent = {
+ version: 1;
+ bookmark_id: string;
+ uri: string;
+ room_id: string;
+ event_id: string;
+ event_ts: number;
+ bookmarked_ts: number;
+ sender?: string;
+ room_name?: string;
+ body_preview?: string;
+ msgtype?: string;
+ deleted?: boolean;
+};
+
declare module 'matrix-js-sdk/lib/@types/event' {
interface StateEvents {
[prefix.MATRIX_UNSTABLE_STATE_ROOM_EMOTES_PROPERTY_NAME]: PackContent;
@@ -58,6 +81,8 @@ declare module 'matrix-js-sdk/lib/@types/event' {
[prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME]: Record;
[prefix.MATRIX_SABLE_UNSTABLE_DISMISSED_INVITES]: { roomIds: string[] };
[prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_ADDED_SERVERS_PROPERTY_NAME]: AddedServersContent;
+ [prefix.MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT]: BookmarkIndexContent;
+ [prefix.MATRIX_SABLE_UNSTABLE_BOOKMARK_ITEM_EVENT_PREFIX]: BookmarkItemContent;
[prefix.MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS]: { gifs: Omit[] };
}
}
diff --git a/src/unstable/prefixes/sable/accountdata.ts b/src/unstable/prefixes/sable/accountdata.ts
index 57bde6303..001404ec5 100644
--- a/src/unstable/prefixes/sable/accountdata.ts
+++ b/src/unstable/prefixes/sable/accountdata.ts
@@ -14,4 +14,6 @@ export const MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME =
export const MATRIX_SABLE_UNSTABLE_DISMISSED_INVITES = 'moe.sable.dismissed_invites';
export const MATRIX_SABLE_UNSTABLE_ACCOUNT_ADDED_SERVERS_PROPERTY_NAME = 'moe.sable.added_servers';
+export const MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT = 'pl.chrome.bookmarks.index';
+export const MATRIX_SABLE_UNSTABLE_BOOKMARK_ITEM_EVENT_PREFIX = 'pl.chrome.bookmark.';
export const MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS = 'moe.sable.favorite_gifs';