Pinned Messages (#2081)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Mon, 16 Dec 2024 10:55:15 +0000 (21:55 +1100)
committerGitHub <noreply@github.com>
Mon, 16 Dec 2024 10:55:15 +0000 (16:25 +0530)
* 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

14 files changed:
src/app/components/message/Reply.tsx
src/app/components/message/placeholder/CompactPlaceholder.tsx
src/app/components/message/placeholder/DefaultPlaceholder.tsx
src/app/components/message/placeholder/LinePlaceholder.css.ts
src/app/components/message/placeholder/LinePlaceholder.tsx
src/app/features/room/RoomTimeline.tsx
src/app/features/room/RoomViewHeader.tsx
src/app/features/room/message/Message.tsx
src/app/features/room/room-pin-menu/RoomPinMenu.css.ts [new file with mode: 0644]
src/app/features/room/room-pin-menu/RoomPinMenu.tsx [new file with mode: 0644]
src/app/features/room/room-pin-menu/index.ts [new file with mode: 0644]
src/app/hooks/useRoomEvent.ts [new file with mode: 0644]
src/app/hooks/useRoomPinnedEvents.ts [new file with mode: 0644]
src/app/pages/client/inbox/Notifications.tsx

index 82a9d91989c8d3e4b8abe96ff7870b45de96aef6..7687074e430498108fb626d70cc3a13b86284a69 100644 (file)
@@ -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<MatrixEvent | null | undefined>(
-    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() ? (
-    <MessageDeletedContent />
-  ) : (
-    <MessageFailedContent />
-  );
+    const fallbackBody = replyEvent?.isRedacted() ? (
+      <MessageDeletedContent />
+    ) : (
+      <MessageFailedContent />
+    );
 
-  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 (
-    <Box direction="Column" {...props} ref={ref}>
-      {threadRootId && (
-        <ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
-      )}
-      <ReplyLayout
-        as="button"
-        userColor={sender ? colorMXID(sender) : undefined}
-        username={
-          sender && (
+    return (
+      <Box direction="Column" {...props} ref={ref}>
+        {threadRootId && (
+          <ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
+        )}
+        <ReplyLayout
+          as="button"
+          userColor={sender ? colorMXID(sender) : undefined}
+          username={
+            sender && (
+              <Text size="T300" truncate>
+                <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
+              </Text>
+            )
+          }
+          data-event-id={replyEventId}
+          onClick={onClick}
+        >
+          {replyEvent !== undefined ? (
             <Text size="T300" truncate>
-              <b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
+              {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
             </Text>
-          )
-        }
-        data-event-id={replyEventId}
-        onClick={onClick}
-      >
-        {replyEvent !== undefined ? (
-          <Text size="T300" truncate>
-            {badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
-          </Text>
-        ) : (
-          <LinePlaceholder
-            style={{
-              backgroundColor: color.SurfaceVariant.ContainerActive,
-              maxWidth: toRem(placeholderWidth),
-              width: '100%',
-            }}
-          />
-        )}
-      </ReplyLayout>
-    </Box>
-  );
-});
+          ) : (
+            <LinePlaceholder
+              style={{
+                backgroundColor: color.SurfaceVariant.ContainerActive,
+                maxWidth: toRem(placeholderWidth),
+                width: '100%',
+              }}
+            />
+          )}
+        </ReplyLayout>
+      </Box>
+    );
+  }
+);
index a6be083ef86b1aaf9dc4102bc289107d120f8322..e6168ae3e1813350e254197b209b3c5b587131fb 100644 (file)
@@ -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) => (
-  <MessageBase>
-    <CompactLayout
-      {...props}
-      ref={ref}
-      before={
-        <>
-          <LinePlaceholder style={{ maxWidth: toRem(50) }} />
-          <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
-        </>
-      }
-    >
-      <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(120, 500)) }} />
-    </CompactLayout>
-  </MessageBase>
-));
+export const CompactPlaceholder = as<'div', { variant?: ContainerColor }>(
+  ({ variant, ...props }, ref) => {
+    const nameSize = useMemo(() => randomNumberBetween(40, 100), []);
+    const msgSize = useMemo(() => randomNumberBetween(120, 500), []);
+
+    return (
+      <CompactLayout
+        {...props}
+        ref={ref}
+        before={
+          <>
+            <LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} />
+            <LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} />
+          </>
+        }
+      >
+        <LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
+      </CompactLayout>
+    );
+  }
+);
index 5f0b57fae2bbe00831b5a1c1b009b0fa9905d239..725ac4b9f293f947fae040e5e41a2153d63bd47c 100644 (file)
@@ -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) => (
-  <MessageBase>
-    <ModernLayout {...props} ref={ref} before={<Avatar style={avatarBg} size="300" />}>
-      <Box style={contentMargin} grow="Yes" direction="Column" gap="200">
-        <Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
-          <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
-          <LinePlaceholder style={{ maxWidth: toRem(50) }} />
-        </Box>
-        <Box grow="Yes" gap="200" wrap="Wrap">
-          <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
-          <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
+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 (
+      <ModernLayout
+        {...props}
+        ref={ref}
+        before={
+          <Avatar
+            style={{ backgroundColor: color[variant ?? 'SurfaceVariant'].Container }}
+            size="300"
+          />
+        }
+      >
+        <Box style={contentMargin} grow="Yes" direction="Column" gap="200">
+          <Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
+            <LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} />
+            <LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} />
+          </Box>
+          <Box grow="Yes" gap="200" wrap="Wrap">
+            <LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
+            <LinePlaceholder variant={variant} style={{ maxWidth: toRem(msg2Size) }} />
+          </Box>
         </Box>
