Skip to content

Commit a0e4ef7

Browse files
committed
feat(threads): update timeline hooks for thread rendering and add changeset
1 parent 0799812 commit a0e4ef7

4 files changed

Lines changed: 95 additions & 38 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Update threads: various fixes, browse all room threads, and see live reply counts on messages.

src/app/hooks/timeline/useProcessedTimeline.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export interface UseProcessedTimelineOptions {
2020
hideNickAvatarEvents: boolean;
2121
isReadOnly: boolean;
2222
hideMemberInReadOnly: boolean;
23+
/**
24+
* When true, skip the filter that removes events whose `threadRootId` points
25+
* to a different event. Required when processing a thread's own timeline
26+
* where every reply legitimately has `threadRootId` set to the root.
27+
*/
28+
skipThreadFilter?: boolean;
2329
}
2430

2531
export interface ProcessedEvent {
@@ -55,6 +61,7 @@ export function useProcessedTimeline({
5561
hideNickAvatarEvents,
5662
isReadOnly,
5763
hideMemberInReadOnly,
64+
skipThreadFilter,
5865
}: UseProcessedTimelineOptions): ProcessedEvent[] {
5966
return useMemo(() => {
6067
let prevEvent: MatrixEvent | undefined;
@@ -116,7 +123,7 @@ export function useProcessedTimeline({
116123
}
117124
}
118125

119-
if (threadRootId !== undefined && threadRootId !== mEventId) return acc;
126+
if (!skipThreadFilter && threadRootId !== undefined && threadRootId !== mEventId) return acc;
120127

121128
const isReactionOrEdit = reactionOrEditEvent(mEvent);
122129
if (isReactionOrEdit) return acc;
@@ -193,5 +200,6 @@ export function useProcessedTimeline({
193200
hideNickAvatarEvents,
194201
isReadOnly,
195202
hideMemberInReadOnly,
203+
skipThreadFilter,
196204
]);
197205
}

src/app/hooks/timeline/useTimelineActions.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@ import { ReactEditor } from 'slate-react';
55

66
import { getMxIdLocalPart, toggleReaction } from '$utils/matrix';
77
import { getMemberDisplayName, getEditedEvent } from '$utils/room';
8-
import { createMentionElement, isEmptyEditor, moveCursor } from '$components/editor';
8+
import { createMentionElement, moveCursor } from '$components/editor';
99

1010
export interface UseTimelineActionsOptions {
1111
room: Room;
1212
mx: MatrixClient;
1313
editor: Editor;
14-
alive: () => boolean;
1514
nicknames: Record<string, string>;
1615
globalProfiles: Record<string, any>;
1716
spaceId?: string;
@@ -27,16 +26,14 @@ export interface UseTimelineActionsOptions {
2726
setReplyDraft: (draft: any) => void;
2827
openThreadId?: string;
2928
setOpenThread: (threadId: string | undefined) => void;
30-
setEditId: (editId: string | undefined) => void;
31-
onEditorReset?: () => void;
29+
handleEdit: (editId?: string) => void;
3230
handleOpenEvent: (eventId: string) => void;
3331
}
3432

3533
export function useTimelineActions({
3634
room,
3735
mx,
3836
editor,
39-
alive,
4037
nicknames,
4138
globalProfiles,
4239
spaceId,
@@ -45,8 +42,7 @@ export function useTimelineActions({
4542
setReplyDraft,
4643
openThreadId,
4744
setOpenThread,
48-
setEditId,
49-
onEditorReset,
45+
handleEdit,
5046
handleOpenEvent,
5147
}: UseTimelineActionsOptions) {
5248
const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
@@ -209,24 +205,6 @@ export function useTimelineActions({
209205
[mx]
210206
);
211207

212-
const handleEdit = useCallback(
213-
(targetEditId?: string) => {
214-
if (targetEditId) {
215-
setEditId(targetEditId);
216-
return;
217-
}
218-
setEditId(undefined);
219-
220-
requestAnimationFrame(() => {
221-
if (!alive()) return;
222-
if (isEmptyEditor(editor)) onEditorReset?.();
223-
ReactEditor.focus(editor);
224-
moveCursor(editor);
225-
});
226-
},
227-
[editor, alive, onEditorReset, setEditId]
228-
);
229-
230208
return {
231209
handleOpenReply,
232210
handleUserClick,

src/app/hooks/timeline/useTimelineEventRenderer.tsx

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { MouseEventHandler, useCallback, useMemo } from 'react';
1+
import { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { useAtomValue } from 'jotai';
44
import {
5+
IThreadBundledRelationship,
56
MatrixClient,
67
MatrixEvent,
8+
NotificationCountType,
79
Room,
10+
RoomEvent,
11+
ThreadEvent,
812
PushProcessor,
913
EventTimelineSet,
1014
IContent,
@@ -47,6 +51,7 @@ import {
4751
} from '$utils/room';
4852
import { getLinkedTimelines, getLiveTimeline } from '$utils/timeline';
4953
import * as customHtmlCss from '$styles/CustomHtml.css';
54+
import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge';
5055
import {
5156
EncryptedContent,
5257
Event,
@@ -105,20 +110,51 @@ function ThreadReplyChip({
105110
const useAuthentication = useMediaAuthentication();
106111
const nicknames = useAtomValue(nicknamesAtom);
107112

113+
const [counter, forceUpdate] = useState(0);
114+
108115
const thread = room.getThread(mEventId);
109116

117+
useEffect(() => {
118+
if (!thread) return () => {};
119+
const onUpdate = () => forceUpdate((n) => n + 1);
120+
thread.on(ThreadEvent.NewReply as any, onUpdate);
121+
thread.on(ThreadEvent.Update as any, onUpdate);
122+
room.on(RoomEvent.Redaction as any, onUpdate);
123+
room.on(RoomEvent.UnreadNotifications as any, onUpdate);
124+
return () => {
125+
thread.off(ThreadEvent.NewReply as any, onUpdate);
126+
thread.off(ThreadEvent.Update as any, onUpdate);
127+
room.off(RoomEvent.Redaction as any, onUpdate);
128+
room.off(RoomEvent.UnreadNotifications as any, onUpdate);
129+
};
130+
}, [room, thread]);
131+
110132
const replyEvents = useMemo(() => {
133+
// With threadSupport:true, reply events live in thread.timelineSet not the main room timeline.
134+
// Prefer thread.events when available so avatars and preview text are populated.
135+
if (thread) {
136+
const fromThread = thread.events.filter(
137+
(ev) => ev.getId() !== mEventId && !reactionOrEditEvent(ev)
138+
);
139+
if (fromThread.length > 0) return fromThread;
140+
}
111141
const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
112142
return linkedTimelines
113143
.flatMap((tl) => tl.getEvents())
114144
.filter(
115145
(ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev)
116146
);
117-
}, [room, mEventId]);
147+
// eslint-disable-next-line react-hooks/exhaustive-deps -- counter is a cache-busting key, not used directly in body
148+
}, [room, mEventId, thread, counter]);
118149

119150
if (!thread) return null;
120151

121-
const replyCount = thread.length ?? 0;
152+
// Prefer the server-authoritative bundled count. thread.length only reflects
153+
// events fetched into the local timeline, which can be much lower than the
154+
// true total before the thread drawer is first opened and paginated.
155+
const bundledCount =
156+
thread.rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>('m.thread')?.count;
157+
const replyCount = bundledCount ?? thread.length ?? 0;
122158
if (replyCount === 0) return null;
123159

124160
const uniqueSenders: string[] = [];
@@ -146,6 +182,12 @@ function ThreadReplyChip({
146182

147183
const isOpen = openThreadId === mEventId;
148184

185+
const unreadTotal = room.getThreadUnreadNotificationCount(mEventId, NotificationCountType.Total);
186+
const unreadHighlight = room.getThreadUnreadNotificationCount(
187+
mEventId,
188+
NotificationCountType.Highlight
189+
);
190+
149191
return (
150192
<Chip
151193
size="400"
@@ -201,6 +243,11 @@ function ThreadReplyChip({
201243
&nbsp;·&nbsp;{latestSenderName}:&nbsp;{latestBody.slice(0, 60)}
202244
</Text>
203245
)}
246+
{unreadTotal > 0 && (
247+
<UnreadBadgeCenter>
248+
<UnreadBadge highlight={unreadHighlight > 0} count={unreadTotal} />
249+
</UnreadBadgeCenter>
250+
)}
204251
</Chip>
205252
);
206253
}
@@ -226,6 +273,7 @@ export interface TimelineEventRendererOptions {
226273
hideMembershipEvents: boolean;
227274
hideNickAvatarEvents: boolean;
228275
showHiddenEvents: boolean;
276+
hideThreadChip?: boolean;
229277
};
230278
state: {
231279
focusItem?: { index: number; highlight: boolean; scrollTo: boolean };
@@ -280,6 +328,7 @@ export function useTimelineEventRenderer({
280328
hideMembershipEvents,
281329
hideNickAvatarEvents,
282330
showHiddenEvents,
331+
hideThreadChip,
283332
},
284333
state: { focusItem, editId, activeReplyId, openThreadId },
285334
permissions: { canRedact, canDeleteOwn, canSendReaction, canPinEvent },
@@ -309,9 +358,16 @@ export function useTimelineEventRenderer({
309358
isRedacted,
310359
getUnsigned,
311360
getTs,
312-
replyEventId,
361+
getWireContent,
362+
replyEventId: rawReplyEventId,
313363
threadRootId,
314364
} = mEvent;
365+
// In the thread drawer (hideThreadChip=true), suppress reply headers for events
366+
// that only have m.in_reply_to as a non-thread-client fallback (is_falling_back: true).
367+
const replyEventId =
368+
hideThreadChip && getWireContent.call(mEvent)?.['m.relates_to']?.is_falling_back
369+
? undefined
370+
: rawReplyEventId;
315371

316372
const reactionRelations = getEventReactions(timelineSet, mEventId);
317373
const reactions = reactionRelations?.getSortedAnnotationsByKey();
@@ -400,15 +456,15 @@ export function useTimelineEventRenderer({
400456
room={room}
401457
timelineSet={timelineSet}
402458
replyEventId={replyEventId}
403-
threadRootId={threadRootId}
459+
threadRootId={hideThreadChip ? undefined : threadRootId}
404460
mentions={baseContent['m.mentions']}
405461
onClick={handleOpenReply}
406462
/>
407463
)
408464
}
409465
reactions={(() => {
410466
const threadChip =
411-
room.getThread(mEventId) || threadRootId ? (
467+
!hideThreadChip && (room.getThread(mEventId) || threadRootId) ? (
412468
<ThreadReplyChip
413469
room={room}
414470
mEventId={mEventId}
@@ -469,9 +525,14 @@ export function useTimelineEventRenderer({
469525
getContent: getEventContent,
470526
getOriginalContent,
471527
getTs,
472-
replyEventId,
528+
getWireContent,
529+
replyEventId: rawReplyEventId,
473530
threadRootId,
474531
} = mEvent;
532+
const replyEventId =
533+
hideThreadChip && getWireContent.call(mEvent)?.['m.relates_to']?.is_falling_back
534+
? undefined
535+
: rawReplyEventId;
475536

476537
const reactionRelations = getEventReactions(timelineSet, mEventId);
477538
const reactions = reactionRelations?.getSortedAnnotationsByKey();
@@ -522,14 +583,14 @@ export function useTimelineEventRenderer({
522583
room={room}
523584
timelineSet={timelineSet}
524585
replyEventId={replyEventId}
525-
threadRootId={threadRootId}
586+
threadRootId={hideThreadChip ? undefined : threadRootId}
526587
onClick={handleOpenReply}
527588
/>
528589
)
529590
}
530591
reactions={(() => {
531592
const threadChip =
532-
room.getThread(mEventId) || threadRootId ? (
593+
!hideThreadChip && (room.getThread(mEventId) || threadRootId) ? (
533594
<ThreadReplyChip
534595
room={room}
535596
mEventId={mEventId}
@@ -638,9 +699,14 @@ export function useTimelineEventRenderer({
638699
isRedacted,
639700
getUnsigned,
640701
getContent: getEventContent,
641-
replyEventId,
702+
getWireContent,
703+
replyEventId: rawReplyEventId,
642704
threadRootId,
643705
} = mEvent;
706+
const replyEventId =
707+
hideThreadChip && getWireContent.call(mEvent)?.['m.relates_to']?.is_falling_back
708+
? undefined
709+
: rawReplyEventId;
644710

645711
const reactionRelations = getEventReactions(timelineSet, mEventId);
646712
const reactions = reactionRelations?.getSortedAnnotationsByKey();
@@ -683,15 +749,15 @@ export function useTimelineEventRenderer({
683749
room={room}
684750
timelineSet={timelineSet}
685751
replyEventId={replyEventId}
686-
threadRootId={threadRootId}
752+
threadRootId={hideThreadChip ? undefined : threadRootId}
687753
mentions={content['m.mentions']}
688754
onClick={handleOpenReply}
689755
/>
690756
)
691757
}
692758
reactions={(() => {
693759
const threadChip =
694-
room.getThread(mEventId) || threadRootId ? (
760+
!hideThreadChip && (room.getThread(mEventId) || threadRootId) ? (
695761
<ThreadReplyChip
696762
room={room}
697763
mEventId={mEventId}

0 commit comments

Comments
 (0)