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/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 045dd6973..61025d4dd 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -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'; @@ -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; @@ -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 ( + + + {bookmarked ? 'Remove Bookmark' : 'Bookmark Message'} + + + ); +}); + export const MessageCopyLinkItem = as< 'button', { @@ -1223,6 +1264,7 @@ function MessageInternal( )} + {canPinEvent && ( @@ -1566,6 +1608,13 @@ export const Event = as<'div', EventProps>( )} + {!stateEvent && ( + + )} {((!mEvent.isRedacted() && canDelete && !stateEvent) || 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 ( + }> + + + +
+ + Remove Bookmark + + + + +
+ + Are you sure you want to remove this bookmark? + {sender && ( + + {sender && ( + + + } + /> + + + {displayName ?? sender} + + + )} + {renderMatrixEvent()} + + )} + + +
+
+
+
+ ); +} + +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/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 ef7e25880..d46226b98 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -36,6 +36,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; @@ -57,5 +80,7 @@ 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; } } diff --git a/src/unstable/prefixes/sable/accountdata.ts b/src/unstable/prefixes/sable/accountdata.ts index 93441cece..4a4956ee6 100644 --- a/src/unstable/prefixes/sable/accountdata.ts +++ b/src/unstable/prefixes/sable/accountdata.ts @@ -14,3 +14,5 @@ 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.';