From 35f0e400ad2e4ca1e7f5757f63843ac21b3c5d08 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 16 Dec 2024 21:55:15 +1100 Subject: [PATCH] Pinned Messages (#2081) * add pinned room events hook * room pinned message - WIP * add room event hook * fetch pinned messages before displaying * use react-query in room event hook * disable staleTime and gc to 1 hour in room event hook * use room event hook in reply component * render pinned messages * add option to pin/unpin messages * remove message base from message placeholders and add variant * display message placeholder while loading pinned messages * render pinned event error * show no pinned message placeholder * fix message placeholder flickering --- src/app/components/message/Reply.tsx | 128 ++--- .../placeholder/CompactPlaceholder.tsx | 43 +- .../placeholder/DefaultPlaceholder.tsx | 52 +- .../placeholder/LinePlaceholder.css.ts | 43 +- .../message/placeholder/LinePlaceholder.tsx | 13 +- src/app/features/room/RoomTimeline.tsx | 73 ++- src/app/features/room/RoomViewHeader.tsx | 69 ++- src/app/features/room/message/Message.tsx | 141 ++++-- .../room/room-pin-menu/RoomPinMenu.css.ts | 18 + .../room/room-pin-menu/RoomPinMenu.tsx | 468 ++++++++++++++++++ src/app/features/room/room-pin-menu/index.ts | 1 + src/app/hooks/useRoomEvent.ts | 56 +++ src/app/hooks/useRoomPinnedEvents.ts | 15 + src/app/pages/client/inbox/Notifications.tsx | 10 +- 14 files changed, 939 insertions(+), 191 deletions(-) create mode 100644 src/app/features/room/room-pin-menu/RoomPinMenu.css.ts create mode 100644 src/app/features/room/room-pin-menu/RoomPinMenu.tsx create mode 100644 src/app/features/room/room-pin-menu/index.ts create mode 100644 src/app/hooks/useRoomEvent.ts create mode 100644 src/app/hooks/useRoomPinnedEvents.ts diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 82a9d9198..7687074e4 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -1,8 +1,6 @@ import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; -import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; -import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; -import React, { MouseEventHandler, ReactNode, useEffect, useMemo, useState } from 'react'; -import to from 'await-to-js'; +import { EventTimelineSet, Room } from 'matrix-js-sdk'; +import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react'; import classNames from 'classnames'; import colorMXID from '../../../util/colorMXID'; import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room'; @@ -12,6 +10,7 @@ import { randomNumberBetween } from '../../utils/common'; import * as css from './Reply.css'; import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content'; import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; +import { useRoomEvent } from '../../hooks/useRoomEvent'; type ReplyLayoutProps = { userColor?: string; @@ -46,7 +45,6 @@ export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( )); type ReplyProps = { - mx: MatrixClient; room: Room; timelineSet?: EventTimelineSet | undefined; replyEventId: string; @@ -54,78 +52,60 @@ type ReplyProps = { onClick?: MouseEventHandler | undefined; }; -export const Reply = as<'div', ReplyProps>((_, ref) => { - const { mx, room, timelineSet, replyEventId, threadRootId, onClick, ...props } = _; - const [replyEvent, setReplyEvent] = useState( - timelineSet?.findEventById(replyEventId) - ); - const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []); +export const Reply = as<'div', ReplyProps>( + ({ room, timelineSet, replyEventId, threadRootId, onClick, ...props }, ref) => { + const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []); + const getFromLocalTimeline = useCallback( + () => timelineSet?.findEventById(replyEventId), + [timelineSet, replyEventId] + ); + const replyEvent = useRoomEvent(room, replyEventId, getFromLocalTimeline); - const { body } = replyEvent?.getContent() ?? {}; - const sender = replyEvent?.getSender(); + const { body } = replyEvent?.getContent() ?? {}; + const sender = replyEvent?.getSender(); - const fallbackBody = replyEvent?.isRedacted() ? ( - - ) : ( - - ); + const fallbackBody = replyEvent?.isRedacted() ? ( + + ) : ( + + ); - useEffect(() => { - let disposed = false; - const loadEvent = async () => { - const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, replyEventId)); - const mEvent = new MatrixEvent(evt); - if (disposed) return; - if (err) { - setReplyEvent(null); - return; - } - if (mEvent.isEncrypted() && mx.getCrypto()) { - await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend)); - } - setReplyEvent(mEvent); - }; - if (replyEvent === undefined) loadEvent(); - return () => { - disposed = true; - }; - }, [replyEvent, mx, room, replyEventId]); + const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; + const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; - const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; - const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; - - return ( - - {threadRootId && ( - - )} - + {threadRootId && ( + + )} + + {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)} + + ) + } + data-event-id={replyEventId} + onClick={onClick} + > + {replyEvent !== undefined ? ( - {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)} + {badEncryption ? : bodyJSX} - ) - } - data-event-id={replyEventId} - onClick={onClick} - > - {replyEvent !== undefined ? ( - - {badEncryption ? : bodyJSX} - - ) : ( - - )} - - - ); -}); + ) : ( + + )} + + + ); + } +); diff --git a/src/app/components/message/placeholder/CompactPlaceholder.tsx b/src/app/components/message/placeholder/CompactPlaceholder.tsx index a6be083ef..e6168ae3e 100644 --- a/src/app/components/message/placeholder/CompactPlaceholder.tsx +++ b/src/app/components/message/placeholder/CompactPlaceholder.tsx @@ -1,22 +1,27 @@ -import React from 'react'; -import { as, toRem } from 'folds'; +import React, { useMemo } from 'react'; +import { as, ContainerColor, toRem } from 'folds'; import { randomNumberBetween } from '../../../utils/common'; import { LinePlaceholder } from './LinePlaceholder'; -import { CompactLayout, MessageBase } from '../layout'; +import { CompactLayout } from '../layout'; -export const CompactPlaceholder = as<'div'>(({ ...props }, ref) => ( - - - - - - } - > - - - -)); +export const CompactPlaceholder = as<'div', { variant?: ContainerColor }>( + ({ variant, ...props }, ref) => { + const nameSize = useMemo(() => randomNumberBetween(40, 100), []); + const msgSize = useMemo(() => randomNumberBetween(120, 500), []); + + return ( + + + + + } + > + + + ); + } +); diff --git a/src/app/components/message/placeholder/DefaultPlaceholder.tsx b/src/app/components/message/placeholder/DefaultPlaceholder.tsx index 5f0b57fae..725ac4b9f 100644 --- a/src/app/components/message/placeholder/DefaultPlaceholder.tsx +++ b/src/app/components/message/placeholder/DefaultPlaceholder.tsx @@ -1,25 +1,39 @@ -import React, { CSSProperties } from 'react'; -import { Avatar, Box, as, color, toRem } from 'folds'; +import React, { CSSProperties, useMemo } from 'react'; +import { Avatar, Box, ContainerColor, as, color, toRem } from 'folds'; import { randomNumberBetween } from '../../../utils/common'; import { LinePlaceholder } from './LinePlaceholder'; -import { MessageBase, ModernLayout } from '../layout'; +import { ModernLayout } from '../layout'; const contentMargin: CSSProperties = { marginTop: toRem(3) }; -const avatarBg: CSSProperties = { backgroundColor: color.SurfaceVariant.Container }; -export const DefaultPlaceholder = as<'div'>(({ ...props }, ref) => ( - - }> - - - - - - - - +export const DefaultPlaceholder = as<'div', { variant?: ContainerColor }>( + ({ variant, ...props }, ref) => { + const nameSize = useMemo(() => randomNumberBetween(40, 100), []); + const msgSize = useMemo(() => randomNumberBetween(80, 200), []); + const msg2Size = useMemo(() => randomNumberBetween(80, 200), []); + + return ( + + } + > + + + + + + + + + - - - -)); + + ); + } +); diff --git a/src/app/components/message/placeholder/LinePlaceholder.css.ts b/src/app/components/message/placeholder/LinePlaceholder.css.ts index 0baedf6e1..34ad76a3e 100644 --- a/src/app/components/message/placeholder/LinePlaceholder.css.ts +++ b/src/app/components/message/placeholder/LinePlaceholder.css.ts @@ -1,12 +1,35 @@ -import { style } from '@vanilla-extract/css'; -import { DefaultReset, color, config, toRem } from 'folds'; +import { ComplexStyleRule } from '@vanilla-extract/css'; +import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; +import { ContainerColor, DefaultReset, color, config, toRem } from 'folds'; -export const LinePlaceholder = style([ - DefaultReset, - { - width: '100%', - height: toRem(16), - borderRadius: config.radii.R300, - backgroundColor: color.SurfaceVariant.Container, +const getVariant = (variant: ContainerColor): ComplexStyleRule => ({ + backgroundColor: color[variant].Container, +}); + +export const LinePlaceholder = recipe({ + base: [ + DefaultReset, + { + width: '100%', + height: toRem(16), + borderRadius: config.radii.R300, + }, + ], + variants: { + variant: { + Background: getVariant('Background'), + Surface: getVariant('Surface'), + SurfaceVariant: getVariant('SurfaceVariant'), + Primary: getVariant('Primary'), + Secondary: getVariant('Secondary'), + Success: getVariant('Success'), + Warning: getVariant('Warning'), + Critical: getVariant('Critical'), + }, + }, + defaultVariants: { + variant: 'SurfaceVariant', }, -]); +}); + +export type LinePlaceholderVariants = RecipeVariants; diff --git a/src/app/components/message/placeholder/LinePlaceholder.tsx b/src/app/components/message/placeholder/LinePlaceholder.tsx index a5e7bd75a..58fc52c0e 100644 --- a/src/app/components/message/placeholder/LinePlaceholder.tsx +++ b/src/app/components/message/placeholder/LinePlaceholder.tsx @@ -3,6 +3,13 @@ import { Box, as } from 'folds'; import classNames from 'classnames'; import * as css from './LinePlaceholder.css'; -export const LinePlaceholder = as<'div'>(({ className, ...props }, ref) => ( - -)); +export const LinePlaceholder = as<'div', css.LinePlaceholderVariants>( + ({ className, variant, ...props }, ref) => ( + + ) +); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index a2738fcbf..63b3d3e2c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -433,10 +433,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const powerLevels = usePowerLevelsContext(); - const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels); + const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } = + usePowerLevelsAPI(powerLevels); const myPowerLevel = getPowerLevel(mx.getUserId() ?? ''); const canRedact = canDoAction('redact', myPowerLevel); const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel); + const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, myPowerLevel); const [editId, setEditId] = useState(); const roomToParents = useAtomValue(roomToParentsAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); @@ -983,6 +985,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli edit={editId === mEventId} canDelete={canRedact || mEvent.getSender() === mx.getUserId()} canSendReaction={canSendReaction} + canPinEvent={canPinEvent} imagePackRooms={imagePackRooms} relations={hasReactions ? reactionRelations : undefined} onUserClick={handleUserClick} @@ -993,7 +996,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli reply={ replyEventId && ( - - - - - + + + + + + + + + + + + + + + ) : ( <> - - - + + + + + + + + + ))} @@ -1570,17 +1589,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli {(!liveTimelineLinked || !rangeAtEnd) && (messageLayout === 1 ? ( <> - - - - - + + + + + + + + + + + + + + + ) : ( <> - - - + + + + + + + + + ))} diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index ae80deb6e..7ee1d3029 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -19,6 +19,7 @@ import { Line, PopOut, RectCords, + Badge, } from 'folds'; import { useNavigate } from 'react-router-dom'; import { JoinRule, Room } from 'matrix-js-sdk'; @@ -54,6 +55,8 @@ import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getViaServers } from '../../plugins/via-servers'; import { BackRouteHandler } from '../../components/BackRouteHandler'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents'; +import { RoomPinMenu } from './room-pin-menu'; type RoomMenuProps = { room: Room; @@ -180,14 +183,18 @@ export function RoomViewHeader() { const room = useRoom(); const space = useSpaceOptionally(); const [menuAnchor, setMenuAnchor] = useState(); + const [pinMenuAnchor, setPinMenuAnchor] = useState(); const mDirects = useAtomValue(mDirectAtom); + const pinnedEvents = useRoomPinnedEvents(room); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const ecryptedRoom = !!encryptionEvent; const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); const name = useRoomName(room); const topic = useRoomTopic(room); - const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; + const avatarUrl = avatarMxc + ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined + : undefined; const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); @@ -205,6 +212,10 @@ export function RoomViewHeader() { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleOpenPinMenu: MouseEventHandler = (evt) => { + setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + return ( @@ -297,6 +308,62 @@ export function RoomViewHeader() { )} )} + + Pinned Messages + + } + > + {(triggerRef) => ( + + {pinnedEvents.length > 0 && ( + + + {pinnedEvents.length} + + + )} + + + )} + + setPinMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setPinMenuAnchor(undefined)} /> + + } + /> {screenSize === ScreenSize.Desktop && ( void; @@ -235,9 +243,9 @@ export const MessageSourceCodeItem = as< const getContent = (evt: MatrixEvent) => evt.isEncrypted() ? { - [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(), - [`<== ORIGINAL_EVENT ==>`]: evt.event, - } + [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(), + [`<== ORIGINAL_EVENT ==>`]: evt.event, + } : evt.event; const getText = (): string => { @@ -340,6 +348,46 @@ export const MessageCopyLinkItem = as< ); }); +export const MessagePinItem = as< + 'button', + { + room: Room; + mEvent: MatrixEvent; + onClose?: () => void; + } +>(({ room, mEvent, onClose, ...props }, ref) => { + const mx = useMatrixClient(); + const pinnedEvents = useRoomPinnedEvents(room); + const isPinned = pinnedEvents.includes(mEvent.getId() ?? ''); + + const handlePin = () => { + const eventId = mEvent.getId(); + const pinContent: RoomPinnedEventsEventContent = { + pinned: Array.from(pinnedEvents).filter((id) => id !== eventId), + }; + if (!isPinned && eventId) { + pinContent.pinned.push(eventId); + } + mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, pinContent); + onClose?.(); + }; + + return ( + } + radii="300" + onClick={handlePin} + {...props} + ref={ref} + > + + {isPinned ? 'Unpin Message' : 'Pin Message'} + + + ); +}); + export const MessageDeleteItem = as< 'button', { @@ -611,6 +659,7 @@ export type MessageProps = { edit?: boolean; canDelete?: boolean; canSendReaction?: boolean; + canPinEvent?: boolean; imagePackRooms?: Room[]; relations?: Relations; messageLayout: MessageLayout; @@ -634,6 +683,7 @@ export const Message = as<'div', MessageProps>( edit, canDelete, canSendReaction, + canPinEvent, imagePackRooms, relations, messageLayout, @@ -949,29 +999,32 @@ export const Message = as<'div', MessageProps>( /> + {canPinEvent && ( + + )} {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( - <> - - - {!mEvent.isRedacted() && canDelete && ( - - )} - {mEvent.getSender() !== mx.getUserId() && ( - - )} - - - )} + <> + + + {!mEvent.isRedacted() && canDelete && ( + + )} + {mEvent.getSender() !== mx.getUserId() && ( + + )} + + + )} } @@ -1095,26 +1148,26 @@ export const Event = as<'div', EventProps>( {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( - <> - - - {!mEvent.isRedacted() && canDelete && ( - - )} - {mEvent.getSender() !== mx.getUserId() && ( - - )} - - - )} + <> + + + {!mEvent.isRedacted() && canDelete && ( + + )} + {mEvent.getSender() !== mx.getUserId() && ( + + )} + + + )} } diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.css.ts b/src/app/features/room/room-pin-menu/RoomPinMenu.css.ts new file mode 100644 index 000000000..9b0269b5d --- /dev/null +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.css.ts @@ -0,0 +1,18 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const PinMenu = style({ + display: 'flex', + maxWidth: toRem(548), + width: '100vw', + maxHeight: '90vh', +}); + +export const PinMenuHeader = style({ + paddingLeft: config.space.S400, + paddingRight: config.space.S200, +}); + +export const PinMenuContent = style({ + paddingLeft: config.space.S200, +}); diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx new file mode 100644 index 000000000..c3d259ae9 --- /dev/null +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -0,0 +1,468 @@ +/* eslint-disable react/destructuring-assignment */ +import React, { forwardRef, MouseEventHandler, useCallback, useMemo, useRef } from 'react'; +import { MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; +import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; +import { + Avatar, + Box, + Chip, + color, + config, + Header, + Icon, + IconButton, + Icons, + Menu, + Scroll, + Spinner, + Text, + toRem, +} from 'folds'; +import { Opts as LinkifyOpts } from 'linkifyjs'; +import { HTMLReactParserOptions } from 'html-react-parser'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; +import * as css from './RoomPinMenu.css'; +import { SequenceCard } from '../../../components/sequence-card'; +import { useRoomEvent } from '../../../hooks/useRoomEvent'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { + AvatarBase, + DefaultPlaceholder, + ImageContent, + MessageNotDecryptedContent, + MessageUnsupportedContent, + ModernLayout, + MSticker, + RedactedContent, + Reply, + Time, + Username, +} from '../../../components/message'; +import { UserAvatar } from '../../../components/user-avatar'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { + getEditedEvent, + getMemberAvatarMxc, + getMemberDisplayName, + getStateEvent, +} from '../../../utils/room'; +import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room'; +import colorMXID from '../../../../util/colorMXID'; +import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '../../../plugins/react-custom-html-parser'; +import { RenderMatrixEvent, useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer'; +import { RenderMessageContent } from '../../../components/RenderMessageContent'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import * as customHtmlCss from '../../../styles/CustomHtml.css'; +import { EncryptedContent } from '../message'; +import { Image } from '../../../components/media'; +import { ImageViewer } from '../../../components/image-viewer'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { VirtualTile } from '../../../components/virtualizer'; +import { usePowerLevelsAPI, usePowerLevelsContext } from '../../../hooks/usePowerLevels'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; + +type PinnedMessageProps = { + room: Room; + eventId: string; + renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>; + onOpen: (roomId: string, eventId: string) => void; + canPinEvent: boolean; +}; +function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: PinnedMessageProps) { + const pinnedEvent = useRoomEvent(room, eventId); + const useAuthentication = useMediaAuthentication(); + const mx = useMatrixClient(); + + const [unpinState, unpin] = useAsyncCallback( + useCallback(() => { + const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents); + const content = pinEvent?.getContent() ?? { pinned: [] }; + const newContent: RoomPinnedEventsEventContent = { + pinned: content.pinned.filter((id) => id !== eventId), + }; + + return mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents, newContent); + }, [room, eventId, mx]) + ); + + const handleOpenClick: MouseEventHandler = (evt) => { + evt.stopPropagation(); + const evtId = evt.currentTarget.getAttribute('data-event-id'); + if (!evtId) return; + onOpen(room.roomId, evtId); + }; + + const handleUnpinClick: MouseEventHandler = (evt) => { + evt.stopPropagation(); + unpin(); + }; + + const renderOptions = () => ( + + + Open + + {canPinEvent && ( + + {unpinState.status === AsyncStatus.Loading ? ( + + ) : ( + + )} + + )} + + ); + + if (pinnedEvent === undefined) return ; + if (pinnedEvent === null) + return ( + + + Failed to load message! + + {renderOptions()} + + ); + + const sender = pinnedEvent.getSender()!; + const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender; + const senderAvatarMxc = getMemberAvatarMxc(room, sender); + const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; + return ( + + + } + /> + + + } + > + + + + + {displayName} + + + + {renderOptions()} + + {pinnedEvent.replyEventId && ( + + )} + {renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)} + + ); +} + +type RoomPinMenuProps = { + room: Room; + requestClose: () => void; +}; +export const RoomPinMenu = forwardRef( + ({ room, requestClose }, ref) => { + const mx = useMatrixClient(); + const userId = mx.getUserId()!; + const powerLevels = usePowerLevelsContext(); + const { canSendStateEvent, getPowerLevel } = usePowerLevelsAPI(powerLevels); + const canPinEvent = canSendStateEvent(StateEvent.RoomPinnedEvents, getPowerLevel(userId)); + + const pinnedEvents = useRoomPinnedEvents(room); + const sortedPinnedEvent = useMemo(() => Array.from(pinnedEvents).reverse(), [pinnedEvents]); + const useAuthentication = useMediaAuthentication(); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const { navigateRoom } = useRoomNavigate(); + const scrollRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: sortedPinnedEvent.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 75, + overscan: 4, + }); + + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) + ), + }), + [mx, room, mentionClickHandler] + ); + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + }), + [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication] + ); + + const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>( + { + [MessageEvent.RoomMessage]: (event, displayName, getContent) => { + if (event.isRedacted()) { + return ( + + ); + } + + return ( + + ); + }, + [MessageEvent.RoomMessageEncrypted]: (event, displayName) => { + const eventId = event.getId()!; + const evtTimeline = room.getTimelineForEvent(eventId); + + const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === eventId); + + if (!mEvent || !evtTimeline) { + return ( + + + {event.getType()} + {' event'} + + + ); + } + + return ( + + {() => { + if (mEvent.isRedacted()) return ; + if (mEvent.getType() === MessageEvent.Sticker) + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + if (mEvent.getType() === MessageEvent.RoomMessage) { + const editedEvent = getEditedEvent(eventId, mEvent, evtTimeline.getTimelineSet()); + const getContent = (() => + editedEvent?.getContent()['m.new_content'] ?? + mEvent.getContent()) as GetContentCallback; + + return ( + + ); + } + if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) + return ( + + + + ); + return ( + + + + ); + }} + + ); + }, + [MessageEvent.Sticker]: (event, displayName, getContent) => { + if (event.isRedacted()) { + return ( + + ); + } + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + }, + }, + undefined, + (event) => { + if (event.isRedacted()) { + return ; + } + return ( + + + {event.getType()} + {' event'} + + + ); + } + ); + + const handleOpen = (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + requestClose(); + }; + + return ( + + +
+ + Pinned Messages + + + + + + +
+ + + + {sortedPinnedEvent.length > 0 ? ( +
+ {virtualizer.getVirtualItems().map((vItem) => { + const eventId = sortedPinnedEvent[vItem.index]; + if (!eventId) return null; + + return ( + + + + + + ); + })} +
+ ) : ( + + + + + No Pinned Messages + + + Users with sufficient power level can pin a messages from its context menu. + + + + )} +
+
+
+
+
+ ); + } +); diff --git a/src/app/features/room/room-pin-menu/index.ts b/src/app/features/room/room-pin-menu/index.ts new file mode 100644 index 000000000..65ddaeea1 --- /dev/null +++ b/src/app/features/room/room-pin-menu/index.ts @@ -0,0 +1 @@ +export * from './RoomPinMenu'; diff --git a/src/app/hooks/useRoomEvent.ts b/src/app/hooks/useRoomEvent.ts new file mode 100644 index 000000000..3ca2449fa --- /dev/null +++ b/src/app/hooks/useRoomEvent.ts @@ -0,0 +1,56 @@ +import { MatrixEvent, Room } from 'matrix-js-sdk'; +import { useCallback, useMemo } from 'react'; +import to from 'await-to-js'; +import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; +import { useQuery } from '@tanstack/react-query'; +import { useMatrixClient } from './useMatrixClient'; + +const useFetchEvent = (room: Room, eventId: string) => { + const mx = useMatrixClient(); + + const fetchEventCallback = useCallback(async () => { + const evt = await mx.fetchRoomEvent(room.roomId, eventId); + const mEvent = new MatrixEvent(evt); + + if (mEvent.isEncrypted() && mx.getCrypto()) { + await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend)); + } + + return mEvent; + }, [mx, room.roomId, eventId]); + + return fetchEventCallback; +}; + +/** + * + * @param room + * @param eventId + * @returns `MatrixEvent`, `undefined` means loading, `null` means failure + */ +export const useRoomEvent = ( + room: Room, + eventId: string, + getLocally?: () => MatrixEvent | undefined +) => { + const event = useMemo(() => { + if (getLocally) return getLocally(); + return room.findEventById(eventId); + }, [room, eventId, getLocally]); + + const fetchEvent = useFetchEvent(room, eventId); + + const { data, error } = useQuery({ + enabled: event === undefined, + queryKey: [room.roomId, eventId], + queryFn: fetchEvent, + staleTime: Infinity, + gcTime: 60 * 60 * 1000, // 1hour + }); + + if (event) return event; + if (data) return data; + if (error) return null; + + return undefined; +}; diff --git a/src/app/hooks/useRoomPinnedEvents.ts b/src/app/hooks/useRoomPinnedEvents.ts new file mode 100644 index 000000000..9ab1d6bda --- /dev/null +++ b/src/app/hooks/useRoomPinnedEvents.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react'; +import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; +import { Room } from 'matrix-js-sdk'; +import { StateEvent } from '../../types/matrix/room'; +import { useStateEvent } from './useStateEvent'; + +export const useRoomPinnedEvents = (room: Room): string[] => { + const pinEvent = useStateEvent(room, StateEvent.RoomPinnedEvents); + const events = useMemo(() => { + const content = pinEvent?.getContent(); + return content?.pinned ?? []; + }, [pinEvent]); + + return events; +}; diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 64eabc995..0c832b094 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -427,7 +427,14 @@ function RoomNotificationsGroupComp({ userId={event.sender} src={ senderAvatarMxc - ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined + ? mxcUrlToHttp( + mx, + senderAvatarMxc, + useAuthentication, + 48, + 48, + 'crop' + ) ?? undefined : undefined } alt={displayName} @@ -459,7 +466,6 @@ function RoomNotificationsGroupComp({
{replyEventId && (