thread menu - WIP

This commit is contained in:
Ajay Bura 2025-09-15 13:16:43 +05:30
parent e6f4eeca8e
commit 154f234d0c
4 changed files with 521 additions and 0 deletions

View file

@ -379,6 +379,44 @@ export function RoomViewHeader() {
</IconButton>
)}
</TooltipProvider>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Threads</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.Thread} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"

View file

@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const ThreadsMenu = style({
display: 'flex',
maxWidth: toRem(548),
width: '100vw',
maxHeight: '90vh',
});
export const ThreadsMenuHeader = style({
paddingLeft: config.space.S400,
paddingRight: config.space.S200,
});
export const ThreadsMenuContent = style({
paddingLeft: config.space.S200,
});

View file

@ -0,0 +1,456 @@
/* eslint-disable react/destructuring-assignment */
import React, { forwardRef, MouseEventHandler, useMemo, useRef } from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
Chip,
color,
config,
Header,
Icon,
IconButton,
Icons,
Menu,
Scroll,
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 './ThreadsMenu.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,
UsernameBold,
} from '../../../components/message';
import { UserAvatar } from '../../../components/user-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
import { GetContentCallback, MessageEvent } from '../../../../types/matrix/room';
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 { ContainerColor } from '../../../styles/ContainerColor.css';
import {
getTagIconSrc,
useAccessibleTagColors,
usePowerLevelTags,
} from '../../../hooks/usePowerLevelTags';
import { useTheme } from '../../../hooks/useTheme';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { useIsDirectRoom } from '../../../hooks/useRoom';
type ThreadsProps = {
room: Room;
eventId: string;
renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>;
onOpen: (roomId: string, eventId: string) => void;
};
function Threads({ room, eventId, renderContent, onOpen }: ThreadsProps) {
const pinnedEvent = useRoomEvent(room, eventId);
const useAuthentication = useMediaAuthentication();
const mx = useMatrixClient();
const direct = useIsDirectRoom();
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const powerLevels = usePowerLevelsContext();
const { getPowerLevel } = usePowerLevelsAPI(powerLevels);
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
const handleOpenClick: MouseEventHandler = (evt) => {
evt.stopPropagation();
const evtId = evt.currentTarget.getAttribute('data-event-id');
if (!evtId) return;
onOpen(room.roomId, evtId);
};
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>
</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;
const senderPowerLevel = getPowerLevel(sender);
const powerLevelTag = getPowerLevelTag(senderPowerLevel);
const tagColor = powerLevelTag?.color ? accessibleTagColors?.get(powerLevelTag.color) : undefined;
const tagIconSrc = powerLevelTag?.icon
? getTagIconSrc(mx, useAuthentication, powerLevelTag.icon)
: undefined;
const usernameColor = legacyUsernameColor || direct ? colorMXID(sender) : tagColor;
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">
<Box alignItems="Center" gap="200">
<Username style={{ color: usernameColor }}>
<Text as="span" truncate>
<UsernameBold>{displayName}</UsernameBold>
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
<Time ts={pinnedEvent.getTs()} />
</Box>
{renderOptions()}
</Box>
{pinnedEvent.replyEventId && (
<Reply
room={room}
replyEventId={pinnedEvent.replyEventId}
threadRootId={pinnedEvent.threadRootId}
onClick={handleOpenClick}
getPowerLevel={getPowerLevel}
getPowerLevelTag={getPowerLevelTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/>
)}
{renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)}
</ModernLayout>
);
}
type ThreadsMenuProps = {
room: Room;
requestClose: () => void;
};
export const ThreadsMenu = forwardRef<HTMLDivElement, ThreadsMenuProps>(
({ room, requestClose }, ref) => {
const mx = useMatrixClient();
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}
edited={!!event.replacingEvent()}
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 || !!mEvent.replacingEvent()}
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.ThreadsMenu}>
<Box grow="Yes" direction="Column">
<Header className={css.ThreadsMenuHeader} size="500">
<Box grow="Yes">
<Text size="H5">Threads</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.ThreadsMenuContent} 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"
>
<Threads
room={room}
eventId={eventId}
renderContent={renderMatrixEvent}
onOpen={handleOpen}
/>
</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.Thread} size="600" />
<Box
style={{ maxWidth: toRem(300) }}
direction="Column"
gap="200"
alignItems="Center"
>
<Text size="H4" align="Center">
No Threads
</Text>
<Text size="T400" align="Center">
This room does not have any threads yet.
</Text>
</Box>
</Box>
)}
</Box>
</Scroll>
</Box>
</Box>
</Menu>
);
}
);

View file

@ -0,0 +1,9 @@
// import { Room } from 'matrix-js-sdk';
// import { useMatrixClient } from './useMatrixClient';
// export const useRoomThreads = (room: Room) => {
// const mx = useMatrixClient();
// mx.createThreadListMessagesRequest;
// mx.processThreadRoots;
// };