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';
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;
));
type ReplyProps = {
- mx: MatrixClient;
room: Room;
timelineSet?: EventTimelineSet | undefined;
replyEventId: string;
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>
+ );
+ }
+);
-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>
+ );
+ }
+);
-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>
+ );
+ }
+);
-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>;
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}
+ />
+ )
+);
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);
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
+ canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
reply={
replyEventId && (
<Reply
- mx={mx}
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
+ canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
reply={
replyEventId && (
<Reply
- mx={mx}
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
highlight={highlighted}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
+ canPinEvent={canPinEvent}
imagePackRooms={imagePackRooms}
relations={hasReactions ? reactionRelations : undefined}
onUserClick={handleUserClick}
{(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>
</>
))}
{(!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} />
Line,
PopOut,
RectCords,
+ Badge,
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { JoinRule, Room } from 'matrix-js-sdk';
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;
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');
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
+ const handleOpenPinMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
+ };
+
return (
<PageHeader balance={screenSize === ScreenSize.Mobile}>
<Box grow="Yes" gap="300">
)}
</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"
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,
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';
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;
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 => {
);
});
+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',
{
edit?: boolean;
canDelete?: boolean;
canSendReaction?: boolean;
+ canPinEvent?: boolean;
imagePackRooms?: Room[];
relations?: Relations;
messageLayout: MessageLayout;
edit,
canDelete,
canSendReaction,
+ canPinEvent,
imagePackRooms,
relations,
messageLayout,
/>
<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>
}
</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>
}
--- /dev/null
+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,
+});
--- /dev/null
+/* 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>
+ );
+ }
+);
--- /dev/null
+export * from './RoomPinMenu';
--- /dev/null
+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;
+};
--- /dev/null
+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;
+};
userId={event.sender}
src={
senderAvatarMxc
- ? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
+ ? mxcUrlToHttp(
+ mx,
+ senderAvatarMxc,
+ useAuthentication,
+ 48,
+ 48,
+ 'crop'
+ ) ?? undefined
: undefined
}
alt={displayName}
</Box>
{replyEventId && (
<Reply
- mx={mx}
room={room}
replyEventId={replyEventId}
threadRootId={threadRootId}