(
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
- const eventId = sortedPinnedEvent[vItem.index];
- if (!eventId) return null;
+ const event = events[vItem.index];
+ if (!event.getId()) return null;
return (
(
variant="SurfaceVariant"
direction="Column"
>
-
@@ -441,7 +476,7 @@ export const ThreadsMenu = forwardRef
(
No Threads
- This room does not have any threads yet.
+ Threads you are participating in will appear here.
diff --git a/src/app/features/room/threads-menu/index.ts b/src/app/features/room/threads-menu/index.ts
new file mode 100644
index 00000000..98286736
--- /dev/null
+++ b/src/app/features/room/threads-menu/index.ts
@@ -0,0 +1 @@
+export * from './ThreadsMenu';
diff --git a/src/app/hooks/useRoomThreads.ts b/src/app/hooks/useRoomThreads.ts
index 8050a5d9..d5ef24d6 100644
--- a/src/app/hooks/useRoomThreads.ts
+++ b/src/app/hooks/useRoomThreads.ts
@@ -1,9 +1,29 @@
-// import { Room } from 'matrix-js-sdk';
-// import { useMatrixClient } from './useMatrixClient';
+import { useCallback } from 'react';
+import { Direction, MatrixEvent, Room, ThreadFilterType } from 'matrix-js-sdk';
+import { useMatrixClient } from './useMatrixClient';
+import { AsyncStatus, useAsyncCallbackValue } from './useAsyncCallback';
-// export const useRoomThreads = (room: Room) => {
-// const mx = useMatrixClient();
+export const useRoomMyThreads = (room: Room): MatrixEvent[] | undefined => {
+ const mx = useMatrixClient();
-// mx.createThreadListMessagesRequest;
-// mx.processThreadRoots;
-// };
+ const [fetchState] = useAsyncCallbackValue(
+ useCallback(
+ () =>
+ mx.createThreadListMessagesRequest(
+ room.roomId,
+ null,
+ 30,
+ Direction.Backward,
+ ThreadFilterType.My
+ ),
+ [mx, room]
+ )
+ );
+
+ if (fetchState.status === AsyncStatus.Success) {
+ const roomEvents = fetchState.data.chunk;
+ const mEvents = roomEvents.map((event) => new MatrixEvent(event)).reverse();
+ return mEvents;
+ }
+ return undefined;
+};
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index b4bba2ad..e80edb20 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -8,6 +8,7 @@ import {
IPowerLevelsContent,
IPushRule,
IPushRules,
+ IThreadBundledRelationship,
JoinRule,
MatrixClient,
MatrixEvent,
@@ -16,6 +17,7 @@ import {
RelationType,
Room,
RoomMember,
+ THREAD_RELATION_TYPE,
} from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData';
@@ -551,3 +553,13 @@ export const guessPerfectParent = (
return perfectParent;
};
+
+export const getEventThreadDetail = (
+ mEvent: MatrixEvent
+): IThreadBundledRelationship | undefined => {
+ const details = mEvent.getServerAggregatedRelation(
+ THREAD_RELATION_TYPE.name
+ );
+
+ return details;
+};
From d36938e1fd92bdbd9135690a1ac81369796505b5 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Wed, 24 Sep 2025 16:32:05 +0530
Subject: [PATCH 3/9] fix typo
---
src/app/components/thread-selector/ThreadSelector.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/components/thread-selector/ThreadSelector.tsx b/src/app/components/thread-selector/ThreadSelector.tsx
index 407f6b09..75eb1590 100644
--- a/src/app/components/thread-selector/ThreadSelector.tsx
+++ b/src/app/components/thread-selector/ThreadSelector.tsx
@@ -77,7 +77,7 @@ export function ThreadSelector({ room, senderId, threadDetail, outlined }: Threa
- {threadDetail.count} Replies
+ {threadDetail.count} {threadDetail.count === 1 ? 'Reply' : 'Replies'}
{/* TODO: date */}
From 67c6785bf3ba4e90db3f607e86e71b18426f5bd9 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Thu, 25 Sep 2025 12:17:14 +0530
Subject: [PATCH 4/9] inherit font weight for time component
---
src/app/components/message/Time.css.ts | 6 ++++++
src/app/components/message/Time.tsx | 13 +++++++++++--
2 files changed, 17 insertions(+), 2 deletions(-)
create mode 100644 src/app/components/message/Time.css.ts
diff --git a/src/app/components/message/Time.css.ts b/src/app/components/message/Time.css.ts
new file mode 100644
index 00000000..f2f5ac55
--- /dev/null
+++ b/src/app/components/message/Time.css.ts
@@ -0,0 +1,6 @@
+import { style } from '@vanilla-extract/css';
+
+export const Time = style({
+ fontWeight: 'inherit',
+ flexShrink: 0,
+});
diff --git a/src/app/components/message/Time.tsx b/src/app/components/message/Time.tsx
index 3eab5cc2..702e682d 100644
--- a/src/app/components/message/Time.tsx
+++ b/src/app/components/message/Time.tsx
@@ -1,6 +1,8 @@
import React, { ComponentProps } from 'react';
import { Text, as } from 'folds';
+import classNames from 'classnames';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
+import * as css from './Time.css';
export type TimeProps = {
compact?: boolean;
@@ -22,7 +24,7 @@ export type TimeProps = {
* @returns {React.ReactElement} A element with the formatted date/time.
*/
export const Time = as<'span', TimeProps & ComponentProps>(
- ({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
+ ({ compact, hour24Clock, dateFormatString, ts, className, ...props }, ref) => {
const formattedTime = timeHourMinute(ts, hour24Clock);
let time = '';
@@ -37,7 +39,14 @@ export const Time = as<'span', TimeProps & ComponentProps>(
}
return (
-
+
{time}
);
From a6a3ac3b24c5bddda4c109d3047c2c41113b0931 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Thu, 25 Sep 2025 12:17:44 +0530
Subject: [PATCH 5/9] redesign thread selector
---
.../thread-selector/ThreadSelector.tsx | 90 ---------------
.../components/thread-selector/styles.css.ts | 20 ----
src/app/features/room/RoomTimeline.tsx | 10 +-
.../thread-selector/ThreadSelector.tsx | 105 ++++++++++++++++++
.../room/message}/thread-selector/index.ts | 0
.../message/thread-selector/styles.css.ts | 37 ++++++
.../room/threads-menu/ThreadsMenu.tsx | 5 +-
src/app/hooks/useRoomThreads.ts | 2 +-
8 files changed, 154 insertions(+), 115 deletions(-)
delete mode 100644 src/app/components/thread-selector/ThreadSelector.tsx
delete mode 100644 src/app/components/thread-selector/styles.css.ts
create mode 100644 src/app/features/room/message/thread-selector/ThreadSelector.tsx
rename src/app/{components => features/room/message}/thread-selector/index.ts (100%)
create mode 100644 src/app/features/room/message/thread-selector/styles.css.ts
diff --git a/src/app/components/thread-selector/ThreadSelector.tsx b/src/app/components/thread-selector/ThreadSelector.tsx
deleted file mode 100644
index 75eb1590..00000000
--- a/src/app/components/thread-selector/ThreadSelector.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { Avatar, Box, Icon, Icons, Text } from 'folds';
-import React, { ReactNode } from 'react';
-import classNames from 'classnames';
-import { IThreadBundledRelationship, Room } from 'matrix-js-sdk';
-import { ContainerColor } from '../../styles/ContainerColor.css';
-import * as css from './styles.css';
-import { UserAvatar } from '../user-avatar';
-import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
-import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
-
-export function ThreadSelectorContainer({ children }: { children: ReactNode }) {
- return {children};
-}
-
-type ThreadSelectorProps = {
- room: Room;
- senderId: string;
- threadDetail: IThreadBundledRelationship;
- outlined?: boolean;
-};
-
-export function ThreadSelector({ room, senderId, threadDetail, outlined }: ThreadSelectorProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
- const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
-
- const latestEvent = threadDetail.latest_event;
- const latestSenderId = latestEvent.sender;
- const latestSenderAvatarMxc = getMemberAvatarMxc(room, latestSenderId);
- const latestDisplayName =
- getMemberDisplayName(room, latestSenderId) ??
- getMxIdLocalPart(latestSenderId) ??
- latestSenderId;
-
- const latestEventTs = latestEvent.origin_server_ts;
-
- return (
-
-
-
- }
- />
-
- {latestSenderId && (
-
- }
- />
-
- )}
-
-
-
- {threadDetail.count} {threadDetail.count === 1 ? 'Reply' : 'Replies'}
-
-
- {/* TODO: date */}
- Last Reply by {latestDisplayName} at {new Date(latestEventTs).getTime()}
-
-
-
-
- );
-}
diff --git a/src/app/components/thread-selector/styles.css.ts b/src/app/components/thread-selector/styles.css.ts
deleted file mode 100644
index fe580b68..00000000
--- a/src/app/components/thread-selector/styles.css.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { style } from '@vanilla-extract/css';
-import { color, config } from 'folds';
-
-export const ThreadSelectorContainer = style({
- paddingTop: config.space.S100,
-});
-
-export const ThreadSelector = style({
- padding: config.space.S200,
- borderRadius: config.radii.R400,
-});
-
-export const ThreadSectorOutlined = style({
- borderWidth: config.borderWidth.B300,
-});
-
-export const ThreadRepliesCount = style({
- color: color.Primary.Main,
- flexShrink: 0,
-});
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 93c3cf66..71674b0f 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -127,7 +127,7 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
-import { ThreadSelector, ThreadSelectorContainer } from '../../components/thread-selector';
+import { ThreadSelector, ThreadSelectorContainer } from './message/thread-selector';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
@@ -1113,7 +1113,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{threadDetail && (
-
+
)}
diff --git a/src/app/features/room/message/thread-selector/ThreadSelector.tsx b/src/app/features/room/message/thread-selector/ThreadSelector.tsx
new file mode 100644
index 00000000..3701948c
--- /dev/null
+++ b/src/app/features/room/message/thread-selector/ThreadSelector.tsx
@@ -0,0 +1,105 @@
+import { Avatar, 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 { UserAvatar } from '../../../../components/user-avatar';
+import { getMemberAvatarMxc, getMemberDisplayName } from '../../../../utils/room';
+import { useMatrixClient } from '../../../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication';
+import { getMxIdLocalPart, mxcUrlToHttp } from '../../../../utils/matrix';
+import { Time } from '../../../../components/message';
+
+export function ThreadSelectorContainer({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+type ThreadSelectorProps = {
+ room: Room;
+ threadDetail: IThreadBundledRelationship;
+ outlined?: boolean;
+ hour24Clock: boolean;
+ dateFormatString: string;
+};
+
+export function ThreadSelector({
+ room,
+ threadDetail,
+ outlined,
+ hour24Clock,
+ dateFormatString,
+}: ThreadSelectorProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+
+ const latestEvent = threadDetail.latest_event;
+
+ const latestSenderId = latestEvent.sender;
+ const latestSenderAvatarMxc = getMemberAvatarMxc(room, latestSenderId);
+ const latestDisplayName =
+ getMemberDisplayName(room, latestSenderId) ??
+ getMxIdLocalPart(latestSenderId) ??
+ latestSenderId;
+
+ const latestEventTs = latestEvent.origin_server_ts;
+
+ return (
+
+
+
+
+ {threadDetail.count} {threadDetail.count === 1 ? 'Thread Reply' : 'Thread Replies'}
+
+
+ {latestSenderId && (
+ <>
+
+
+
+
+ }
+ />
+
+
+
+
+ Latest by {latestDisplayName} at{' '}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/app/components/thread-selector/index.ts b/src/app/features/room/message/thread-selector/index.ts
similarity index 100%
rename from src/app/components/thread-selector/index.ts
rename to src/app/features/room/message/thread-selector/index.ts
diff --git a/src/app/features/room/message/thread-selector/styles.css.ts b/src/app/features/room/message/thread-selector/styles.css.ts
new file mode 100644
index 00000000..fbd15ecc
--- /dev/null
+++ b/src/app/features/room/message/thread-selector/styles.css.ts
@@ -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} ${config.space.S300}`,
+ 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,
+});
diff --git a/src/app/features/room/threads-menu/ThreadsMenu.tsx b/src/app/features/room/threads-menu/ThreadsMenu.tsx
index 5ff308e6..375b97da 100644
--- a/src/app/features/room/threads-menu/ThreadsMenu.tsx
+++ b/src/app/features/room/threads-menu/ThreadsMenu.tsx
@@ -80,7 +80,7 @@ import {
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomMyThreads } from '../../../hooks/useRoomThreads';
-import { ThreadSelector, ThreadSelectorContainer } from '../../../components/thread-selector';
+import { ThreadSelector, ThreadSelectorContainer } from '../message/thread-selector';
type ThreadMessageProps = {
room: Room;
@@ -287,9 +287,10 @@ export const ThreadsMenu = forwardRef(
)}
diff --git a/src/app/hooks/useRoomThreads.ts b/src/app/hooks/useRoomThreads.ts
index d5ef24d6..a8ed0933 100644
--- a/src/app/hooks/useRoomThreads.ts
+++ b/src/app/hooks/useRoomThreads.ts
@@ -14,7 +14,7 @@ export const useRoomMyThreads = (room: Room): MatrixEvent[] | undefined => {
null,
30,
Direction.Backward,
- ThreadFilterType.My
+ ThreadFilterType.All
),
[mx, room]
)
From d73428ee3de68695a1063069d00e023f0a9413c6 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Wed, 22 Oct 2025 16:20:22 +0530
Subject: [PATCH 6/9] add option to inherit priority in time component
---
src/app/components/message/Time.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/app/components/message/Time.tsx b/src/app/components/message/Time.tsx
index 702e682d..bc5e487a 100644
--- a/src/app/components/message/Time.tsx
+++ b/src/app/components/message/Time.tsx
@@ -9,6 +9,7 @@ export type TimeProps = {
ts: number;
hour24Clock: boolean;
dateFormatString: string;
+ inheritPriority?: boolean;
};
/**
@@ -24,7 +25,7 @@ export type TimeProps = {
* @returns {React.ReactElement} A element with the formatted date/time.
*/
export const Time = as<'span', TimeProps & ComponentProps>(
- ({ compact, hour24Clock, dateFormatString, ts, className, ...props }, ref) => {
+ ({ compact, hour24Clock, dateFormatString, ts, inheritPriority, className, ...props }, ref) => {
const formattedTime = timeHourMinute(ts, hour24Clock);
let time = '';
@@ -35,7 +36,7 @@ export const Time = as<'span', TimeProps & ComponentProps>(
} else if (yesterday(ts)) {
time = `Yesterday ${formattedTime}`;
} else {
- time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
+ time = `${timeDayMonYear(ts, dateFormatString)}, ${formattedTime}`;
}
return (
@@ -43,7 +44,7 @@ export const Time = as<'span', TimeProps & ComponentProps>(
as="time"
className={classNames(css.Time, className)}
size="T200"
- priority="300"
+ priority={inheritPriority ? undefined : '300'}
{...props}
ref={ref}
>
From 174b315278941d30b845e041cc4f18bd530e9cfe Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Wed, 22 Oct 2025 16:21:16 +0530
Subject: [PATCH 7/9] move timeline utils functions to new file
---
src/app/features/room/RoomTimeline.tsx | 91 +++++--------------------
src/app/features/room/utils/index.ts | 1 +
src/app/features/room/utils/timeline.ts | 66 ++++++++++++++++++
3 files changed, 85 insertions(+), 73 deletions(-)
create mode 100644 src/app/features/room/utils/index.ts
create mode 100644 src/app/features/room/utils/timeline.ts
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 71674b0f..44696b0a 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -128,6 +128,16 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
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>(
({ position, className, ...props }, ref) => (
@@ -152,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 = {
room: Room;
eventId?: string;
@@ -234,6 +171,14 @@ type RoomTimelineProps = {
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 = {
linkedTimelines: EventTimeline[];
range: ItemRange;
diff --git a/src/app/features/room/utils/index.ts b/src/app/features/room/utils/index.ts
new file mode 100644
index 00000000..13d812d7
--- /dev/null
+++ b/src/app/features/room/utils/index.ts
@@ -0,0 +1 @@
+export * from './timeline';
diff --git a/src/app/features/room/utils/timeline.ts b/src/app/features/room/utils/timeline.ts
new file mode 100644
index 00000000..f9c45525
--- /dev/null
+++ b/src/app/features/room/utils/timeline.ts
@@ -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;
+};
From e44ca92422ee4fe6b9ccbeed255b48d16fe6ffcf Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Wed, 22 Oct 2025 16:21:33 +0530
Subject: [PATCH 8/9] remove avatar from threads selector
---
.../thread-selector/ThreadSelector.tsx | 45 ++++---------------
.../message/thread-selector/styles.css.ts | 2 +-
2 files changed, 10 insertions(+), 37 deletions(-)
diff --git a/src/app/features/room/message/thread-selector/ThreadSelector.tsx b/src/app/features/room/message/thread-selector/ThreadSelector.tsx
index 3701948c..b022a34f 100644
--- a/src/app/features/room/message/thread-selector/ThreadSelector.tsx
+++ b/src/app/features/room/message/thread-selector/ThreadSelector.tsx
@@ -1,13 +1,10 @@
-import { Avatar, Box, Icon, Icons, Line, Text } from 'folds';
+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 { UserAvatar } from '../../../../components/user-avatar';
-import { getMemberAvatarMxc, getMemberDisplayName } from '../../../../utils/room';
-import { useMatrixClient } from '../../../../hooks/useMatrixClient';
-import { useMediaAuthentication } from '../../../../hooks/useMediaAuthentication';
-import { getMxIdLocalPart, mxcUrlToHttp } from '../../../../utils/matrix';
+import { getMemberDisplayName } from '../../../../utils/room';
+import { getMxIdLocalPart } from '../../../../utils/matrix';
import { Time } from '../../../../components/message';
export function ThreadSelectorContainer({ children }: { children: ReactNode }) {
@@ -29,13 +26,9 @@ export function ThreadSelector({
hour24Clock,
dateFormatString,
}: ThreadSelectorProps) {
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
-
const latestEvent = threadDetail.latest_event;
const latestSenderId = latestEvent.sender;
- const latestSenderAvatarMxc = getMemberAvatarMxc(room, latestSenderId);
const latestDisplayName =
getMemberDisplayName(room, latestSenderId) ??
getMxIdLocalPart(latestSenderId) ??
@@ -51,10 +44,10 @@ export function ThreadSelector({
alignItems="Center"
gap="300"
>
-
+
- {threadDetail.count} {threadDetail.count === 1 ? 'Thread Reply' : 'Thread Replies'}
+ {threadDetail.count} {threadDetail.count === 1 ? 'Reply' : 'Replies'}
{latestSenderId && (
@@ -65,35 +58,15 @@ export function ThreadSelector({
variant="SurfaceVariant"
/>
-
-
- }
- />
-
-
-
- Latest by {latestDisplayName} at{' '}
-
+ Last reply by
+ {latestDisplayName}
+ —
diff --git a/src/app/features/room/message/thread-selector/styles.css.ts b/src/app/features/room/message/thread-selector/styles.css.ts
index fbd15ecc..45bb7d62 100644
--- a/src/app/features/room/message/thread-selector/styles.css.ts
+++ b/src/app/features/room/message/thread-selector/styles.css.ts
@@ -9,7 +9,7 @@ export const ThreadSelectorContainer = style({
export const ThreadSelector = style([
ContainerColor({ variant: 'SurfaceVariant' }),
{
- padding: `${config.space.S200} ${config.space.S300}`,
+ padding: config.space.S200,
borderRadius: config.radii.R400,
cursor: 'pointer',
From 12bcbc2e78a27d49581d0e1fc43e39f342649acf Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Wed, 22 Oct 2025 16:22:31 +0530
Subject: [PATCH 9/9] load threads in My Threads menu
---
.../room/threads-menu/ThreadsError.tsx | 32 ++
.../room/threads-menu/ThreadsLoading.tsx | 23 +
.../room/threads-menu/ThreadsMenu.tsx | 507 ++----------------
.../room/threads-menu/ThreadsTimeline.tsx | 481 +++++++++++++++++
src/app/hooks/useRoomThreads.ts | 37 +-
src/client/initMatrix.ts | 1 +
6 files changed, 608 insertions(+), 473 deletions(-)
create mode 100644 src/app/features/room/threads-menu/ThreadsError.tsx
create mode 100644 src/app/features/room/threads-menu/ThreadsLoading.tsx
create mode 100644 src/app/features/room/threads-menu/ThreadsTimeline.tsx
diff --git a/src/app/features/room/threads-menu/ThreadsError.tsx b/src/app/features/room/threads-menu/ThreadsError.tsx
new file mode 100644
index 00000000..c72b7202
--- /dev/null
+++ b/src/app/features/room/threads-menu/ThreadsError.tsx
@@ -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 (
+
+
+
+
+ {error.name}
+
+
+ {error.message}
+
+
+
+ );
+}
diff --git a/src/app/features/room/threads-menu/ThreadsLoading.tsx b/src/app/features/room/threads-menu/ThreadsLoading.tsx
new file mode 100644
index 00000000..236435b2
--- /dev/null
+++ b/src/app/features/room/threads-menu/ThreadsLoading.tsx
@@ -0,0 +1,23 @@
+import { Box, config, Spinner } from 'folds';
+import React from 'react';
+import { ContainerColor } from '../../../styles/ContainerColor.css';
+
+export function ThreadsLoading() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/features/room/threads-menu/ThreadsMenu.tsx b/src/app/features/room/threads-menu/ThreadsMenu.tsx
index 375b97da..28e73f33 100644
--- a/src/app/features/room/threads-menu/ThreadsMenu.tsx
+++ b/src/app/features/room/threads-menu/ThreadsMenu.tsx
@@ -1,194 +1,48 @@
/* eslint-disable react/destructuring-assignment */
-import React, { forwardRef, MouseEventHandler, useMemo, useRef } from 'react';
-import { IRoomEvent, 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 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 { 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 { VirtualTile } from '../../../components/virtualizer';
-import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { ContainerColor } from '../../../styles/ContainerColor.css';
-import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
-import { useTheme } from '../../../hooks/useTheme';
-import { PowerIcon } from '../../../components/power';
-import colorMXID from '../../../../util/colorMXID';
-import { useIsDirectRoom } from '../../../hooks/useRoom';
-import { useRoomCreators } from '../../../hooks/useRoomCreators';
-import {
- GetMemberPowerTag,
- getPowerTagIconSrc,
- useAccessiblePowerTagColors,
- useGetMemberPowerTag,
-} from '../../../hooks/useMemberPowerTag';
-import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomMyThreads } from '../../../hooks/useRoomThreads';
-import { ThreadSelector, ThreadSelectorContainer } from '../message/thread-selector';
+import { AsyncStatus } from '../../../hooks/useAsyncCallback';
+import { getLinkedTimelines, getTimelinesEventsCount } from '../utils';
+import { ThreadsTimeline } from './ThreadsTimeline';
+import { ThreadsLoading } from './ThreadsLoading';
+import { ThreadsError } from './ThreadsError';
-type ThreadMessageProps = {
- room: Room;
- event: MatrixEvent;
- renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>;
- onOpen: (roomId: string, eventId: string) => void;
- getMemberPowerTag: GetMemberPowerTag;
- accessibleTagColors: Map;
- legacyUsernameColor: boolean;
- hour24Clock: boolean;
- dateFormatString: string;
+const getTimelines = (timelineSet: EventTimelineSet) => {
+ const liveTimeline = timelineSet.getLiveTimeline();
+ const linkedTimelines = getLinkedTimelines(liveTimeline);
+
+ return linkedTimelines;
};
-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 = () => (
-
-
- Open
-
-
- );
-
- 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;
+function NoThreads() {
return (
-
-
- }
- />
-
-
- }
+
-
-
-
-
-
- {displayName}
-
-
- {tagIconSrc && }
-
-
-
- {renderOptions()}
+
+
+
+ No Threads Yet
+
+
+ Threads you’re participating in will appear here.
+
- {event.replyEventId && (
-
- )}
- {renderContent(event.getType(), false, event, displayName, getContent)}
-
+
);
}
@@ -198,204 +52,16 @@ type ThreadsMenuProps = {
};
export const ThreadsMenu = forwardRef(
({ room, requestClose }, ref) => {
- const mx = useMatrixClient();
- const powerLevels = usePowerLevelsContext();
- const creators = useRoomCreators(room);
+ const threadsState = useRoomMyThreads(room);
+ const threadsTimelineSet =
+ threadsState.status === AsyncStatus.Success ? threadsState.data : undefined;
- const creatorsTag = useRoomCreatorsTag();
- const powerLevelTags = usePowerLevelTags(room, powerLevels);
- const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
+ const linkedTimelines = useMemo(() => {
+ if (!threadsTimelineSet) return undefined;
+ return getTimelines(threadsTimelineSet);
+ }, [threadsTimelineSet]);
- 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 { navigateRoom } = useRoomNavigate();
- const scrollRef = useRef(null);
-
- const events = useRoomMyThreads(room);
-
- const virtualizer = useVirtualizer({
- count: events?.length ?? 0,
- getScrollElement: () => scrollRef.current,
- estimateSize: () => 75,
- overscan: 4,
- });
-
- const mentionClickHandler = useMentionClickHandler(room.roomId);
- const spoilerClickHandler = useSpoilerClickHandler();
-
- const linkifyOpts = useMemo(
- () => ({
- ...LINKIFY_OPTS,
- render: factoryRenderLinkifyWithMention((href) =>
- renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
- ),
- }),
- [mx, room, mentionClickHandler]
- );
- const htmlReactParserOptions = useMemo(
- () =>
- getReactCustomHtmlParser(mx, room.roomId, {
- linkifyOpts,
- useAuthentication,
- handleSpoilerClick: spoilerClickHandler,
- handleMentionClick: mentionClickHandler,
- }),
- [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
- );
-
- const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>(
- {
- [MessageEvent.RoomMessage]: (event, displayName, getContent) => {
- if (event.isRedacted()) {
- return (
-
- );
- }
-
- const threadDetail = getEventThreadDetail(event);
-
- return (
- <>
-
- {threadDetail && (
-
-
-
- )}
- >
- );
- },
- [MessageEvent.RoomMessageEncrypted]: (mEvent, displayName) => {
- const eventId = mEvent.getId()!;
- const evtTimeline = room.getTimelineForEvent(eventId);
-
- return (
-
- {() => {
- if (mEvent.isRedacted()) return ;
- if (mEvent.getType() === MessageEvent.Sticker)
- return (
- (
- }
- renderViewer={(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 (
-
- );
- }
- if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
- return (
-
-
-
- );
- return (
-
-
-
- );
- }}
-
- );
- },
- [MessageEvent.Sticker]: (event, displayName, getContent) => {
- if (event.isRedacted()) {
- return (
-
- );
- }
- return (
- (
- }
- renderViewer={(p) => }
- />
- )}
- />
- );
- },
- },
- undefined,
- (event) => {
- if (event.isRedacted()) {
- return ;
- }
- return (
-
-
- {event.getType()}
- {' event'}
-
-
- );
- }
- );
-
- const handleOpen = (roomId: string, eventId: string) => {
- navigateRoom(roomId, eventId);
- requestClose();
- };
+ const hasEvents = linkedTimelines && getTimelinesEventsCount(linkedTimelines) > 0;
return (
-
-
- {events && events.length > 0 ? (
-
- {virtualizer.getVirtualItems().map((vItem) => {
- const event = events[vItem.index];
- if (!event.getId()) return null;
-
- return (
-
-
-
-
-
- );
- })}
-
- ) : (
-
-
-
-
- No Threads
-
-
- Threads you are participating in will appear here.
-
-
-
- )}
-
-
+ {threadsState.status === AsyncStatus.Success && hasEvents ? (
+
+ ) : (
+
+
+ {(threadsState.status === AsyncStatus.Loading ||
+ threadsState.status === AsyncStatus.Idle) && }
+ {threadsState.status === AsyncStatus.Success && !hasEvents && }
+ {threadsState.status === AsyncStatus.Error && (
+
+ )}
+
+
+ )}
diff --git a/src/app/features/room/threads-menu/ThreadsTimeline.tsx b/src/app/features/room/threads-menu/ThreadsTimeline.tsx
new file mode 100644
index 00000000..d6cbb62d
--- /dev/null
+++ b/src/app/features/room/threads-menu/ThreadsTimeline.tsx
@@ -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;
+ 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 = () => (
+
+
+ Open
+
+
+ );
+
+ 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 (
+
+
+ }
+ />
+
+
+ }
+ >
+
+
+
+
+
+ {displayName}
+
+
+ {tagIconSrc && }
+
+
+
+ {renderOptions()}
+
+ {event.replyEventId && (
+
+ )}
+ {renderContent(event.getType(), false, event, displayName, getContent)}
+
+ );
+}
+
+type ThreadsTimelineProps = {
+ timelines: EventTimeline[];
+ requestClose: () => void;
+};
+export function ThreadsTimeline({ timelines, requestClose }: ThreadsTimelineProps) {
+ const mx = useMatrixClient();
+ const { navigateRoom } = useRoomNavigate();
+ const alive = useAlive();
+ const scrollRef = useRef(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(
+ () => ({
+ ...LINKIFY_OPTS,
+ render: factoryRenderLinkifyWithMention((href) =>
+ renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler))
+ ),
+ }),
+ [mx, room, mentionClickHandler]
+ );
+ const htmlReactParserOptions = useMemo(
+ () =>
+ getReactCustomHtmlParser(mx, room.roomId, {
+ linkifyOpts,
+ useAuthentication,
+ handleSpoilerClick: spoilerClickHandler,
+ handleMentionClick: mentionClickHandler,
+ }),
+ [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
+ );
+
+ const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>(
+ {
+ [MessageEvent.RoomMessage]: (event, displayName, getContent) => {
+ if (event.isRedacted()) {
+ return ;
+ }
+
+ const threadDetail = getEventThreadDetail(event);
+
+ return (
+ <>
+
+ {threadDetail && (
+
+
+
+ )}
+ >
+ );
+ },
+ [MessageEvent.RoomMessageEncrypted]: (mEvent, displayName) => {
+ const eventId = mEvent.getId()!;
+ const evtTimeline = room.getTimelineForEvent(eventId);
+
+ return (
+
+ {() => {
+ if (mEvent.isRedacted()) return ;
+ if (mEvent.getType() === MessageEvent.Sticker)
+ return (
+ (
+ }
+ renderViewer={(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 (
+
+ );
+ }
+ if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
+ return (
+
+
+
+ );
+ return (
+
+
+
+ );
+ }}
+
+ );
+ },
+ [MessageEvent.Sticker]: (event, displayName, getContent) => {
+ if (event.isRedacted()) {
+ return ;
+ }
+
+ const threadDetail = getEventThreadDetail(event);
+
+ return (
+ <>
+ (
+ }
+ renderViewer={(p) => }
+ />
+ )}
+ />
+ {threadDetail && (
+
+
+
+ )}
+ >
+ );
+ },
+ },
+ undefined,
+ (event) => {
+ if (event.isRedacted()) {
+ return ;
+ }
+ return (
+
+
+ {event.getType()}
+ {' event'}
+
+
+ );
+ }
+ );
+
+ 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 (
+
+
+
+ {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 (
+
+
+
+
+
+ );
+ })}
+
+ {paginationToken && }
+
+
+ );
+}
diff --git a/src/app/hooks/useRoomThreads.ts b/src/app/hooks/useRoomThreads.ts
index a8ed0933..7d5fa807 100644
--- a/src/app/hooks/useRoomThreads.ts
+++ b/src/app/hooks/useRoomThreads.ts
@@ -1,29 +1,20 @@
import { useCallback } from 'react';
-import { Direction, MatrixEvent, Room, ThreadFilterType } from 'matrix-js-sdk';
-import { useMatrixClient } from './useMatrixClient';
-import { AsyncStatus, useAsyncCallbackValue } from './useAsyncCallback';
+import { EventTimelineSet, Room } from 'matrix-js-sdk';
+import { AsyncState, useAsyncCallbackValue } from './useAsyncCallback';
-export const useRoomMyThreads = (room: Room): MatrixEvent[] | undefined => {
- const mx = useMatrixClient();
+export const useRoomMyThreads = (room: Room): AsyncState => {
+ const [threadsState] = useAsyncCallbackValue(
+ useCallback(async () => {
+ await room.createThreadsTimelineSets();
+ await room.fetchRoomThreads();
- const [fetchState] = useAsyncCallbackValue(
- useCallback(
- () =>
- mx.createThreadListMessagesRequest(
- room.roomId,
- null,
- 30,
- Direction.Backward,
- ThreadFilterType.All
- ),
- [mx, room]
- )
+ const timelineSet = room.threadsTimelineSets[0];
+ if (timelineSet === undefined) {
+ throw new Error('Failed to fetch My Threads!');
+ }
+ return timelineSet;
+ }, [room])
);
- if (fetchState.status === AsyncStatus.Success) {
- const roomEvents = fetchState.data.chunk;
- const mEvents = roomEvents.map((event) => new MatrixEvent(event)).reverse();
- return mEvents;
- }
- return undefined;
+ return threadsState;
};
diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts
index 487c3f13..8ed4100e 100644
--- a/src/client/initMatrix.ts
+++ b/src/client/initMatrix.ts
@@ -42,6 +42,7 @@ export const initClient = async (session: Session): Promise => {
export const startClient = async (mx: MatrixClient) => {
await mx.startClient({
lazyLoadMembers: true,
+ threadSupport: true,
});
};