-      </Box>
-    </ModernLayout>
-  </MessageBase>
-));
+      </ModernLayout>
+    );
+  }
+);
index 0baedf6e17aa89fd23b264bfb1c112130bc8609b..34ad76a3e6d91f9b8c7585751c7579c30fb13b8b 100644 (file)
@@ -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<typeof LinePlaceholder>;
index a5e7bd75a20674d64e72d4d8d55c6ad89bf62098..58fc52c0e413499131e40843aa4a9cba4e6783ce 100644 (file)
@@ -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) => (
-  <Box className={classNames(css.LinePlaceholder, className)} shrink="No" {...props} ref={ref} />
-));
+export const LinePlaceholder = as<'div', css.LinePlaceholderVariants>(
+  ({ className, variant, ...props }, ref) => (
+    <Box
+      className={classNames(css.LinePlaceholder({ variant }), className)}
+      shrink="No"
+      {...props}
+      ref={ref}
+    />
+  )
+);
index a2738fcbf257c79411fba3e96823d7934f3444cc..63b3d3e2cdccd53f56eeb8b6286460183ff93a0d 100644 (file)
@@ -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<string>();
   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 && (
                 <Reply
-                  mx={mx}
                   room={room}
                   timelineSet={timelineSet}
                   replyEventId={replyEventId}
@@ -1055,6 +1057,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}
@@ -1065,7 +1068,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
             reply={
               replyEventId && (
                 <Reply
-                  mx={mx}
                   room={room}
                   timelineSet={timelineSet}
                   replyEventId={replyEventId}
@@ -1163,6 +1165,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
             highlight={highlighted}
             canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
             canSendReaction={canSendReaction}
+            canPinEvent={canPinEvent}
             imagePackRooms={imagePackRooms}
             relations={hasReactions ? reactionRelations : undefined}
             onUserClick={handleUserClick}
@@ -1551,17 +1554,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
           {(canPaginateBack || !rangeAtStart) &&
             (messageLayout === 1 ? (
               <>
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder ref={observeBackAnchor} />
+                <MessageBase>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase ref={observeBackAnchor}>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
               </>
             ) : (
               <>
-                <DefaultPlaceholder />
-                <DefaultPlaceholder />
-                <DefaultPlaceholder ref={observeBackAnchor} />
+                <MessageBase>
+                  <DefaultPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <DefaultPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase ref={observeBackAnchor}>
+                  <DefaultPlaceholder key={getItems().length} />
+                </MessageBase>
               </>
             ))}
 
@@ -1570,17 +1589,33 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
           {(!liveTimelineLinked || !rangeAtEnd) &&
             (messageLayout === 1 ? (
               <>
-                <CompactPlaceholder ref={observeFrontAnchor} />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
-                <CompactPlaceholder />
+                <MessageBase ref={observeFrontAnchor}>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <CompactPlaceholder key={getItems().length} />
+                </MessageBase>
               </>
             ) : (
               <>
-                <DefaultPlaceholder ref={observeFrontAnchor} />
-                <DefaultPlaceholder />
-                <DefaultPlaceholder />
+                <MessageBase ref={observeFrontAnchor}>
+                  <DefaultPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <DefaultPlaceholder key={getItems().length} />
+                </MessageBase>
+                <MessageBase>
+                  <DefaultPlaceholder key={getItems().length} />
+                </MessageBase>
               </>
             ))}
           <span ref={atBottomAnchorRef} />
index ae80deb6e1b1fea114fa95285103cd4f1082dd47..7ee1d302956b3a216dbe9f14249a8a9f595d8266 100644 (file)
@@ -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<RectCords>();
+  const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
   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<HTMLButtonElement> = (evt) => {
+    setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
+  };
+
   return (
     <PageHeader balance={screenSize === ScreenSize.Mobile}>
       <Box grow="Yes" gap="300">
@@ -297,6 +308,62 @@ export function RoomViewHeader() {
               )}
             </TooltipProvider>
           )}
