This commit is contained in:
Ajay Bura 2025-10-22 10:52:47 +00:00 committed by GitHub
commit 486fe5b806
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 968 additions and 76 deletions

View file

@ -0,0 +1,6 @@
import { style } from '@vanilla-extract/css';
export const Time = style({
fontWeight: 'inherit',
flexShrink: 0,
});

View file

@ -1,12 +1,15 @@
import React, { ComponentProps } from 'react'; import React, { ComponentProps } from 'react';
import { Text, as } from 'folds'; import { Text, as } from 'folds';
import classNames from 'classnames';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time'; import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
import * as css from './Time.css';
export type TimeProps = { export type TimeProps = {
compact?: boolean; compact?: boolean;
ts: number; ts: number;
hour24Clock: boolean; hour24Clock: boolean;
dateFormatString: string; dateFormatString: string;
inheritPriority?: boolean;
}; };
/** /**
@ -22,7 +25,7 @@ export type TimeProps = {
* @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time. * @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time.
*/ */
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>( export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => { ({ compact, hour24Clock, dateFormatString, ts, inheritPriority, className, ...props }, ref) => {
const formattedTime = timeHourMinute(ts, hour24Clock); const formattedTime = timeHourMinute(ts, hour24Clock);
let time = ''; let time = '';
@ -33,11 +36,18 @@ export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
} else if (yesterday(ts)) { } else if (yesterday(ts)) {
time = `Yesterday ${formattedTime}`; time = `Yesterday ${formattedTime}`;
} else { } else {
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`; time = `${timeDayMonYear(ts, dateFormatString)}, ${formattedTime}`;
} }
return ( return (
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}> <Text
as="time"
className={classNames(css.Time, className)}
size="T200"
priority={inheritPriority ? undefined : '300'}
{...props}
ref={ref}
>
{time} {time}
</Text> </Text>
); );

View file

@ -77,6 +77,7 @@ import {
decryptAllTimelineEvent, decryptAllTimelineEvent,
getEditedEvent, getEditedEvent,
getEventReactions, getEventReactions,
getEventThreadDetail,
getLatestEditableEvt, getLatestEditableEvt,
getMemberDisplayName, getMemberDisplayName,
getReactionContent, getReactionContent,
@ -126,6 +127,17 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { ThreadSelector, ThreadSelectorContainer } from './message/thread-selector';
import {
getEventIdAbsoluteIndex,
getFirstLinkedTimeline,
getLinkedTimelines,
getTimelineAndBaseIndex,
getTimelineEvent,
getTimelineRelativeIndex,
getTimelinesEventsCount,
timelineToEventsCount,
} from './utils';
const TimelineFloat = as<'div', css.TimelineFloatVariants>( const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => ( ({ position, className, ...props }, ref) => (
@ -150,79 +162,6 @@ const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
) )
); );
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
const timelineSet = room.getUnfilteredTimelineSet();
return timelineSet.getTimelineForEvent(eventId) ?? undefined;
};
export const getFirstLinkedTimeline = (
timeline: EventTimeline,
direction: Direction
): EventTimeline => {
const linkedTm = timeline.getNeighbouringTimeline(direction);
if (!linkedTm) return timeline;
return getFirstLinkedTimeline(linkedTm, direction);
};
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
const timelines: EventTimeline[] = [];
for (
let nextTimeline: EventTimeline | null = firstTimeline;
nextTimeline;
nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
) {
timelines.push(nextTimeline);
}
return timelines;
};
export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
count + timelineToEventsCount(tm);
return timelines.reduce(timelineEventCountReducer, 0);
};
export const getTimelineAndBaseIndex = (
timelines: EventTimeline[],
index: number
): [EventTimeline | undefined, number] => {
let uptoTimelineLen = 0;
const timeline = timelines.find((t) => {
uptoTimelineLen += t.getEvents().length;
if (index < uptoTimelineLen) return true;
return false;
});
if (!timeline) return [undefined, 0];
return [timeline, uptoTimelineLen - timeline.getEvents().length];
};
export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
absoluteIndex - timelineBaseIndex;
export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
timeline.getEvents()[index];
export const getEventIdAbsoluteIndex = (
timelines: EventTimeline[],
eventTimeline: EventTimeline,
eventId: string
): number | undefined => {
const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
if (timelineIndex === -1) return undefined;
const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
if (eventIndex === -1) return undefined;
const baseIndex = timelines
.slice(0, timelineIndex)
.reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
return baseIndex + eventIndex;
};
type RoomTimelineProps = { type RoomTimelineProps = {
room: Room; room: Room;
eventId?: string; eventId?: string;
@ -232,6 +171,14 @@ type RoomTimelineProps = {
const PAGINATION_LIMIT = 80; const PAGINATION_LIMIT = 80;
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
const timelineSet = room.getUnfilteredTimelineSet();
return timelineSet.getTimelineForEvent(eventId) ?? undefined;
};
type Timeline = { type Timeline = {
linkedTimelines: EventTimeline[]; linkedTimelines: EventTimeline[];
range: ItemRange; range: ItemRange;
@ -1034,6 +981,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderDisplayName = const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const threadDetail = getEventThreadDetail(mEvent);
return ( return (
<Message <Message
@ -1107,6 +1055,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === MessageLayout.Bubble}
/> />
)} )}
{threadDetail && (
<ThreadSelectorContainer>
<ThreadSelector
room={room}
threadDetail={threadDetail}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
outlined={messageLayout === MessageLayout.Bubble}
/>
</ThreadSelectorContainer>
)}
</Message> </Message>
); );
}, },

View file

@ -69,6 +69,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ThreadsMenu } from './threads-menu';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@ -263,6 +264,7 @@ export function RoomViewHeader() {
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>(); const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const [threadsMenuAnchor, setThreadsMenuAnchor] = useState<RectCords>();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const pinnedEvents = useRoomPinnedEvents(room); const pinnedEvents = useRoomPinnedEvents(room);
@ -295,6 +297,10 @@ export function RoomViewHeader() {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
const handleOpenThreadsMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setThreadsMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return ( return (
<PageHeader balance={screenSize === ScreenSize.Mobile}> <PageHeader balance={screenSize === ScreenSize.Mobile}>
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
@ -424,6 +430,27 @@ export function RoomViewHeader() {
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>My Threads</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
style={{ position: 'relative' }}
onClick={handleOpenThreadsMenu}
ref={triggerRef}
aria-pressed={!!threadsMenuAnchor}
>
<Icon size="400" src={Icons.Thread} filled={!!threadsMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut <PopOut
anchor={pinMenuAnchor} anchor={pinMenuAnchor}
position="Bottom" position="Bottom"
@ -443,6 +470,25 @@ export function RoomViewHeader() {
</FocusTrap> </FocusTrap>
} }
/> />
<PopOut
anchor={threadsMenuAnchor}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setThreadsMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<ThreadsMenu room={room} requestClose={() => setThreadsMenuAnchor(undefined)} />
</FocusTrap>
}
/>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"

View file

@ -0,0 +1,78 @@
import { Box, Icon, Icons, Line, Text } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { IThreadBundledRelationship, Room } from 'matrix-js-sdk';
import * as css from './styles.css';
import { getMemberDisplayName } from '../../../../utils/room';
import { getMxIdLocalPart } from '../../../../utils/matrix';
import { Time } from '../../../../components/message';
export function ThreadSelectorContainer({ children }: { children: ReactNode }) {
return <Box className={css.ThreadSelectorContainer}>{children}</Box>;
}
type ThreadSelectorProps = {
room: Room;
threadDetail: IThreadBundledRelationship;
outlined?: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
export function ThreadSelector({
room,
threadDetail,
outlined,
hour24Clock,
dateFormatString,
}: ThreadSelectorProps) {
const latestEvent = threadDetail.latest_event;
const latestSenderId = latestEvent.sender;
const latestDisplayName =
getMemberDisplayName(room, latestSenderId) ??
getMxIdLocalPart(latestSenderId) ??
latestSenderId;
const latestEventTs = latestEvent.origin_server_ts;
return (
<Box
as="button"
type="button"
className={classNames(css.ThreadSelector, outlined && css.ThreadSectorOutlined)}
alignItems="Center"
gap="300"
>
<Box className={css.ThreadRepliesCount} shrink="No" alignItems="Center" gap="200">
<Icon size="100" src={Icons.Thread} filled />
<Text size="L400">
{threadDetail.count} {threadDetail.count === 1 ? 'Reply' : 'Replies'}
</Text>
</Box>
{latestSenderId && (
<>
<Line
className={css.ThreadSelectorDivider}
direction="Vertical"
variant="SurfaceVariant"
/>
<Box gap="200" alignItems="Inherit">
<Text size="T200" truncate>
<span>Last reply by </span>
<b>{latestDisplayName}</b>
<span> </span>
<Time
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
ts={latestEventTs}
inheritPriority
/>
</Text>
<Icon size="100" src={Icons.ChevronRight} />
</Box>
</>
)}
</Box>
);
}

View file

@ -0,0 +1 @@
export * from './ThreadSelector';

View file

@ -0,0 +1,37 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { ContainerColor } from '../../../../styles/ContainerColor.css';
export const ThreadSelectorContainer = style({
marginTop: config.space.S200,
});
export const ThreadSelector = style([
ContainerColor({ variant: 'SurfaceVariant' }),
{
padding: config.space.S200,
borderRadius: config.radii.R400,
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: color.SurfaceVariant.ContainerHover,
},
'&:active': {
backgroundColor: color.SurfaceVariant.ContainerActive,
},
},
},
]);
export const ThreadSectorOutlined = style({
borderWidth: config.borderWidth.B300,
});
export const ThreadSelectorDivider = style({
height: toRem(16),
});
export const ThreadRepliesCount = style({
color: color.Primary.Main,
});

View file

@ -0,0 +1,32 @@
import React from 'react';
import { Box, Icon, Icons, toRem, Text, config } from 'folds';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { BreakWord } from '../../../styles/Text.css';
export function ThreadsError({ error }: { error: Error }) {
return (
<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.Warning} size="600" />
<Box style={{ maxWidth: toRem(300) }} direction="Column" gap="200" alignItems="Center">
<Text size="H4" align="Center">
{error.name}
</Text>
<Text className={BreakWord} size="T400" align="Center">
{error.message}
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,23 @@
import { Box, config, Spinner } from 'folds';
import React from 'react';
import { ContainerColor } from '../../../styles/ContainerColor.css';
export function ThreadsLoading() {
return (
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
marginBottom: config.space.S200,
padding: config.space.S700,
borderRadius: config.radii.R300,
}}
grow="Yes"
direction="Column"
gap="400"
justifyContent="Center"
alignItems="Center"
>
<Spinner variant="Secondary" />
</Box>
);
}

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,99 @@
/* eslint-disable react/destructuring-assignment */
import React, { forwardRef, useMemo } from 'react';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import { Box, config, Header, Icon, IconButton, Icons, Menu, Scroll, Text, toRem } from 'folds';
import * as css from './ThreadsMenu.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { useRoomMyThreads } from '../../../hooks/useRoomThreads';
import { AsyncStatus } from '../../../hooks/useAsyncCallback';
import { getLinkedTimelines, getTimelinesEventsCount } from '../utils';
import { ThreadsTimeline } from './ThreadsTimeline';
import { ThreadsLoading } from './ThreadsLoading';
import { ThreadsError } from './ThreadsError';
const getTimelines = (timelineSet: EventTimelineSet) => {
const liveTimeline = timelineSet.getLiveTimeline();
const linkedTimelines = getLinkedTimelines(liveTimeline);
return linkedTimelines;
};
function NoThreads() {
return (
<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 Yet
</Text>
<Text size="T400" align="Center">
Threads youre participating in will appear here.
</Text>
</Box>
</Box>
);
}
type ThreadsMenuProps = {
room: Room;
requestClose: () => void;
};
export const ThreadsMenu = forwardRef<HTMLDivElement, ThreadsMenuProps>(
({ room, requestClose }, ref) => {
const threadsState = useRoomMyThreads(room);
const threadsTimelineSet =
threadsState.status === AsyncStatus.Success ? threadsState.data : undefined;
const linkedTimelines = useMemo(() => {
if (!threadsTimelineSet) return undefined;
return getTimelines(threadsTimelineSet);
}, [threadsTimelineSet]);
const hasEvents = linkedTimelines && getTimelinesEventsCount(linkedTimelines) > 0;
return (
<Menu ref={ref} className={css.ThreadsMenu}>
<Box grow="Yes" direction="Column">
<Header className={css.ThreadsMenuHeader} size="500">
<Box grow="Yes">
<Text size="H5">My 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">
{threadsState.status === AsyncStatus.Success && hasEvents ? (
<ThreadsTimeline timelines={linkedTimelines} requestClose={requestClose} />
) : (
<Scroll size="300" hideTrack visibility="Hover">
<Box className={css.ThreadsMenuContent} direction="Column" gap="100">
{(threadsState.status === AsyncStatus.Loading ||
threadsState.status === AsyncStatus.Idle) && <ThreadsLoading />}
{threadsState.status === AsyncStatus.Success && !hasEvents && <NoThreads />}
{threadsState.status === AsyncStatus.Error && (
<ThreadsError error={threadsState.error} />
)}
</Box>
</Scroll>
)}
</Box>
</Box>
</Menu>
);
}
);

View file

@ -0,0 +1,481 @@
/* eslint-disable react/destructuring-assignment */
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Direction, EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk';
import { Avatar, Box, Chip, config, Icon, Icons, Scroll, Text } from 'folds';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { HTMLReactParserOptions } from 'html-react-parser';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SequenceCard } from '../../../components/sequence-card';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import {
AvatarBase,
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,
getEventThreadDetail,
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 { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { useTheme } from '../../../hooks/useTheme';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { useIsDirectRoom, useRoom } from '../../../hooks/useRoom';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import {
GetMemberPowerTag,
getPowerTagIconSrc,
useAccessiblePowerTagColors,
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { ThreadSelector, ThreadSelectorContainer } from '../message/thread-selector';
import {
getTimelineAndBaseIndex,
getTimelineEvent,
getTimelineRelativeIndex,
getTimelinesEventsCount,
} from '../utils';
import { ThreadsLoading } from './ThreadsLoading';
import { VirtualTile } from '../../../components/virtualizer';
import { useAlive } from '../../../hooks/useAlive';
import * as css from './ThreadsMenu.css';
type ThreadMessageProps = {
room: Room;
event: MatrixEvent;
renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>;
onOpen: (roomId: string, eventId: string) => void;
getMemberPowerTag: GetMemberPowerTag;
accessibleTagColors: Map<string, string>;
legacyUsernameColor: boolean;
hour24Clock: boolean;
dateFormatString: string;
};
function ThreadMessage({
room,
event,
renderContent,
onOpen,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
hour24Clock,
dateFormatString,
}: ThreadMessageProps) {
const useAuthentication = useMediaAuthentication();
const mx = useMatrixClient();
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={event.getId()}
onClick={handleOpenClick}
variant="Secondary"
radii="Pill"
>
<Text size="T200">Open</Text>
</Chip>
</Box>
);
const sender = event.getSender()!;
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => event.getContent()) as GetContentCallback;
const memberPowerTag = getMemberPowerTag(sender);
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
: undefined;
const tagIconSrc = memberPowerTag?.icon
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
: undefined;
const usernameColor = legacyUsernameColor ? 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={event.getTs()} hour24Clock={hour24Clock} dateFormatString={dateFormatString} />
</Box>
{renderOptions()}
</Box>
{event.replyEventId && (
<Reply
room={room}
replyEventId={event.replyEventId}
threadRootId={event.threadRootId}
onClick={handleOpenClick}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor}
/>
)}
{renderContent(event.getType(), false, event, displayName, getContent)}
</ModernLayout>
);
}
type ThreadsTimelineProps = {
timelines: EventTimeline[];
requestClose: () => void;
};
export function ThreadsTimeline({ timelines, requestClose }: ThreadsTimelineProps) {
const mx = useMatrixClient();
const { navigateRoom } = useRoomNavigate();
const alive = useAlive();
const scrollRef = useRef<HTMLDivElement>(null);
const room = useRoom();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const theme = useTheme();
const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
const useAuthentication = useMediaAuthentication();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const direct = useIsDirectRoom();
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
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} />;
}
const threadDetail = getEventThreadDetail(event);
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
/>
{threadDetail && (
<ThreadSelectorContainer>
<ThreadSelector
room={room}
threadDetail={threadDetail}
outlined
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</ThreadSelectorContainer>
)}
</>
);
},
[MessageEvent.RoomMessageEncrypted]: (mEvent, displayName) => {
const eventId = mEvent.getId()!;
const evtTimeline = room.getTimelineForEvent(eventId);
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 =
evtTimeline && 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} />;
}
const threadDetail = getEventThreadDetail(event);
return (
<>
<MSticker
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
/>
{threadDetail && (
<ThreadSelectorContainer>
<ThreadSelector
room={room}
threadDetail={threadDetail}
outlined
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</ThreadSelectorContainer>
)}
</>
);
},
},
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();
};
const eventsLength = getTimelinesEventsCount(timelines);
const timelineToPaginate = timelines[timelines.length - 1];
const [paginationToken, setPaginationToken] = useState(
timelineToPaginate.getPaginationToken(Direction.Backward)
);
const virtualizer = useVirtualizer({
count: eventsLength,
getScrollElement: () => scrollRef.current,
estimateSize: () => 122,
overscan: 10,
});
const vItems = virtualizer.getVirtualItems();
const paginate = useCallback(async () => {
const moreToLoad = await mx.paginateEventTimeline(timelineToPaginate, {
backwards: true,
limit: 30,
});
if (alive()) {
setPaginationToken(
moreToLoad ? timelineToPaginate.getPaginationToken(Direction.Backward) : null
);
}
}, [mx, alive, timelineToPaginate]);
// auto paginate when scroll reach bottom
useEffect(() => {
const lastVItem = vItems.length > 0 ? vItems[vItems.length - 1] : undefined;
if (paginationToken && lastVItem && lastVItem.index === eventsLength - 1) {
paginate();
}
}, [vItems, paginationToken, eventsLength, paginate]);
return (
<Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
<Box className={css.ThreadsMenuContent} direction="Column" gap="100">
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{vItems.map((vItem) => {
const reverseTimelineIndex = eventsLength - vItem.index - 1;
const [timeline, baseIndex] = getTimelineAndBaseIndex(timelines, reverseTimelineIndex);
if (!timeline) return null;
const event = getTimelineEvent(
timeline,
getTimelineRelativeIndex(reverseTimelineIndex, baseIndex)
);
if (!event?.getId()) return null;
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S200 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<SequenceCard
key={event.getId()}
style={{ padding: config.space.S400, borderRadius: config.radii.R300 }}
variant="SurfaceVariant"
direction="Column"
>
<ThreadMessage
room={room}
event={event}
renderContent={renderMatrixEvent}
onOpen={handleOpen}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
</SequenceCard>
</VirtualTile>
);
})}
</div>
{paginationToken && <ThreadsLoading />}
</Box>
</Scroll>
);
}

View file

@ -0,0 +1 @@
export * from './ThreadsMenu';

View file

@ -0,0 +1 @@
export * from './timeline';

View file

@ -0,0 +1,66 @@
import { Room, EventTimeline, Direction, MatrixEvent } from 'matrix-js-sdk';
export const getFirstLinkedTimeline = (
timeline: EventTimeline,
direction: Direction
): EventTimeline => {
const linkedTm = timeline.getNeighbouringTimeline(direction);
if (!linkedTm) return timeline;
return getFirstLinkedTimeline(linkedTm, direction);
};
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
const timelines: EventTimeline[] = [];
for (
let nextTimeline: EventTimeline | null = firstTimeline;
nextTimeline;
nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
) {
timelines.push(nextTimeline);
}
return timelines;
};
export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
count + timelineToEventsCount(tm);
return timelines.reduce(timelineEventCountReducer, 0);
};
export const getTimelineAndBaseIndex = (
timelines: EventTimeline[],
index: number
): [EventTimeline | undefined, number] => {
let uptoTimelineLen = 0;
const timeline = timelines.find((t) => {
uptoTimelineLen += t.getEvents().length;
if (index < uptoTimelineLen) return true;
return false;
});
if (!timeline) return [undefined, 0];
return [timeline, uptoTimelineLen - timeline.getEvents().length];
};
export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
absoluteIndex - timelineBaseIndex;
export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
timeline.getEvents()[index];
export const getEventIdAbsoluteIndex = (
timelines: EventTimeline[],
eventTimeline: EventTimeline,
eventId: string
): number | undefined => {
const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
if (timelineIndex === -1) return undefined;
const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
if (eventIndex === -1) return undefined;
const baseIndex = timelines
.slice(0, timelineIndex)
.reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
return baseIndex + eventIndex;
};

View file

@ -0,0 +1,20 @@
import { useCallback } from 'react';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
export const useRoomMyThreads = (room: Room): AsyncState<EventTimelineSet, Error> => {
const [threadsState] = useAsyncCallbackValue<EventTimelineSet, Error>(
useCallback(async () => {
await room.createThreadsTimelineSets();
await room.fetchRoomThreads();
const timelineSet = room.threadsTimelineSets[0];
if (timelineSet === undefined) {
throw new Error('Failed to fetch My Threads!');
}
return timelineSet;
}, [room])
);
return threadsState;
};

View file

@ -8,6 +8,7 @@ import {
IPowerLevelsContent, IPowerLevelsContent,
IPushRule, IPushRule,
IPushRules, IPushRules,
IThreadBundledRelationship,
JoinRule, JoinRule,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
@ -16,6 +17,7 @@ import {
RelationType, RelationType,
Room, Room,
RoomMember, RoomMember,
THREAD_RELATION_TYPE,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
@ -551,3 +553,13 @@ export const guessPerfectParent = (
return perfectParent; return perfectParent;
}; };
export const getEventThreadDetail = (
mEvent: MatrixEvent
): IThreadBundledRelationship | undefined => {
const details = mEvent.getServerAggregatedRelation<IThreadBundledRelationship>(
THREAD_RELATION_TYPE.name
);
return details;
};

View file

@ -42,6 +42,7 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
export const startClient = async (mx: MatrixClient) => { export const startClient = async (mx: MatrixClient) => {
await mx.startClient({ await mx.startClient({
lazyLoadMembers: true, lazyLoadMembers: true,
threadSupport: true,
}); });
}; };