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
130 changes: 48 additions & 82 deletions src/app/components/message/modals/Options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -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 (
<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 type OptionEmojiMenuProps = {
mEvent: MatrixEvent;
closeMenu: () => void;
Expand Down Expand Up @@ -284,7 +328,6 @@ export function OptionQuickMenu({
hideReadReceipts,
showDeveloperTools,
canPinEvent,
cleanedDisplayName,
canDelete,
handleOpenMenu,
menuAnchor,
Expand Down Expand Up @@ -386,7 +429,6 @@ export function OptionQuickMenu({
hideReadReceipts={hideReadReceipts}
showDeveloperTools={showDeveloperTools}
canPinEvent={canPinEvent}
cleanedDisplayName={cleanedDisplayName}
canDelete={canDelete}
setIsEmoji={setIsEmoji}
emojiBoardAnchor={menuAnchor}
Expand Down Expand Up @@ -433,7 +475,6 @@ export type OptionMenuProps = {
hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
canPinEvent?: boolean;
cleanedDisplayName?: string;
canDelete?: boolean;
handleOpenMenu?: MouseEventHandler<HTMLButtonElement>;
menuAnchor?: RectCords | undefined;
Expand All @@ -458,7 +499,6 @@ export function OptionMenu({
hideReadReceipts,
showDeveloperTools,
canPinEvent,
cleanedDisplayName,
canDelete,
imagePackRooms,
setIsEmoji,
Expand All @@ -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();
Expand Down Expand Up @@ -666,80 +700,13 @@ export function OptionMenu({
<MessageSourceCodeItem room={room} mEvent={mEvent} closeMenu={closeMenu} />
)}
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={onTotalClose} />

<MessageCopyTextItem room={room} mEvent={mEvent} onClose={onTotalClose} />
{canForwardEvent(mEvent) && (
<MessageForwardItem room={room} mEvent={mEvent} onClose={closeMenu} />
)}
<MessageBookmarkItem room={room} mEvent={mEvent} onClose={closeMenu} />
{canPinEvent && <MessagePinItem room={room} mEvent={mEvent} onClose={onTotalClose} />}
{cleanedDisplayName &&
senderId !== mx.getUserId() &&
(nickEditOpen ? (
<Box
direction="Column"
gap="100"
style={{
padding: `${config.space.S100} ${config.space.S200}`,
}}
>
<Text size="L400">Nickname</Text>
<input
autoFocus
value={nickDraft}
onChange={(e) => 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}
/>
<Box gap="200">
<MenuItem
size="300"
radii="300"
variant="Success"
fill="None"
onClick={() => {
setNickname(senderId, nickDraft || undefined, mx);
closeMenu();
}}
>
<Text size="B300">Save</Text>
</MenuItem>
{nicknames[senderId] && (
<MenuItem
size="300"
radii="300"
variant="Critical"
fill="None"
onClick={() => {
setNickname(senderId, undefined, mx);
onTotalClose();
}}
>
<Text size="B300">Clear</Text>
</MenuItem>
)}
</Box>
</Box>
) : (
<MenuItem
size="300"
after={menuIcon(PencilSimple)}
radii="300"
onClick={() => {
setNickDraft(nicknames[senderId] ?? '');
setNickEditOpen(true);
}}
>
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
{nicknames[senderId] ? 'Edit Nickname' : 'Set Nickname'}
</Text>
</MenuItem>
))}
</Box>
{((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && (
<>
Expand Down Expand Up @@ -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}
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: [],
};
}
Loading
Loading