Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat_add_bookmarks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add bookmark functionality using account data
13 changes: 13 additions & 0 deletions src/app/components/GlobalKeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getDirectRoomPath,
getHomeRoomPath,
getHomeSearchPath,
getInboxBookmarksPath,
getSpaceRoomPath,
getSpaceSearchPath,
withSearchParam,
Expand Down Expand Up @@ -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) => {
Expand All @@ -184,6 +196,7 @@ export function GlobalKeyboardShortcuts() {
useKeyDown(window, handleNextUnreadKeyDown);
useKeyDown(window, handleUnreadNavKeyDown);
useKeyDown(window, handleReplyKeyDown);
useKeyDown(window, handleBookmarkKeyDown);
useKeyDown(window, handleSearchMessageInRoom);

return null;
Expand Down
90 changes: 90 additions & 0 deletions src/app/features/bookmarks/bookmarkDomain.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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: [],
};
}
91 changes: 91 additions & 0 deletions src/app/features/bookmarks/bookmarkRepository.ts
Original file line number Diff line number Diff line change
@@ -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<BookmarkIndexContent> {
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<BookmarkItemContent | undefined> {
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<void> {
await mx.setAccountData(MATRIX_SABLE_UNSTABLE_BOOKMARKS_INDEX_EVENT, index);
}

async function writeItem(mx: MatrixClient, item: BookmarkItemContent): Promise<void> {
await mx.setAccountData(bookmarkItemEventType(item.bookmark_id) as keyof AccountDataEvents, item);
}

type IndexMutator = (index: BookmarkIndexContent) => BookmarkIndexContent;

async function mutateIndex(mx: MatrixClient, mutate: IndexMutator): Promise<void> {
const currentIndex = await readIndexFromServer(mx);
const nextIndex = mutate(currentIndex);
await writeIndex(mx, nextIndex);
}

export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent): Promise<void> {
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<void> {
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<BookmarkItemContent[]> {
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);
}
3 changes: 3 additions & 0 deletions src/app/features/bookmarks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './bookmarkDomain';
export * from './bookmarkRepository';
export * from '../../hooks/useBookmarks';
49 changes: 49 additions & 0 deletions src/app/features/room/message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ import {
} from '$components/icons/phosphor';
import { getPowerTagIconSrc } from '$hooks/useMemberPowerTag';
import { useSableCosmetics } from '$hooks/useSableCosmetics';
import { computeBookmarkId, createBookmarkItem } from '$features/bookmarks/bookmarkDomain';
import { useIsBookmarked, useBookmarkActions } from '$hooks/useBookmarks';
import { SwipeableMessageWrapper } from '$components/SwipeableMessageWrapper';
import { mobileOrTablet } from '$utils/user-agent';
import { useUserProfile } from '$hooks/useUserProfile';
Expand All @@ -98,6 +100,7 @@ import type { PerMessageProfileBeeperFormat } from '$hooks/usePerMessageProfile'
import { convertBeeperFormatToOurPerMessageProfile } from '$hooks/usePerMessageProfile';
import { MessageEditor } from './MessageEditor';
import * as css from './styles.css';
import { BookmarkIcon } from '@phosphor-icons/react';

export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;

Expand Down Expand Up @@ -142,6 +145,44 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
}
);

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 (
<MenuItem
size="300"
after={menuIcon(BookmarkIcon)}
radii="300"
onClick={handleClick}
{...props}
ref={ref}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{bookmarked ? 'Remove Bookmark' : 'Bookmark Message'}
</Text>
</MenuItem>
);
});

export const MessageCopyLinkItem = as<
'button',
{
Expand Down Expand Up @@ -1223,6 +1264,7 @@ function MessageInternal(
<MessageSourceCodeItem room={room} mEvent={mEvent} />
)}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageBookmarkItem room={room} mEvent={mEvent} onClose={closeMenu} />
<MessageForwardItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && (
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
Expand Down Expand Up @@ -1566,6 +1608,13 @@ export const Event = as<'div', EventProps>(
<MessageSourceCodeItem room={room} mEvent={mEvent} />
)}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{!stateEvent && (
<MessageBookmarkItem
room={room}
mEvent={mEvent}
onClose={closeMenu}
/>
)}
<MessageForwardItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
Expand Down
17 changes: 16 additions & 1 deletion src/app/hooks/router/useInbox.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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;
};
Loading
Loading