(
}}
>
{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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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,
});
};
From 38cc6e6f3a0ff56ab62f034771e73f2d849fc6ae Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 3 Nov 2025 15:27:02 +0530
Subject: [PATCH 10/14] add room to active thread atom
---
.../pages/client/ClientInitStorageAtom.tsx | 12 ++-
src/app/state/hooks/roomToActiveThread.ts | 55 ++++++++++++++
src/app/state/roomToActiveThread.ts | 75 +++++++++++++++++++
3 files changed, 139 insertions(+), 3 deletions(-)
create mode 100644 src/app/state/hooks/roomToActiveThread.ts
create mode 100644 src/app/state/roomToActiveThread.ts
diff --git a/src/app/pages/client/ClientInitStorageAtom.tsx b/src/app/pages/client/ClientInitStorageAtom.tsx
index 1abee707..308a0212 100644
--- a/src/app/pages/client/ClientInitStorageAtom.tsx
+++ b/src/app/pages/client/ClientInitStorageAtom.tsx
@@ -8,6 +8,8 @@ import { makeNavToActivePathAtom } from '../../state/navToActivePath';
import { NavToActivePathProvider } from '../../state/hooks/navToActivePath';
import { makeOpenedSidebarFolderAtom } from '../../state/openedSidebarFolder';
import { OpenedSidebarFolderProvider } from '../../state/hooks/openedSidebarFolder';
+import { makeRoomToActiveThreadAtom } from '../../state/roomToActiveThread';
+import { RoomToActiveThreadProvider } from '../../state/hooks/roomToActiveThread';
type ClientInitStorageAtomProps = {
children: ReactNode;
@@ -22,15 +24,19 @@ export function ClientInitStorageAtom({ children }: ClientInitStorageAtomProps)
const navToActivePathAtom = useMemo(() => makeNavToActivePathAtom(userId), [userId]);
+ const roomToActiveThreadAtom = useMemo(() => makeRoomToActiveThreadAtom(userId), [userId]);
+
const openedSidebarFolderAtom = useMemo(() => makeOpenedSidebarFolderAtom(userId), [userId]);
return (
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/src/app/state/hooks/roomToActiveThread.ts b/src/app/state/hooks/roomToActiveThread.ts
new file mode 100644
index 00000000..7abb5974
--- /dev/null
+++ b/src/app/state/hooks/roomToActiveThread.ts
@@ -0,0 +1,55 @@
+import { createContext, useCallback, useContext } from 'react';
+import { useAtomValue, useSetAtom } from 'jotai';
+import { RoomToActiveThreadAtom } from '../roomToActiveThread';
+
+const RoomToActiveThreadAtomContext = createContext(null);
+export const RoomToActiveThreadProvider = RoomToActiveThreadAtomContext.Provider;
+
+export const useRoomToActiveThreadAtom = (): RoomToActiveThreadAtom => {
+ const anAtom = useContext(RoomToActiveThreadAtomContext);
+
+ if (!anAtom) {
+ throw new Error('RoomToActiveThreadAtom is not provided!');
+ }
+
+ return anAtom;
+};
+
+export const useThreadSelector = (roomId: string): ((threadId: string) => void) => {
+ const roomToActiveThreadAtom = useRoomToActiveThreadAtom();
+ const setRoomToActiveThread = useSetAtom(roomToActiveThreadAtom);
+
+ const onThreadSelect = useCallback(
+ (threadId: string) => {
+ setRoomToActiveThread({
+ type: 'PUT',
+ roomId,
+ threadId,
+ });
+ },
+ [roomId, setRoomToActiveThread]
+ );
+
+ return onThreadSelect;
+};
+
+export const useActiveThread = (roomId: string): string | undefined => {
+ const roomToActiveThreadAtom = useRoomToActiveThreadAtom();
+ const roomToActiveThread = useAtomValue(roomToActiveThreadAtom);
+
+ return roomToActiveThread.get(roomId);
+};
+
+export const useThreadClose = (roomId: string): (() => void) => {
+ const roomToActiveThreadAtom = useRoomToActiveThreadAtom();
+ const setRoomToActiveThread = useSetAtom(roomToActiveThreadAtom);
+
+ const closeThread = useCallback(() => {
+ setRoomToActiveThread({
+ type: 'DELETE',
+ roomId,
+ });
+ }, [roomId, setRoomToActiveThread]);
+
+ return closeThread;
+};
diff --git a/src/app/state/roomToActiveThread.ts b/src/app/state/roomToActiveThread.ts
new file mode 100644
index 00000000..e156c63a
--- /dev/null
+++ b/src/app/state/roomToActiveThread.ts
@@ -0,0 +1,75 @@
+import { atom, WritableAtom } from 'jotai';
+import produce from 'immer';
+import {
+ atomWithLocalStorage,
+ getLocalStorageItem,
+ setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+const ROOM_TO_ACTIVE_THREAD = 'roomToActiveThread';
+
+const getStoreKey = (userId: string): string => `${ROOM_TO_ACTIVE_THREAD}${userId}`;
+
+type RoomToActiveThread = Map;
+
+type RoomToActiveThreadAction =
+ | {
+ type: 'PUT';
+ roomId: string;
+ threadId: string;
+ }
+ | {
+ type: 'DELETE';
+ roomId: string;
+ };
+
+export type RoomToActiveThreadAtom = WritableAtom<
+ RoomToActiveThread,
+ [RoomToActiveThreadAction],
+ undefined
+>;
+
+export const makeRoomToActiveThreadAtom = (userId: string): RoomToActiveThreadAtom => {
+ const storeKey = getStoreKey(userId);
+
+ const baseRoomToActiveThread = atomWithLocalStorage(
+ storeKey,
+ (key) => {
+ const obj: Record = getLocalStorageItem(key, {});
+ return new Map(Object.entries(obj));
+ },
+ (key, value) => {
+ const obj: Record = Object.fromEntries(value);
+ setLocalStorageItem(key, obj);
+ }
+ );
+
+ const navToActivePathAtom = atom(
+ (get) => get(baseRoomToActiveThread),
+ (get, set, action) => {
+ if (action.type === 'DELETE') {
+ set(
+ baseRoomToActiveThread,
+ produce(get(baseRoomToActiveThread), (draft) => {
+ draft.delete(action.roomId);
+ })
+ );
+ return;
+ }
+ if (action.type === 'PUT') {
+ set(
+ baseRoomToActiveThread,
+ produce(get(baseRoomToActiveThread), (draft) => {
+ draft.set(action.roomId, action.threadId);
+ })
+ );
+ }
+ }
+ );
+
+ return navToActivePathAtom;
+};
+
+export const clearRoomToActiveThreadStore = (userId: string) => {
+ localStorage.removeItem(getStoreKey(userId));
+};
From 3d4c91c969534b17e97e726581b1c757afcab2b6 Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 3 Nov 2025 15:28:42 +0530
Subject: [PATCH 11/14] add onClick prop to thread selector
---
.../features/room/message/thread-selector/ThreadSelector.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/app/features/room/message/thread-selector/ThreadSelector.tsx b/src/app/features/room/message/thread-selector/ThreadSelector.tsx
index b022a34f..41c87edc 100644
--- a/src/app/features/room/message/thread-selector/ThreadSelector.tsx
+++ b/src/app/features/room/message/thread-selector/ThreadSelector.tsx
@@ -13,18 +13,22 @@ export function ThreadSelectorContainer({ children }: { children: ReactNode }) {
type ThreadSelectorProps = {
room: Room;
+ threadId: string;
threadDetail: IThreadBundledRelationship;
outlined?: boolean;
hour24Clock: boolean;
dateFormatString: string;
+ onClick?: (threadId: string) => void;
};
export function ThreadSelector({
room,
+ threadId,
threadDetail,
outlined,
hour24Clock,
dateFormatString,
+ onClick,
}: ThreadSelectorProps) {
const latestEvent = threadDetail.latest_event;
@@ -43,6 +47,7 @@ export function ThreadSelector({
className={classNames(css.ThreadSelector, outlined && css.ThreadSectorOutlined)}
alignItems="Center"
gap="300"
+ onClick={() => onClick?.(threadId)}
>
From b5fd41f8628a5f149d3448120bb6e2e1bfcf4b1f Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 3 Nov 2025 15:28:52 +0530
Subject: [PATCH 12/14] clear active thread state on logout
---
src/client/initMatrix.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts
index 8ed4100e..f26b4f8d 100644
--- a/src/client/initMatrix.ts
+++ b/src/client/initMatrix.ts
@@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
+import { clearRoomToActiveThreadStore } from '../app/state/roomToActiveThread';
type Session = {
baseUrl: string;
@@ -49,6 +50,7 @@ export const startClient = async (mx: MatrixClient) => {
export const clearCacheAndReload = async (mx: MatrixClient) => {
mx.stopClient();
clearNavToActivePathStore(mx.getSafeUserId());
+ clearRoomToActiveThreadStore(mx.getSafeUserId());
await mx.store.deleteAllData();
window.location.reload();
};
From f35aa384e5d5a02ac3878d34961c369334459d3b Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 3 Nov 2025 15:29:51 +0530
Subject: [PATCH 13/14] thread view - WIP
---
src/app/features/room/Room.tsx | 18 ++++-
src/app/features/room/RoomTimeline.tsx | 5 ++
.../features/room/thread-view/ThreadView.tsx | 79 +++++++++++++++++++
src/app/features/room/thread-view/index.tsx | 1 +
.../features/room/thread-view/styles.css.ts | 21 +++++
.../room/threads-menu/ThreadsTimeline.tsx | 23 ++++--
src/app/utils/room.ts | 1 +
7 files changed, 137 insertions(+), 11 deletions(-)
create mode 100644 src/app/features/room/thread-view/ThreadView.tsx
create mode 100644 src/app/features/room/thread-view/index.tsx
create mode 100644 src/app/features/room/thread-view/styles.css.ts
diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx
index 24878d5e..47549fc7 100644
--- a/src/app/features/room/Room.tsx
+++ b/src/app/features/room/Room.tsx
@@ -13,6 +13,8 @@ import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../utils/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
+import { ThreadView } from './thread-view';
+import { useActiveThread } from '../../state/hooks/roomToActiveThread';
export function Room() {
const { eventId } = useParams();
@@ -25,6 +27,8 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
+ const threadId = useActiveThread(room.roomId);
+
useKeyDown(
window,
useCallback(
@@ -41,11 +45,19 @@ export function Room() {
- {screenSize === ScreenSize.Desktop && isDrawer && (
+ {threadId ? (
<>
-
-
+
+
>
+ ) : (
+ screenSize === ScreenSize.Desktop &&
+ isDrawer && (
+ <>
+
+
+ >
+ )
)}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 44696b0a..5ca2fc8e 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -138,6 +138,7 @@ import {
getTimelinesEventsCount,
timelineToEventsCount,
} from './utils';
+import { useThreadSelector } from '../../state/hooks/roomToActiveThread';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
@@ -429,6 +430,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const spoilerClickHandler = useSpoilerClickHandler();
const openUserRoomProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();
+ const handleThreadClick = useThreadSelector(room.roomId);
const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents);
@@ -961,6 +963,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
},
[editor]
);
+
const { t } = useTranslation();
const renderMatrixEvent = useMatrixEventRenderer<
@@ -1060,10 +1063,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)}
diff --git a/src/app/features/room/thread-view/ThreadView.tsx b/src/app/features/room/thread-view/ThreadView.tsx
new file mode 100644
index 00000000..6b6f4642
--- /dev/null
+++ b/src/app/features/room/thread-view/ThreadView.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text, Tooltip, TooltipProvider } from 'folds';
+import classNames from 'classnames';
+import FocusTrap from 'focus-trap-react';
+import { Page, PageHeader } from '../../../components/page';
+import * as css from './styles.css';
+import { useRoom } from '../../../hooks/useRoom';
+import { useThreadClose } from '../../../state/hooks/roomToActiveThread';
+import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
+
+type ThreadViewProps = {
+ threadId: string;
+};
+export function ThreadView({ threadId }: ThreadViewProps) {
+ const room = useRoom();
+ const screenSize = useScreenSizeContext();
+ const floating = screenSize !== ScreenSize.Desktop;
+
+ const closeThread = useThreadClose(room.roomId);
+
+ const thread = room.getThread(threadId);
+ const events = thread?.events ?? [];
+
+ return (
+
+
+
+
+
+
+ Thread
+
+
+
+
+ Close
+
+ }
+ >
+ {(triggerRef) => (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {events.map((mEvent) => (
+
+ {mEvent.sender?.name}: {mEvent.getContent().body}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/room/thread-view/index.tsx b/src/app/features/room/thread-view/index.tsx
new file mode 100644
index 00000000..6ba05aab
--- /dev/null
+++ b/src/app/features/room/thread-view/index.tsx
@@ -0,0 +1 @@
+export * from './ThreadView';
diff --git a/src/app/features/room/thread-view/styles.css.ts b/src/app/features/room/thread-view/styles.css.ts
new file mode 100644
index 00000000..fe66d49b
--- /dev/null
+++ b/src/app/features/room/thread-view/styles.css.ts
@@ -0,0 +1,21 @@
+import { style } from '@vanilla-extract/css';
+import { config, toRem } from 'folds';
+
+export const ThreadView = style({
+ width: toRem(456),
+ flexShrink: 0,
+ flexGrow: 0,
+});
+
+export const ThreadViewFloating = style({
+ position: 'absolute',
+ right: 0,
+ top: 0,
+ bottom: 0,
+ zIndex: 1,
+
+ maxWidth: toRem(456),
+ flexShrink: 1,
+ width: '100vw',
+ boxShadow: config.shadow.E400,
+});
diff --git a/src/app/features/room/threads-menu/ThreadsTimeline.tsx b/src/app/features/room/threads-menu/ThreadsTimeline.tsx
index d6cbb62d..0e28f97d 100644
--- a/src/app/features/room/threads-menu/ThreadsTimeline.tsx
+++ b/src/app/features/room/threads-menu/ThreadsTimeline.tsx
@@ -77,7 +77,7 @@ import * as css from './ThreadsMenu.css';
type ThreadMessageProps = {
room: Room;
event: MatrixEvent;
- renderContent: RenderMatrixEvent<[MatrixEvent, string, GetContentCallback]>;
+ renderContent: RenderMatrixEvent<[string, MatrixEvent, string, GetContentCallback]>;
onOpen: (roomId: string, eventId: string) => void;
getMemberPowerTag: GetMemberPowerTag;
accessibleTagColors: Map;
@@ -134,6 +134,10 @@ function ThreadMessage({
const usernameColor = legacyUsernameColor ? colorMXID(sender) : tagColor;
+ const mEventId = event.getId();
+
+ if (!mEventId) return null;
+
return (
)}
- {renderContent(event.getType(), false, event, displayName, getContent)}
+ {renderContent(event.getType(), false, mEventId, event, displayName, getContent)}
);
}
@@ -238,9 +242,11 @@ export function ThreadsTimeline({ timelines, requestClose }: ThreadsTimelineProp
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
);
- const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>(
+ const renderMatrixEvent = useMatrixEventRenderer<
+ [string, MatrixEvent, string, GetContentCallback]
+ >(
{
- [MessageEvent.RoomMessage]: (event, displayName, getContent) => {
+ [MessageEvent.RoomMessage]: (eventId, event, displayName, getContent) => {
if (event.isRedacted()) {
return ;
}
@@ -265,6 +271,7 @@ export function ThreadsTimeline({ timelines, requestClose }: ThreadsTimelineProp
);
},
- [MessageEvent.RoomMessageEncrypted]: (mEvent, displayName) => {
- const eventId = mEvent.getId()!;
+ [MessageEvent.RoomMessageEncrypted]: (eventId, mEvent, displayName) => {
const evtTimeline = room.getTimelineForEvent(eventId);
return (
@@ -333,7 +339,7 @@ export function ThreadsTimeline({ timelines, requestClose }: ThreadsTimelineProp
);
},
- [MessageEvent.Sticker]: (event, displayName, getContent) => {
+ [MessageEvent.Sticker]: (eventId, event, displayName, getContent) => {
if (event.isRedacted()) {
return ;
}
@@ -357,6 +363,7 @@ export function ThreadsTimeline({ timelines, requestClose }: ThreadsTimelineProp
{
+ (eventId, event) => {
if (event.isRedacted()) {
return ;
}
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index e80edb20..f7b8f017 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -203,6 +203,7 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => {
if (mEvent.isRedacted()) return false;
if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
+ if (mEvent.getRelation()?.rel_type === 'm.thread') return false;
return true;
};
From 444b2feb9e3faa6ca3fa81f2fd47adb5ee0f54ae Mon Sep 17 00:00:00 2001
From: Ajay Bura <32841439+ajbura@users.noreply.github.com>
Date: Mon, 3 Nov 2025 15:30:52 +0530
Subject: [PATCH 14/14] update folds
---
package-lock.json | 8 ++++----
package.json | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 39cc6c22..4abfac35 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,7 +32,7 @@
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
- "folds": "2.3.0",
+ "folds": "2.5.0",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",
@@ -7157,9 +7157,9 @@
}
},
"node_modules/folds": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/folds/-/folds-2.3.0.tgz",
- "integrity": "sha512-1KoM21jrg5daxvKrmSY0V04wa946KlNT0z6h017Rsnw2fdtNC6J0f34Ce5GF46Tzi00gZ/7SvCDXMzW/7e5s0w==",
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/folds/-/folds-2.5.0.tgz",
+ "integrity": "sha512-UJhvXAQ1XnZ9w10KJwSW+frvzzWE/zcF0dH3fDVCD70RFHAxwEi0UkkVS8CaZGxZF2Wvt3qTJyTS5LW3LwwUAw==",
"license": "Apache-2.0",
"peerDependencies": {
"@vanilla-extract/css": "1.9.2",
diff --git a/package.json b/package.json
index 495d34f5..5ad4daab 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
- "folds": "2.3.0",
+ "folds": "2.5.0",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",