+          <TooltipProvider
+            position="Bottom"
+            offset={4}
+            tooltip={
+              <Tooltip>
+                <Text>Pinned Messages</Text>
+              </Tooltip>
+            }
+          >
+            {(triggerRef) => (
+              <IconButton
+                style={{ position: 'relative' }}
+                onClick={handleOpenPinMenu}
+                ref={triggerRef}
+                aria-pressed={!!pinMenuAnchor}
+              >
+                {pinnedEvents.length > 0 && (
+                  <Badge
+                    style={{
+                      position: 'absolute',
+                      left: toRem(3),
+                      top: toRem(3),
+                    }}
+                    variant="Secondary"
+                    size="400"
+                    fill="Solid"
+                    radii="Pill"
+                  >
+                    <Text as="span" size="L400">
+                      {pinnedEvents.length}
+                    </Text>
+                  </Badge>
+                )}
+                <Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
+              </IconButton>
+            )}
+          </TooltipProvider>
+          <PopOut
+            anchor={pinMenuAnchor}
+            position="Bottom"
+            content={
+              <FocusTrap
+                focusTrapOptions={{
+                  initialFocus: false,
+                  returnFocusOnDeactivate: false,
+                  onDeactivate: () => setPinMenuAnchor(undefined),
+                  clickOutsideDeactivates: true,
+                  isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+                  isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+                  escapeDeactivates: stopPropagation,
+                }}
+              >
+                <RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
+              </FocusTrap>
+            }
+          />
           {screenSize === ScreenSize.Desktop && (
             <TooltipProvider
               position="Bottom"
index e9a2d79730109222a420c0ec8e65fd5e93371c83..21b186422dee13830f289d92172e887ce0cee781 100644 (file)
@@ -35,6 +35,7 @@ import { useHover, useFocusWithin } from 'react-aria';
 import { MatrixEvent, Room } from 'matrix-js-sdk';
 import { Relations } from 'matrix-js-sdk/lib/models/relations';
 import classNames from 'classnames';
+import { EventType, RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
 import {
   AvatarBase,
   BubbleLayout,
@@ -51,7 +52,12 @@ import {
   getMemberAvatarMxc,
   getMemberDisplayName,
 } from '../../../utils/room';
-import { getCanonicalAliasOrRoomId, getMxIdLocalPart, isRoomAlias, mxcUrlToHttp } from '../../../utils/matrix';
+import {
+  getCanonicalAliasOrRoomId,
+  getMxIdLocalPart,
+  isRoomAlias,
+  mxcUrlToHttp,
+} from '../../../utils/matrix';
 import { MessageLayout, MessageSpacing } from '../../../state/settings';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
 import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
@@ -68,6 +74,8 @@ import { stopPropagation } from '../../../utils/keyboard';
 import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
 import { getViaServers } from '../../../plugins/via-servers';
 import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
+import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
+import { StateEvent } from '../../../../types/matrix/room';
 
 export type ReactionHandler = (keyOrMxc: string, shortcode: string) => 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 (
+    <MenuItem
+      size="300"
+      after={<Icon size="100" src={Icons.Pin} />}
+      radii="300"
+      onClick={handlePin}
+      {...props}
+      ref={ref}
+    >
+      <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+        {isPinned ? 'Unpin Message' : 'Pin Message'}
+      </Text>
+    </MenuItem>
+  );
+});
+
 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>(
                           />
                           <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
                           <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
+                          {canPinEvent && (
+                            <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
+                          )}
                         </Box>
                         {((!mEvent.isRedacted() && canDelete) ||
                           mEvent.getSender() !== mx.getUserId()) && (
-                            <>
-                              <Line size="300" />
-                              <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
-                                {!mEvent.isRedacted() && canDelete && (
-                                  <MessageDeleteItem
-                                    room={room}
-                                    mEvent={mEvent}
-                                    onClose={closeMenu}
-                                  />
-                                )}
-                                {mEvent.getSender() !== mx.getUserId() && (
-                                  <MessageReportItem
-                                    room={room}
-                                    mEvent={mEvent}
-                                    onClose={closeMenu}
-                                  />
-                                )}
-                              </Box>
-                            </>
-                          )}
+                          <>
+                            <Line size="300" />
+                            <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                              {!mEvent.isRedacted() && canDelete && (
+                                <MessageDeleteItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                              {mEvent.getSender() !== mx.getUserId() && (
+                                <MessageReportItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                            </Box>
+                          </>
+                        )}
                       </Menu>
                     </FocusTrap>
                   }
@@ -1095,26 +1148,26 @@ export const Event = as<'div', EventProps>(
                         </Box>
                         {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
                           (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
-                            <>
-                              <Line size="300" />
-                              <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
-                                {!mEvent.isRedacted() && canDelete && (
-                                  <MessageDeleteItem
-                                    room={room}
-                                    mEvent={mEvent}
-                                    onClose={closeMenu}
-                                  />
-                                )}
-                                {mEvent.getSender() !== mx.getUserId() && (
-                                  <MessageReportItem
-                                    room={room}
-                                    mEvent={mEvent}
-                                    onClose={closeMenu}
-                                  />
-                                )}
-                              </Box>
-                            </>
-                          )}
+                          <>
+                            <Line size="300" />
+                            <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+                              {!mEvent.isRedacted() && canDelete && (
+                                <MessageDeleteItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                              {mEvent.getSender() !== mx.getUserId() && (
+                                <MessageReportItem
+                                  room={room}
+                                  mEvent={mEvent}
+                                  onClose={closeMenu}
+                                />
+                              )}
+                            </Box>
+                          </>
+                        )}
                       </Menu>
                     </FocusTrap>
                   }
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 (file)
index 0000000..9b0269b
--- /dev/null
@@ -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 (file)
index 0000000..c3d259a
--- /dev/null
@@ -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<RoomPinnedEventsEventContent>() ?? { 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 = () => (
+    <Box shrink="No" gap="200" alignItems="Center">
+      <Chip data-event-id={eventId} onClick={handleOpenClick} variant="Secondary" radii="Pill">
+        <Text size="T200">Open</Text>
+      </Chip>
+      {canPinEvent && (
+        <IconButton
+          data-event-id={eventId}
+          variant="Secondary"
+          size="300"
+          radii="Pill"
+          onClick={unpinState.status === AsyncStatus.Loading ? undefined : handleUnpinClick}
+          aria-disabled={unpinState.status === AsyncStatus.Loading}
+        >
+          {unpinState.status === AsyncStatus.Loading ? (
+            <Spinner size="100" />
+          ) : (
+            <Icon src={Icons.Cross} size="100" />
+          )}
+        </IconButton>
+      )}
+    </Box>
+  );
+
+  if (pinnedEvent === undefined) return <DefaultPlaceholder variant="Secondary" />;
+  if (pinnedEvent === null)
+    return (
+      <Box gap="300" justifyContent="SpaceBetween" alignItems="Center">
+        <Box>
+          <Text style={{ color: color.Critical.Main }}>Failed to load message!</Text>
+        </Box>
+        {renderOptions()}
+      </Box>
+    );
+
+  const sender = pinnedEvent.getSender()!;
+  const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
+  const senderAvatarMxc = getMemberAvatarMxc(room, sender);
+  const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
+  return (
+    <ModernLayout
+      before={
+        <AvatarBase>
+          <Avatar size="300">
+            <UserAvatar
+              userId={sender}
+              src={
+                senderAvatarMxc
+                  ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
+                    undefined
+                  : undefined
+              }
+              alt={displayName}
+              renderFallback={() => <Icon size="200" src={Icons.User} filled />}
+            />
+          </Avatar>
+        </AvatarBase>
+      }
+    >
+      <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
+        <Box gap="200" alignItems="Baseline">
+          <Username style={{ color: colorMXID(sender) }}>
+            <Text as="span" truncate>
+              <b>{displayName}</b>
+            </Text>
+          </Username>
+          <Time ts={pinnedEvent.getTs()} />
+        </Box>
+        {renderOptions()}
+      </Box>
+      {pinnedEvent.replyEventId && (
+        <Reply
+          room={room}
+          replyEventId={pinnedEvent.replyEventId}
+          threadRootId={pinnedEvent.threadRootId}
+          onClick={handleOpenClick}
+        />
+      )}
+      {renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)}
+    </ModernLayout>
+  );
+}
+
+type RoomPinMenuProps = {
+  room: Room;
+  requestClose: () => void;
+};
+export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
+  ({ 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<HTMLDivElement>(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<LinkifyOpts>(
+      () => ({
+        ...LINKIFY_OPTS,
+        render: factoryRenderLinkifyWithMention((href) =>
+          renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
+        ),
+      }),
+      [mx, room, mentionClickHandler]
+    );
+    const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
+      () =>
+        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 (
+              <RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />
+            );
+          }
+
+          return (
+            <RenderMessageContent
+              displayName={displayName}
+              msgType={event.getContent().msgtype ?? ''}
+              ts={event.getTs()}
+              getContent={getContent}
+              mediaAutoLoad={mediaAutoLoad}
+              urlPreview={urlPreview}
+              htmlReactParserOptions={htmlReactParserOptions}
+              linkifyOpts={linkifyOpts}
+              outlineAttachment
+            />
+          );
+        },
+        [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 (
+              <Box grow="Yes" direction="Column">
+                <Text size="T400" priority="300">
+                  <code className={customHtmlCss.Code}>{event.getType()}</code>
+                  {' event'}
+                </Text>
+              </Box>
+            );
+          }
+
+          return (
+            <EncryptedContent mEvent={mEvent}>
+              {() => {
+                if (mEvent.isRedacted()) return <RedactedContent />;
+                if (mEvent.getType() === MessageEvent.Sticker)
+                  return (
+                    <MSticker
+                      content={mEvent.getContent()}
+                      renderImageContent={(props) => (
+                        <ImageContent
+                          {...props}
+                          autoPlay={mediaAutoLoad}
+                          renderImage={(p) => <Image {...p} loading="lazy" />}
+                          renderViewer={(p) => <ImageViewer {...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 (
+                    <RenderMessageContent
+                      displayName={displayName}
+                      msgType={mEvent.getContent().msgtype ?? ''}
+                      ts={mEvent.getTs()}
+                      edited={!!editedEvent}
+                      getContent={getContent}
+                      mediaAutoLoad={mediaAutoLoad}
+                      urlPreview={urlPreview}
+                      htmlReactParserOptions={htmlReactParserOptions}
+                      linkifyOpts={linkifyOpts}
+                    />
+                  );
+                }
+                if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
+                  return (
+                    <Text>
+                      <MessageNotDecryptedContent />
+                    </Text>
+                  );
+                return (
+                  <Text>
+                    <MessageUnsupportedContent />
+                  </Text>
+                );
+              }}
+            </EncryptedContent>
+          );
+        },
+        [MessageEvent.Sticker]: (event, displayName, getContent) => {
+          if (event.isRedacted()) {
+            return (
+              <RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />
+            );
+          }
+          return (
+            <MSticker
+              content={getContent()}
+              renderImageContent={(props) => (
+                <ImageContent
+                  {...props}
+                  autoPlay={mediaAutoLoad}
+                  renderImage={(p) => <Image {...p} loading="lazy" />}
+                  renderViewer={(p) => <ImageViewer {...p} />}
+                />
+              )}
+            />
+          );
+        },
+      },
+      undefined,
+      (event) => {
+        if (event.isRedacted()) {
+          return <RedactedContent reason={event.getUnsigned().redacted_because?.content.reason} />;
+        }
+        return (
+          <Box grow="Yes" direction="Column">
+            <Text size="T400" priority="300">
+              <code className={customHtmlCss.Code}>{event.getType()}</code>
+              {' event'}
+            </Text>
+          </Box>
+        );
+      }
+    );
+
+    const handleOpen = (roomId: string, eventId: string) => {
+      navigateRoom(roomId, eventId);
+      requestClose();
+    };
+
+    return (
+      <Menu ref={ref} className={css.PinMenu}>
+        <Box grow="Yes" direction="Column">
+          <Header className={css.PinMenuHeader} size="500">
+            <Box grow="Yes">
+              <Text size="H5">Pinned Messages</Text>
+            </Box>
+            <Box shrink="No">
+              <IconButton size="300" onClick={requestClose} radii="300">
+                <Icon src={Icons.Cross} size="400" />
+              </IconButton>
+            </Box>
+          </Header>
+          <Box grow="Yes">
+            <Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
+              <Box className={css.PinMenuContent} direction="Column" gap="100">
+                {sortedPinnedEvent.length > 0 ? (
+                  <div
+                    style={{
+                      position: 'relative',
+                      height: virtualizer.getTotalSize(),
+                    }}
+                  >
+                    {virtualizer.getVirtualItems().map((vItem) => {
+                      const eventId = sortedPinnedEvent[vItem.index];
+                      if (!eventId) return null;
+
+                      return (
+                        <VirtualTile
+                          virtualItem={vItem}
+                          style={{ paddingBottom: config.space.S200 }}
+                          ref={virtualizer.measureElement}
+                          key={vItem.index}
+                        >
+                          <SequenceCard
+                            style={{ padding: config.space.S400, borderRadius: config.radii.R300 }}
+                            variant="SurfaceVariant"
+                            direction="Column"
+                          >
+                            <PinnedMessage
+                              room={room}
+                              eventId={eventId}
+                              renderContent={renderMatrixEvent}
+                              onOpen={handleOpen}
+                              canPinEvent={canPinEvent}
+                            />
+                          </SequenceCard>
+                        </VirtualTile>
+                      );
+                    })}
+                  </div>
+                ) : (
+                  <Box
+                    className={ContainerColor({ variant: 'SurfaceVariant' })}
+                    style={{
+                      marginBottom: config.space.S200,
+                      padding: `${config.space.S700} ${config.space.S400} ${toRem(60)}`,
+                      borderRadius: config.radii.R300,
+                    }}
+                    grow="Yes"
+                    direction="Column"
+                    gap="400"
+                    justifyContent="Center"
+                    alignItems="Center"
+                  >
+                    <Icon src={Icons.Pin} size="600" />
+                    <Box
+                      style={{ maxWidth: toRem(300) }}
+                      direction="Column"
+                      gap="200"
+                      alignItems="Center"
+                    >
+                      <Text size="H4" align="Center">
+                        No Pinned Messages
+                      </Text>
+                      <Text size="T400" align="Center">
+                        Users with sufficient power level can pin a messages from its context menu.
+                      </Text>
+                    </Box>
+                  </Box>
+                )}
+              </Box>
+            </Scroll>
+          </Box>
+        </Box>
+      </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 (file)
index 0000000..65ddaee
--- /dev/null
@@ -0,0 +1 @@
+export * from './RoomPinMenu';
diff --git a/src/app/hooks/useRoomEvent.ts b/src/app/hooks/useRoomEvent.ts
new file mode 100644 (file)
index 0000000..3ca2449
--- /dev/null
@@ -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 (file)
index 0000000..9ab1d6b
--- /dev/null
@@ -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<RoomPinnedEventsEventContent>();
+    return content?.pinned ?? [];
+  }, [pinEvent]);
+
+  return events;
+};
index 64eabc99544171861968dd49dd55a0e5667c7a21..0c832b0946cd26833780929580e7e08ba4f46fdd 100644 (file)
@@ -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({
                 </Box>
                 {replyEventId && (
                   <Reply
-                    mx={mx}
                     room={room}
                     replyEventId={replyEventId}
                     threadRootId={threadRootId}