diff --git a/.changeset/feat-thread-enhancements.md b/.changeset/feat-thread-enhancements.md new file mode 100644 index 000000000..b2532c19c --- /dev/null +++ b/.changeset/feat-thread-enhancements.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Update threads: various fixes, browse all room threads, and see live reply counts on messages. diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fb4b33515..0c88c5e63 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -32,6 +32,7 @@ import { MessageBase, CompactPlaceholder, DefaultPlaceholder } from '$components import { RoomIntro } from '$components/room-intro'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useAlive } from '$hooks/useAlive'; +import { useMessageEdit } from '$hooks/useMessageEdit'; import { useDocumentFocusChange } from '$hooks/useDocumentFocusChange'; import { markAsRead } from '$utils/notifications'; import { @@ -123,6 +124,8 @@ export function RoomTimeline({ }: Readonly) { const mx = useMatrixClient(); const alive = useAlive(); + + const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive }); const { navigateRoom } = useRoomNavigate(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); @@ -165,7 +168,6 @@ export function RoomTimeline({ return myPowerLevel < sendLevel; }, [powerLevels, mx]); - const [editId, setEditId] = useState(); const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true)); const readUptoEventIdRef = useRef(undefined); @@ -458,7 +460,6 @@ export function RoomTimeline({ room, mx, editor, - alive, nicknames, globalProfiles, spaceId: optionalSpace?.roomId, @@ -467,8 +468,7 @@ export function RoomTimeline({ setReplyDraft, openThreadId, setOpenThread, - setEditId, - onEditorReset, + handleEdit, handleOpenEvent: (id) => { const evtTimeline = getEventTimeline(room, id); const absoluteIndex = evtTimeline diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx index 7c1e830f0..c9374180b 100644 --- a/src/app/features/room/ThreadBrowser.tsx +++ b/src/app/features/room/ThreadBrowser.tsx @@ -15,12 +15,21 @@ import { Icons, Input, Scroll, + Spinner, Text, Avatar, config, Chip, } from 'folds'; -import { MatrixEvent, Room, Thread, ThreadEvent } from '$types/matrix-sdk'; +import { + EventTimelineSet, + MatrixEvent, + NotificationCountType, + Room, + RoomEvent, + Thread, + ThreadEvent, +} from '$types/matrix-sdk'; import { useAtomValue } from 'jotai'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts as LinkifyOpts } from 'linkifyjs'; @@ -53,6 +62,7 @@ import { makeMentionCustomProps, renderMatrixMention, } from '$plugins/react-custom-html-parser'; +import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import { EncryptedContent } from './message'; import * as css from './ThreadDrawer.css'; @@ -60,9 +70,10 @@ type ThreadPreviewProps = { room: Room; thread: Thread; onClick: (threadId: string) => void; + onJump?: () => void; }; -function ThreadPreview({ room, thread, onClick }: ThreadPreviewProps) { +function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -106,8 +117,25 @@ function ThreadPreview({ room, thread, onClick }: ThreadPreviewProps) { (evt) => { evt.stopPropagation(); navigateRoom(room.roomId, thread.id); + onJump?.(); }, - [navigateRoom, room.roomId, thread.id] + [navigateRoom, room.roomId, thread.id, onJump] + ); + + const [, forceUnread] = useState(0); + useEffect(() => { + const onUnread = (_count: unknown, threadId?: string) => { + if (!threadId || threadId === thread.id) forceUnread((n) => n + 1); + }; + room.on(RoomEvent.UnreadNotifications as any, onUnread); + return () => { + room.off(RoomEvent.UnreadNotifications as any, onUnread); + }; + }, [room, thread.id]); + const unreadTotal = room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total); + const unreadHighlight = room.getThreadUnreadNotificationCount( + thread.id, + NotificationCountType.Highlight ); const { rootEvent } = thread; @@ -119,9 +147,15 @@ function ThreadPreview({ room, thread, onClick }: ThreadPreviewProps) { const senderAvatarMxc = getMemberAvatarMxc(room, senderId); const getContent = (() => rootEvent.getContent()) as GetContentCallback; - const replyCount = thread.events.filter( + // Prefer a locally-counted reply count (based on events already in the thread + // timeline) but fall back to thread.length which is the server-reported count + // from bundled m.thread aggregations. This means that for threads discovered + // via fetchRoomThreads() whose timeline hasn't been paginated yet we still + // show the correct count rather than zero. + const localReplyCount = thread.events.filter( (ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev) ).length; + const replyCount = Math.max(thread.length ?? 0, localReplyCount); const lastReply = thread.events .filter((ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev)) @@ -174,7 +208,12 @@ function ThreadPreview({ room, thread, onClick }: ThreadPreviewProps) { dateFormatString={dateFormatString} /> - + + {unreadTotal > 0 && ( + + 0} count={unreadTotal} /> + + )} Jump @@ -253,22 +292,147 @@ type ThreadBrowserProps = { }; export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBrowserProps) { + const mx = useMatrixClient(); const [, forceUpdate] = useState(0); const [query, setQuery] = useState(''); + const [loadingMore, setLoadingMore] = useState(false); + const [canLoadMore, setCanLoadMore] = useState(false); const searchRef = useRef(null); + const threadListTimelineSetRef = useRef(null); - // Re-render when threads change. + // On mount, set up thread event listeners, create the server-side thread + // timeline sets, then fetch page 1 via paginate. The two operations are + // sequenced in a single effect so that createThreadsTimelineSets() always + // resolves before fetchRoomThreads() runs — the SDK's fetchRoomThreadList + // has an early-return guard (`if (this.threadsTimelineSets.length === 0)`) + // that silently no-ops when the sets haven't been created yet, so running + // both in parallel (the old two-effect approach) caused fetchRoomThreads to + // always be a no-op and left threadsReady=true prematurely. useEffect(() => { const onUpdate = () => forceUpdate((n) => n + 1); room.on(ThreadEvent.New as any, onUpdate); room.on(ThreadEvent.Update as any, onUpdate); room.on(ThreadEvent.NewReply as any, onUpdate); + + let cancelled = false; + const loadThreads = async () => { + setLoadingMore(true); + try { + // Create the timeline sets first — required before fetchRoomThreads(). + const sets = await room.createThreadsTimelineSets(); + if (!sets || cancelled) return; + const [allThreadsSet] = sets; + threadListTimelineSetRef.current = allThreadsSet; + + // Now fetch page 1 from the /threads endpoint. threadsTimelineSets is + // populated so fetchRoomThreadList will not early-return. + await room.fetchRoomThreads().catch((err: unknown) => { + // eslint-disable-next-line no-console + console.warn('ThreadBrowser: fetchRoomThreads failed', err); + }); + + // Paginate to load the first page into the timeline set. + const hasMore = await mx.paginateEventTimeline(allThreadsSet.getLiveTimeline(), { + backwards: true, + }); + // Ensure Thread objects exist for server-returned thread roots not yet + // known locally (threads outside the current sliding-sync window). + // fetchRoomThreads() creates Thread objects internally but uses + // room.findEventById() to set rootEvent — if the root event isn't in + // the sliding-sync cache, rootEvent ends up undefined, and + // ThreadPreview returns null for those threads. Backfill here using + // the event we already have from the threads timeline set. + allThreadsSet + .getLiveTimeline() + .getEvents() + .filter((event) => !!event.getId()) + .forEach((event) => { + const id = event.getId()!; + const existingThread = room.getThread(id); + + const bundled = (event.getUnsigned() as any)?.['m.relations']?.['m.thread']; + const bundledCount: number | undefined = + typeof bundled?.count === 'number' ? bundled.count : undefined; + if (!existingThread) { + room.createThread(id, event, [], false); + } else { + if (!existingThread.rootEvent) { + existingThread.rootEvent = event; + existingThread.setEventMetadata(event); + } + // Seed/update replyCount from bundled aggregations. This is needed + // for threads that were created by sliding-sync BEFORE fetchRoomThreads + // ran: SS delivers root events without bundled aggregations, so + // room.createThread() sets replyCount=0 and the SDK's fast-path + // ("replyCount===0 → initialEventsFetched=true, no server fetch") fires. + // Later, fetchRoomThreads() brings events WITH bundled counts, but + // createThread() is idempotent and returns the stale thread unchanged. + // Backfilling replyCount here lets ThreadPreview show the right count + // and lets Case C in ThreadDrawer know there are replies to fetch. + + if (bundledCount !== undefined && (existingThread as any).replyCount === 0) { + (existingThread as any).replyCount = bundledCount; + } + } + }); + if (!cancelled) { + setCanLoadMore(hasMore); + forceUpdate((n) => n + 1); + } + } catch { + // Server doesn't support thread list API; fall back to locally known threads. + } finally { + if (!cancelled) setLoadingMore(false); + } + }; + loadThreads(); + return () => { + cancelled = true; room.off(ThreadEvent.New as any, onUpdate); room.off(ThreadEvent.Update as any, onUpdate); room.off(ThreadEvent.NewReply as any, onUpdate); }; - }, [room]); + }, [room, mx]); + + const handleLoadMore = useCallback(async () => { + const tls = threadListTimelineSetRef.current; + if (!tls || loadingMore) return; + setLoadingMore(true); + try { + const hasMore = await mx.paginateEventTimeline(tls.getLiveTimeline(), { backwards: true }); + tls + .getLiveTimeline() + .getEvents() + .filter((event) => !!event.getId()) + .forEach((event) => { + const id = event.getId()!; + const existingThread = room.getThread(id); + + const bundled = (event.getUnsigned() as any)?.['m.relations']?.['m.thread']; + const bundledCount: number | undefined = + typeof bundled?.count === 'number' ? bundled.count : undefined; + if (!existingThread) { + room.createThread(id, event, [], false); + } else { + if (!existingThread.rootEvent) { + existingThread.rootEvent = event; + existingThread.setEventMetadata(event); + } + + if (bundledCount !== undefined && (existingThread as any).replyCount === 0) { + (existingThread as any).replyCount = bundledCount; + } + } + }); + setCanLoadMore(hasMore); + forceUpdate((n) => n + 1); + } catch { + // ignore + } finally { + setLoadingMore(false); + } + }, [mx, room, loadingMore]); const allThreads = room.getThreads().sort((a: Thread, b: Thread) => { const aTs = a.events.at(-1)?.getTs() ?? a.rootEvent?.getTs() ?? 0; @@ -294,7 +458,7 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr direction="Column" shrink="No" > -
+
@@ -302,9 +466,6 @@ export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBr - - # {room.name} - - {threads.length === 0 ? ( - - - - {lowerQuery ? 'No threads match your search.' : 'No threads yet.'} - - - ) : ( - - {threads.map((thread: Thread) => ( - - ))} - - )} + {(() => { + if (threads.length === 0 && loadingMore) + return ( + + + + ); + if (threads.length === 0) + return ( + + + + {lowerQuery ? 'No threads match your search.' : 'No threads yet.'} + + + ); + return ( + <> + + {threads.map((thread: Thread) => ( + + ))} + + {(loadingMore || canLoadMore) && ( + + {loadingMore ? ( + + ) : ( + + Load more threads + + )} + + )} + + ); + })()} diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index b662d2ab3..3bd829a89 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -1,6 +1,8 @@ import { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; +import { Box, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, config } from 'folds'; import { + Direction, + IEvent, MatrixEvent, PushProcessor, ReceiptType, @@ -13,11 +15,6 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { ReactEditor } from 'slate-react'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts as LinkifyOpts } from 'linkifyjs'; -import { ImageContent, MSticker, RedactedContent, Reply } from '$components/message'; -import { RenderMessageContent } from '$components/RenderMessageContent'; -import { Image } from '$components/media'; -import { ImageViewer } from '$components/image-viewer'; -import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; import { factoryRenderLinkifyWithMention, getReactCustomHtmlParser, @@ -25,25 +22,19 @@ import { makeMentionCustomProps, renderMatrixMention, } from '$plugins/react-custom-html-parser'; -import { - getEditedEvent, - getEventReactions, - getMemberDisplayName, - reactionOrEditEvent, -} from '$utils/room'; +import { getEditedEvent, getMemberDisplayName, reactionOrEditEvent } from '$utils/room'; import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; -import { minuteDifference } from '$utils/time'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nicknamesAtom } from '$state/nicknames'; -import { MessageLayout, MessageSpacing, settingsAtom } from '$state/settings'; +import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { useRoomAbbreviationsContext } from '$hooks/useRoomAbbreviations'; import { buildAbbrReplaceTextNode } from '$components/message/RenderBody'; import { createMentionElement, moveCursor, useEditor } from '$components/editor'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; -import { GetContentCallback, MessageEvent, StateEvent } from '$types/matrix/room'; +import { MessageEvent, StateEvent } from '$types/matrix/room'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useRoomCreators } from '$hooks/useRoomCreators'; @@ -51,7 +42,12 @@ import { useImagePackRooms } from '$hooks/useImagePackRooms'; import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; import { IReplyDraft, roomIdToReplyDraftAtomFamily } from '$state/room/roomInputDrafts'; import { roomToParentsAtom } from '$state/room/roomToParents'; -import { EncryptedContent, Message, Reactions } from './message'; +import { useIgnoredUsers } from '$hooks/useIgnoredUsers'; +import { useGetMemberPowerTag } from '$hooks/useMemberPowerTag'; +import { useMemberEventParser } from '$hooks/useMemberEventParser'; +import { useMessageEdit } from '$hooks/useMessageEdit'; +import { useProcessedTimeline, ProcessedEvent } from '$hooks/timeline/useProcessedTimeline'; +import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer'; import { RoomInput } from './RoomInput'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import * as css from './ThreadDrawer.css'; @@ -86,268 +82,6 @@ export function getThreadReplyEvents(room: Room, threadRootId: string): MatrixEv ); } -type ForwardedMessageProps = { - isForwarded: boolean; - originalTimestamp: number; - originalRoomId: string; - originalEventId: string; - originalEventPrivate: boolean; -}; - -type ThreadMessageProps = { - room: Room; - mEvent: MatrixEvent; - threadRootId: string; - editId: string | undefined; - onEditId: (id?: string) => void; - messageLayout: MessageLayout; - messageSpacing: MessageSpacing; - canDelete: boolean; - canSendReaction: boolean; - canPinEvent: boolean; - imagePackRooms: Room[]; - activeReplyId: string | undefined; - hour24Clock: boolean; - dateFormatString: string; - onUserClick: MouseEventHandler; - onUsernameClick: MouseEventHandler; - onReplyClick: MouseEventHandler; - onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; - onResend?: (event: MatrixEvent) => void; - onDeleteFailedSend?: (event: MatrixEvent) => void; - pushProcessor: PushProcessor; - linkifyOpts: LinkifyOpts; - htmlReactParserOptions: HTMLReactParserOptions; - showHideReads: boolean; - showDeveloperTools: boolean; - onReferenceClick: MouseEventHandler; - jumpToEventId?: string; - collapse?: boolean; -}; - -function ThreadMessage({ - room, - threadRootId: threadRootIdProp, - mEvent, - editId, - onEditId, - messageLayout, - messageSpacing, - canDelete, - canSendReaction, - collapse = false, - canPinEvent, - imagePackRooms, - activeReplyId, - hour24Clock, - dateFormatString, - onUserClick, - onUsernameClick, - onReplyClick, - onReactionToggle, - onResend, - onDeleteFailedSend, - pushProcessor, - linkifyOpts, - htmlReactParserOptions, - showHideReads, - showDeveloperTools, - onReferenceClick, - jumpToEventId, -}: ThreadMessageProps) { - // Use the thread's own timeline set so reactions/edits on thread events are found correctly - const threadTimelineSet = room.getThread(threadRootIdProp)?.timelineSet; - const timelineSet = threadTimelineSet ?? room.getUnfilteredTimelineSet(); - const mEventId = mEvent.getId()!; - const senderId = mEvent.getSender() ?? ''; - const nicknames = useAtomValue(nicknamesAtom); - const senderDisplayName = - getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; - - const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); - const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); - const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); - const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; - const [autoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); - - const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); - const editedNewContent = editedEvent?.getContent()['m.new_content']; - const baseContent = mEvent.getContent(); - const safeContent = - Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); - const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; - - const reactionRelations = getEventReactions(timelineSet, mEventId); - const reactions = reactionRelations?.getSortedAnnotationsByKey(); - const hasReactions = reactions && reactions.length > 0; - - const pushActions = pushProcessor.actionsForEvent(mEvent); - let notifyHighlight: 'silent' | 'loud' | undefined; - if (pushActions?.notify && pushActions.tweaks?.highlight) { - notifyHighlight = pushActions.tweaks?.sound ? 'loud' : 'silent'; - } - - // Extract message forwarding info - const forwardContent = safeContent['moe.sable.message.forward'] as - | { - original_timestamp?: unknown; - original_room_id?: string; - original_event_id?: string; - original_event_private?: boolean; - } - | undefined; - - const messageForwardedProps: ForwardedMessageProps | undefined = forwardContent - ? { - isForwarded: true, - originalTimestamp: - typeof forwardContent.original_timestamp === 'number' - ? forwardContent.original_timestamp - : mEvent.getTs(), - originalRoomId: forwardContent.original_room_id ?? room.roomId, - originalEventId: forwardContent.original_event_id ?? '', - originalEventPrivate: forwardContent.original_event_private ?? false, - } - : undefined; - - const { replyEventId } = mEvent; - - const relation = mEvent.getRelation(); - const contentRelatesTo = mEvent.getContent()?.['m.relates_to']; - const isFallback = - relation?.is_falling_back === true || contentRelatesTo?.is_falling_back === true; - - return ( - - ) - } - reactions={ - hasReactions ? ( - - ) : undefined - } - > - {mEvent.isRedacted() ? ( - - ) : ( - - {() => { - if (mEvent.isRedacted()) - return ( - - ); - - if (mEvent.getType() === MessageEvent.Sticker) - return ( - ( - { - if (!autoplayStickers && p.src) { - return ( - - - - ); - } - return ; - }} - renderViewer={(p) => } - /> - )} - /> - ); - - if (mEvent.getType() === MessageEvent.RoomMessage) { - return ( - - ); - } - - return ( - - ); - }} - - )} - - ); -} - type ThreadDrawerProps = { room: Room; threadRootId: string; @@ -359,12 +93,20 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const mx = useMatrixClient(); const drawerRef = useRef(null); const editor = useEditor(); - const [, forceUpdate] = useState(0); - const [editId, setEditId] = useState(undefined); + const [forceUpdateCounter, forceUpdate] = useState(0); const [jumpToEventId, setJumpToEventId] = useState(undefined); + const [loadingOlderReplies, setLoadingOlderReplies] = useState(false); + // Ref guard so the auto-paginate effect never starts a second concurrent request. + const paginatingOlderRef = useRef(false); const scrollRef = useRef(null); const prevReplyCountRef = useRef(0); - const replyEventsRef = useRef([]); + const processedEventsRef = useRef([]); + // Track whether we've already attempted a server fetch for a given threadRootId. + // Prevents infinite re-fetch loops on genuinely empty threads while still + // allowing the first fetch even when thread.length === 0 (sliding sync case + // where bundled aggregations are absent so replyCount defaults to 0). + const serverFetchAttemptedRef = useRef(null); + const { editId, handleEdit } = useMessageEdit(editor); const nicknames = useAtomValue(nicknamesAtom); const pushProcessor = useMemo(() => new PushProcessor(mx), [mx]); const useAuthentication = useMediaAuthentication(); @@ -378,6 +120,20 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [hideReads] = useSetting(settingsAtom, 'hideReads'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); + const [clientUrlPreview] = useSetting(settingsAtom, 'clientUrlPreview'); + const [encClientUrlPreview] = useSetting(settingsAtom, 'encClientUrlPreview'); + const [autoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); + const [autoplayEmojis] = useSetting(settingsAtom, 'autoplayEmojis'); + const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); + const [showTombstoneEvents] = useSetting(settingsAtom, 'showTombstoneEvents'); + const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly'); + const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; + const showClientUrlPreview = room.hasEncryptionStateEvent() + ? clientUrlPreview && encClientUrlPreview + : clientUrlPreview; // Memoized parsing options const linkifyOpts = useMemo( @@ -406,6 +162,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra handleSpoilerClick: spoilerClickHandler, handleMentionClick: mentionClickHandler, nicknames, + autoplayEmojis, replaceTextNode: buildAbbrReplaceTextNode(abbrMap), }), [ @@ -416,6 +173,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra mentionClickHandler, useAuthentication, nicknames, + autoplayEmojis, abbrMap, ] ); @@ -428,6 +186,17 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); + const isReadOnly = useMemo(() => { + const myPowerLevel = powerLevels?.users?.[mx.getUserId()!] ?? powerLevels?.users_default ?? 0; + const sendLevel = powerLevels?.events?.['m.room.message'] ?? powerLevels?.events_default ?? 0; + return myPowerLevel < sendLevel; + }, [powerLevels, mx]); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const parseMemberEvent = useMemberEventParser(); + + // Ignored users + const ignoredUsersList = useIgnoredUsers(); + const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); // Image packs const roomToParents = useAtomValue(roomToParentsAtom); @@ -441,31 +210,116 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra // User profile popup const openUserRoomProfile = useOpenUserRoomProfile(); - const rootEvent = room.findEventById(threadRootId); - - // When the drawer is opened with classic sync (no server-side thread support), - // room.createThread() may have been called with empty initialEvents so - // thread.events only has the root. Backfill events from the main room - // timeline so the authoritative source is populated for subsequent renders. + // Thread timeline data for useProcessedTimeline + const thread = room.getThread(threadRootId); + const threadTimeline = thread?.timelineSet.getLiveTimeline(); + + // Prefer the event from the main timeline (already indexed), but fall back + // to thread.rootEvent — populated from bundled /threads server data even when + // the root is outside the currently-loaded timeline window. + const rootEvent = room.findEventById(threadRootId) ?? thread?.rootEvent; + const totalEvents = threadTimeline?.getEvents().length ?? 0; + const linkedTimelines = useMemo( + () => (threadTimeline ? [threadTimeline] : []), + // eslint-disable-next-line react-hooks/exhaustive-deps + [threadTimeline, totalEvents] + ); + const items = useMemo(() => Array.from({ length: totalEvents }, (_, i) => i), [totalEvents]); + + const processedEvents = useProcessedTimeline({ + items, + linkedTimelines, + skipThreadFilter: true, + ignoredUsersSet, + showHiddenEvents, + showTombstoneEvents, + mxUserId: mx.getUserId(), + readUptoEventId: undefined, + hideMembershipEvents: true, + hideNickAvatarEvents: true, + isReadOnly, + hideMemberInReadOnly, + }); + + // When the thread's own timeline is empty (server-side threads not yet fetched, + // or classic sync before backfill completes), fall back to scanning the main + // room timeline directly so replies are shown immediately. + const displayReplies = useMemo((): ProcessedEvent[] => { + const filtered = processedEvents.filter((e) => e.id !== threadRootId); + if (filtered.length > 0) return filtered; + const timelineSet = thread?.timelineSet ?? room.getUnfilteredTimelineSet(); + return getThreadReplyEvents(room, threadRootId).map((ev, idx) => ({ + id: ev.getId()!, + itemIndex: idx, + mEvent: ev, + timelineSet, + eventSender: ev.getSender() ?? null, + collapsed: false, + willRenderNewDivider: false, + willRenderDayDivider: false, + })); + // forceUpdateCounter makes this recompute whenever events arrive + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [room, threadRootId, thread, processedEvents, forceUpdateCounter]); + + processedEventsRef.current = displayReplies; + + const processedReplies = displayReplies; + + // Ensure the Thread object exists and has its reply events loaded. + // + // Case A — no Thread object yet (e.g. root event was loaded via jump/backward + // pagination but the SDK hasn't created a Thread shell for it): + // Call room.createThread() so the SDK constructor kicks off + // updateThreadMetadata() → resetLiveTimeline() + paginateEventTimeline() + // automatically. ThreadEvent.Update fires when done → forceUpdate → re-render. + // DO NOT call resetLiveTimeline() or paginateEventTimeline() ourselves — + // that would race with the SDK's own initialization and cause a flood of + // "Ignoring event … does not belong in timeline" warnings. + // + // If the root is also outside the local timeline (e.g. a thread discovered + // via ThreadBrowser but whose root predates the sliding-sync window), we + // fetch it bare with fetchRoomEvent — this does NOT add it to the main + // room timeline or cause any TimelineRefresh side-effects. // - // IMPORTANT: skip this backfill when server-side thread support is active - // (initialEventsFetched starts false). In that case the SDK will call - // updateThreadMetadata() → resetLiveTimeline() + paginateEventTimeline() - // automatically. Calling thread.addEvents() ourselves first would trigger - // that same cascade prematurely and cause a flood of - // "EventTimelineSet.addEventToTimeline: Ignoring event=…" warnings because - // canContain() fails while the timeline is in the middle of being reset and - // repopulated. + // Case B — Thread exists but initialEventsFetched is false (server-side thread + // support active; SDK's updateThreadMetadata() is running): + // Do nothing — the SDK will fire ThreadEvent.Update when done. + // + // Case C — Thread exists, initialEventsFetched is true (classic sync or SDK + // has already finished initialising): + // With classic sync the SDK never paginates thread timelines, so we backfill + // from the main live timeline. With server-side threads the SDK already + // populated thread.events; this block is a no-op in that case. useEffect(() => { - const thread = room.getThread(threadRootId); - if (!thread) return; - // initialEventsFetched === false ↔ Thread.hasServerSideSupport is set. - // The SDK handles initialization itself; our manual backfill must not run. - if (!thread.initialEventsFetched) return; - const hasRepliesInThread = thread.events.some( + // Case A: create thread shell; SDK handles the rest asynchronously. + if (!room.getThread(threadRootId)) { + const localRoot = room.findEventById(threadRootId); + if (localRoot) { + room.createThread(threadRootId, localRoot, [], false); + } else { + // Root not in local timeline — fetch it from the server without + // touching the main timeline (no TimelineRefresh side-effect). + mx.fetchRoomEvent(room.roomId, threadRootId) + .then((rawEvt) => { + if (room.getThread(threadRootId)) return; // created concurrently + room.createThread(threadRootId, new MatrixEvent(rawEvt as IEvent), [], false); + }) + .catch(() => {}); + } + } + + const currThread = room.getThread(threadRootId); + // Case B: SDK is actively initialising — don't interfere. + if (!currThread || !currThread.initialEventsFetched) return; + + // Case C: SDK is done (or classic sync). Backfill from live timeline if + // thread.events is still empty (classic sync path; server-side was already + // populated by paginateEventTimeline inside updateThreadMetadata). + const hasRepliesInThread = currThread.events.some( (ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev) ); - if (hasRepliesInThread) return; // already populated, nothing to do + if (hasRepliesInThread) return; const liveEvents = room .getUnfilteredTimelineSet() @@ -478,9 +332,44 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra !reactionOrEditEvent(ev) ); if (liveEvents.length > 0) { - thread.addEvents(liveEvents, false); + // thread.addEvents() is typed as void but is internally async; schedule + // forceUpdate in a microtask so the timeline has been updated first. + currThread.addEvents(liveEvents, false); + Promise.resolve().then(() => forceUpdate((n) => n + 1)); + return; } - }, [room, threadRootId]); + + // No local events anywhere. We always attempt one server fetch here because + // with sliding sync the server omits bundled aggregations from subscription + // timeline events, leaving thread.replyCount at 0 even when the server has + // replies (the old `if (!currThread.length) return` guard incorrectly bailed + // out in that case). We use serverFetchAttemptedRef to ensure we only try + // once per threadRootId — if the paginate truly returns nothing, the ref + // prevents an infinite re-fetch loop. + if (serverFetchAttemptedRef.current === threadRootId) return; + serverFetchAttemptedRef.current = threadRootId; + + // Do NOT call resetLiveTimeline() here. Resetting a thread timeline emits + // RoomEvent.TimelineReset on the thread's EventTimelineSet; Thread's own + // onTimelineReset handler then awaits processRootEventPromise (a live + // fetchRootEvent network call). If that promise is still pending this + // creates a deadlock that hangs and eventually crashes the app — observed + // on both classic sync and sliding sync. + // + // Calling paginateEventTimeline directly is safe: the thread's back-token + // is null (set by the fast path when replyCount was 0 at construction), but + // the SDK maps null → undefined for the `from` parameter and calls + // fetchRelations without a cursor, which correctly fetches the latest + // replies from the server. + mx.paginateEventTimeline(currThread.timelineSet.getLiveTimeline(), { backwards: true }) + .then(() => forceUpdate((n) => n + 1)) + .catch(() => {}); + // forceUpdateCounter must be in deps so this effect re-runs after + // ThreadEvent.Update fires (which flips initialEventsFetched from false to + // true). Without it the effect runs once on mount, hits Case B + // (initialEventsFetched=false) and never runs again — leaving the drawer + // empty on sliding sync where replyCount starts at 0. + }, [mx, room, threadRootId, forceUpdate, forceUpdateCounter]); // Re-render when new thread events arrive (including reactions via ThreadEvent.Update). useEffect(() => { @@ -535,10 +424,10 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra // Mark thread as read when viewing it useEffect(() => { const markThreadAsRead = async () => { - const thread = room.getThread(threadRootId); - if (!thread) return; + const currentThread = room.getThread(threadRootId); + if (!currentThread) return; - const events = thread.events || []; + const events = currentThread.events || []; if (events.length === 0) return; const lastEvent = events[events.length - 1]; @@ -547,7 +436,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const userId = mx.getUserId(); if (!userId) return; - const readUpToId = thread.getEventReadUpTo(userId, false); + const readUpToId = currentThread.getEventReadUpTo(userId, false); const lastEventId = lastEvent.getId(); // Only send receipt if we haven't already read up to the last event @@ -563,11 +452,35 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra // Mark as read when opened and when new messages arrive markThreadAsRead(); - }, [mx, room, threadRootId, forceUpdate]); + }, [mx, room, threadRootId, forceUpdateCounter]); const replyEvents = getThreadReplyEvents(room, threadRootId); + const threadObjForLoading = room.getThread(threadRootId); + const isThreadLoading = + !!threadObjForLoading && !threadObjForLoading.initialEventsFetched && replyEvents.length === 0; - replyEventsRef.current = replyEvents; + // Automatically paginate backwards until all older replies are loaded. + // Re-runs on each forceUpdateCounter tick so each completed page triggers the next. + useEffect(() => { + const t = room.getThread(threadRootId); + if (!t || !t.initialEventsFetched || paginatingOlderRef.current) return; + const backToken = t.timelineSet.getLiveTimeline().getPaginationToken(Direction.Backward); + if (backToken == null) { + setLoadingOlderReplies(false); + return; + } + paginatingOlderRef.current = true; + setLoadingOlderReplies(true); + mx.paginateEventTimeline(t.timelineSet.getLiveTimeline(), { backwards: true }) + .then(() => { + paginatingOlderRef.current = false; + forceUpdate((n) => n + 1); + }) + .catch(() => { + paginatingOlderRef.current = false; + setLoadingOlderReplies(false); + }); + }, [mx, room, threadRootId, forceUpdateCounter]); // Auto-scroll to bottom when event count grows (if the user is near the bottom). useEffect(() => { @@ -577,8 +490,8 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra if (prevReplyCountRef.current === 0 || isAtBottom) { el.scrollTop = el.scrollHeight; } - prevReplyCountRef.current = replyEvents.length; - }, [replyEvents.length]); + prevReplyCountRef.current = processedReplies.length; + }, [processedReplies.length]); const handleUserClick: MouseEventHandler = useCallback( (evt) => { @@ -670,16 +583,29 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra [mx, room, threadRootId] ); - const handleEdit = useCallback( - (evtId?: string) => { - setEditId(evtId); - if (!evtId) { - ReactEditor.focus(editor); - moveCursor(editor); + const handleEditLastMessage = useCallback(() => { + const userId = mx.getUserId(); + const ownReply = [...processedEventsRef.current] + .reverse() + .find( + (e) => + e.id !== threadRootId && + e.mEvent.getSender() === userId && + !e.mEvent.isRedacted() && + !reactionOrEditEvent(e.mEvent) + ); + const ownId = ownReply?.id; + if (ownId) { + handleEdit(ownId); + const el = drawerRef.current; + if (el) { + el.querySelector(`[data-message-id="${ownId}"]`)?.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); } - }, - [editor] - ); + } + }, [mx, threadRootId, handleEdit]); const handleResend = useCallback( (event: MatrixEvent) => { @@ -700,7 +626,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const targetId = evt.currentTarget.getAttribute('data-event-id'); if (!targetId) return; const isRoot = targetId === threadRootId; - const isInReplies = replyEventsRef.current.some((e) => e.getId() === targetId); + const isInReplies = processedEventsRef.current.some((e) => e.id === targetId); if (!isRoot && !isInReplies) return; setJumpToEventId(targetId); setTimeout(() => setJumpToEventId(undefined), 2500); @@ -713,42 +639,63 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra [threadRootId] ); - const sharedMessageProps = { + // Map jumpToEventId to a focusItem index for useTimelineEventRenderer highlighting + const jumpIndex = jumpToEventId ? processedEvents.findIndex((e) => e.id === jumpToEventId) : -1; + const focusItem = + jumpIndex >= 0 + ? { index: processedEvents[jumpIndex].itemIndex, highlight: true, scrollTo: false as const } + : undefined; + + const renderMatrixEvent = useTimelineEventRenderer({ room, - threadRootId, - editId, - onEditId: handleEdit, - messageLayout, - messageSpacing, - canDelete: canRedact || canDeleteOwn, - canSendReaction, - canPinEvent, - imagePackRooms, - activeReplyId, - hour24Clock, - dateFormatString, - onUserClick: handleUserClick, - onUsernameClick: handleUsernameClick, - onReplyClick: handleReplyClick, - onReactionToggle: handleReactionToggle, - onResend: handleResend, - onDeleteFailedSend: handleDeleteFailedSend, + mx, pushProcessor, - linkifyOpts, - htmlReactParserOptions, - showHideReads: hideReads, - showDeveloperTools, - onReferenceClick: handleOpenReply, - jumpToEventId, - }; + nicknames, + imagePackRooms, + settings: { + messageLayout, + messageSpacing, + hideReads, + showDeveloperTools, + hour24Clock, + dateFormatString, + mediaAutoLoad, + showUrlPreview, + showClientUrlPreview, + autoplayStickers, + hideMemberInReadOnly, + isReadOnly, + hideMembershipEvents: true, + hideNickAvatarEvents: true, + showHiddenEvents, + hideThreadChip: true, + }, + state: { focusItem, editId, activeReplyId, openThreadId: threadRootId }, + permissions: { + canRedact, + canDeleteOwn, + canSendReaction, + canPinEvent, + }, + callbacks: { + onUserClick: handleUserClick, + onUsernameClick: handleUsernameClick, + onReplyClick: handleReplyClick, + onReactionToggle: handleReactionToggle, + onEditId: handleEdit, + onResend: handleResend, + onDeleteFailedSend: handleDeleteFailedSend, + setOpenThread: () => {}, + handleOpenReply, + }, + utils: { htmlReactParserOptions, linkifyOpts, getMemberPowerTag, parseMemberEvent }, + }); // Latest thread event for the following indicator (latest reply, or root if no replies) const threadParticipantIds = new Set( - [rootEvent, ...replyEvents].map((ev) => ev?.getSender()).filter(Boolean) as string[] + processedEvents.map((e) => e.mEvent.getSender()).filter(Boolean) as string[] ); - const latestThreadEventId = ( - replyEvents.length > 0 ? replyEvents[replyEvents.length - 1] : rootEvent - )?.getId(); + const latestThreadEventId = processedEvents.at(-1)?.id ?? rootEvent?.getId(); return ( - - # {room.name} - - + {renderMatrixEvent( + rootEvent.getType(), + typeof rootEvent.getStateKey() === 'string', + rootEvent.getId()!, + rootEvent, + processedEvents.find((e) => e.id === threadRootId)?.itemIndex ?? 0, + thread?.timelineSet ?? room.getUnfilteredTimelineSet(), + false + )} )} @@ -816,55 +768,75 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra hideTrack style={{ flexGrow: 1 }} > - {replyEvents.length === 0 ? ( - - - - No replies yet. Start the thread below! - - - ) : ( - <> - {/* Reply count label inside scroll area */} - - - {replyEvents.length} {replyEvents.length === 1 ? 'reply' : 'replies'} - - - - {replyEvents.map((mEvent, i) => { - const prevEvent = i > 0 ? replyEvents[i - 1] : undefined; - const collapse = - prevEvent !== undefined && - prevEvent.getSender() === mEvent.getSender() && - prevEvent.getType() === mEvent.getType() && - minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; - return ( - - ); - })} - - - )} + {(() => { + if (isThreadLoading) + return ( + + + + ); + if (processedReplies.length === 0) + return ( + + + + No replies yet. Start the thread below! + + + ); + return ( + <> + {/* Spinner shown while older replies are being auto-loaded */} + {loadingOlderReplies && ( + + + + )} + {/* Reply count label inside scroll area */} + + + {processedReplies.length} {processedReplies.length === 1 ? 'reply' : 'replies'} + + + + {processedReplies.map((e) => + renderMatrixEvent( + e.mEvent.getType(), + typeof e.mEvent.getStateKey() === 'string', + e.id, + e.mEvent, + e.itemIndex, + e.timelineSet, + e.collapsed + ) + )} + + + ); + })()} @@ -878,6 +850,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra threadRootId={threadRootId} editor={editor} fileDropContainerRef={drawerRef} + onEditLastMessage={handleEditLastMessage} /> {hideReads ? ( diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index f1f799308..44e9500a2 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -20,6 +20,12 @@ export interface UseProcessedTimelineOptions { hideNickAvatarEvents: boolean; isReadOnly: boolean; hideMemberInReadOnly: boolean; + /** + * When true, skip the filter that removes events whose `threadRootId` points + * to a different event. Required when processing a thread's own timeline + * where every reply legitimately has `threadRootId` set to the root. + */ + skipThreadFilter?: boolean; } export interface ProcessedEvent { @@ -55,6 +61,7 @@ export function useProcessedTimeline({ hideNickAvatarEvents, isReadOnly, hideMemberInReadOnly, + skipThreadFilter, }: UseProcessedTimelineOptions): ProcessedEvent[] { return useMemo(() => { let prevEvent: MatrixEvent | undefined; @@ -116,7 +123,7 @@ export function useProcessedTimeline({ } } - if (threadRootId !== undefined && threadRootId !== mEventId) return acc; + if (!skipThreadFilter && threadRootId !== undefined && threadRootId !== mEventId) return acc; const isReactionOrEdit = reactionOrEditEvent(mEvent); if (isReactionOrEdit) return acc; @@ -193,5 +200,6 @@ export function useProcessedTimeline({ hideNickAvatarEvents, isReadOnly, hideMemberInReadOnly, + skipThreadFilter, ]); } diff --git a/src/app/hooks/timeline/useTimelineActions.ts b/src/app/hooks/timeline/useTimelineActions.ts index e7bb45f68..621e92aef 100644 --- a/src/app/hooks/timeline/useTimelineActions.ts +++ b/src/app/hooks/timeline/useTimelineActions.ts @@ -5,13 +5,12 @@ import { ReactEditor } from 'slate-react'; import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; import { getMemberDisplayName, getEditedEvent } from '$utils/room'; -import { createMentionElement, isEmptyEditor, moveCursor } from '$components/editor'; +import { createMentionElement, moveCursor } from '$components/editor'; export interface UseTimelineActionsOptions { room: Room; mx: MatrixClient; editor: Editor; - alive: () => boolean; nicknames: Record; globalProfiles: Record; spaceId?: string; @@ -27,8 +26,7 @@ export interface UseTimelineActionsOptions { setReplyDraft: (draft: any) => void; openThreadId?: string; setOpenThread: (threadId: string | undefined) => void; - setEditId: (editId: string | undefined) => void; - onEditorReset?: () => void; + handleEdit: (editId?: string) => void; handleOpenEvent: (eventId: string) => void; } @@ -36,7 +34,6 @@ export function useTimelineActions({ room, mx, editor, - alive, nicknames, globalProfiles, spaceId, @@ -45,8 +42,7 @@ export function useTimelineActions({ setReplyDraft, openThreadId, setOpenThread, - setEditId, - onEditorReset, + handleEdit, handleOpenEvent, }: UseTimelineActionsOptions) { const handleOpenReply: MouseEventHandler = useCallback( @@ -209,24 +205,6 @@ export function useTimelineActions({ [mx] ); - const handleEdit = useCallback( - (targetEditId?: string) => { - if (targetEditId) { - setEditId(targetEditId); - return; - } - setEditId(undefined); - - requestAnimationFrame(() => { - if (!alive()) return; - if (isEmptyEditor(editor)) onEditorReset?.(); - ReactEditor.focus(editor); - moveCursor(editor); - }); - }, - [editor, alive, onEditorReset, setEditId] - ); - return { handleOpenReply, handleUserClick, diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index f7dca411a..9b56ea774 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -1,10 +1,14 @@ -import { MouseEventHandler, useCallback, useMemo } from 'react'; +import { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import { + IThreadBundledRelationship, MatrixClient, MatrixEvent, + NotificationCountType, Room, + RoomEvent, + ThreadEvent, PushProcessor, EventTimelineSet, IContent, @@ -47,6 +51,7 @@ import { } from '$utils/room'; import { getLinkedTimelines, getLiveTimeline } from '$utils/timeline'; import * as customHtmlCss from '$styles/CustomHtml.css'; +import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import { EncryptedContent, Event, @@ -105,20 +110,49 @@ function ThreadReplyChip({ const useAuthentication = useMediaAuthentication(); const nicknames = useAtomValue(nicknamesAtom); + const [counter, forceUpdate] = useState(0); + const thread = room.getThread(mEventId); + useEffect(() => { + if (!thread) return () => {}; + const onUpdate = () => forceUpdate((n) => n + 1); + thread.on(ThreadEvent.NewReply as any, onUpdate); + thread.on(ThreadEvent.Update as any, onUpdate); + room.on(RoomEvent.Redaction as any, onUpdate); + return () => { + thread.off(ThreadEvent.NewReply as any, onUpdate); + thread.off(ThreadEvent.Update as any, onUpdate); + room.off(RoomEvent.Redaction as any, onUpdate); + }; + }, [room, thread]); + const replyEvents = useMemo(() => { + // With threadSupport:true, reply events live in thread.timelineSet not the main room timeline. + // Prefer thread.events when available so avatars and preview text are populated. + if (thread) { + const fromThread = thread.events.filter( + (ev) => ev.getId() !== mEventId && !reactionOrEditEvent(ev) + ); + if (fromThread.length > 0) return fromThread; + } const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); return linkedTimelines .flatMap((tl) => tl.getEvents()) .filter( (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) ); - }, [room, mEventId]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- counter is a cache-busting key, not used directly in body + }, [room, mEventId, thread, counter]); if (!thread) return null; - const replyCount = thread.length ?? 0; + // Prefer the server-authoritative bundled count. thread.length only reflects + // events fetched into the local timeline, which can be much lower than the + // true total before the thread drawer is first opened and paginated. + const bundledCount = + thread.rootEvent?.getServerAggregatedRelation('m.thread')?.count; + const replyCount = bundledCount ?? thread.length ?? 0; if (replyCount === 0) return null; const uniqueSenders: string[] = []; @@ -146,6 +180,12 @@ function ThreadReplyChip({ const isOpen = openThreadId === mEventId; + const unreadTotal = room.getThreadUnreadNotificationCount(mEventId, NotificationCountType.Total); + const unreadHighlight = room.getThreadUnreadNotificationCount( + mEventId, + NotificationCountType.Highlight + ); + return ( )} + {unreadTotal > 0 && ( + + 0} count={unreadTotal} /> + + )} ); } @@ -226,6 +271,7 @@ export interface TimelineEventRendererOptions { hideMembershipEvents: boolean; hideNickAvatarEvents: boolean; showHiddenEvents: boolean; + hideThreadChip?: boolean; }; state: { focusItem?: { index: number; highlight: boolean; scrollTo: boolean }; @@ -280,6 +326,7 @@ export function useTimelineEventRenderer({ hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents, + hideThreadChip, }, state: { focusItem, editId, activeReplyId, openThreadId }, permissions: { canRedact, canDeleteOwn, canSendReaction, canPinEvent }, @@ -309,9 +356,16 @@ export function useTimelineEventRenderer({ isRedacted, getUnsigned, getTs, - replyEventId, + getWireContent, + replyEventId: rawReplyEventId, threadRootId, } = mEvent; + // In the thread drawer (hideThreadChip=true), suppress reply headers for events + // that only have m.in_reply_to as a non-thread-client fallback (is_falling_back: true). + const replyEventId = + hideThreadChip && getWireContent.call(mEvent)?.['m.relates_to']?.is_falling_back + ? undefined + : rawReplyEventId; const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations?.getSortedAnnotationsByKey(); @@ -400,7 +454,7 @@ export function useTimelineEventRenderer({ room={room} timelineSet={timelineSet} replyEventId={replyEventId} - threadRootId={threadRootId} + threadRootId={hideThreadChip ? undefined : threadRootId} mentions={baseContent['m.mentions']} onClick={handleOpenReply} /> @@ -408,7 +462,7 @@ export function useTimelineEventRenderer({ } reactions={(() => { const threadChip = - room.getThread(mEventId) || threadRootId ? ( + !hideThreadChip && (room.getThread(mEventId) || threadRootId) ? ( ) } reactions={(() => { const threadChip = - room.getThread(mEventId) || threadRootId ? ( + !hideThreadChip && (room.getThread(mEventId) || threadRootId) ? ( @@ -691,7 +755,7 @@ export function useTimelineEventRenderer({ } reactions={(() => { const threadChip = - room.getThread(mEventId) || threadRootId ? ( + !hideThreadChip && (room.getThread(mEventId) || threadRootId) ? ( void; + alive?: () => boolean; +} + +/** + * Manages the "edit mode" state for a message composer. + * + * Centralises the `editId` state and the `handleEdit` callback so both + * `RoomTimeline` and `ThreadDrawer` share identical behaviour: setting an + * edit target activates the editor for that event; clearing it resets the + * editor and returns focus. + */ +export function useMessageEdit( + editor: Editor, + options?: UseMessageEditOptions +): { editId: string | undefined; handleEdit: (editId?: string) => void } { + const [editId, setEditId] = useState(undefined); + + // Use refs so the callback never goes stale on options changes. + const aliveRef = useRef(options?.alive); + aliveRef.current = options?.alive; + const onResetRef = useRef(options?.onReset); + onResetRef.current = options?.onReset; + + const handleEdit = useCallback( + (targetEditId?: string) => { + if (targetEditId) { + setEditId(targetEditId); + return; + } + setEditId(undefined); + requestAnimationFrame(() => { + if (aliveRef.current && !aliveRef.current()) return; + if (onResetRef.current && isEmptyEditor(editor)) onResetRef.current(); + ReactEditor.focus(editor); + moveCursor(editor); + }); + }, + [editor] + ); + + return { editId, handleEdit }; +} diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 093093ba2..f6f428a10 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -429,6 +429,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): await mx.startClient({ lazyLoadMembers: true, pollTimeout: FAST_SYNC_POLL_TIMEOUT_MS, + threadSupport: true, }); // Attach an ongoing classic-sync observer — equivalent to SlidingSyncManager's // onLifecycle listener. Tracks state transitions, initial-sync timing, and errors. @@ -581,6 +582,7 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): await mx.startClient({ lazyLoadMembers: true, slidingSync: manager.slidingSync, + threadSupport: true, }); } catch (err) { debugLog.error('network', 'Failed to start client with sliding sync', {