import React, { MouseEventHandler, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { MatrixEvent, RelationType, Room, RoomEvent, ThreadEvent, } from 'matrix-js-sdk'; import { useAtomValue, useSetAtom } from 'jotai'; import { Badge, Box, IconButton, Line, Scroll, Text, Tooltip, TooltipProvider, config } from 'folds'; import { Icon, Icons } from '../../components/icons'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useKeyDown } from '../../hooks/useKeyDown'; import { editableActiveElement } from '../../utils/dom'; import { useEditor, createMentionElement, moveCursor, } from '../../components/editor'; import { useSetting } from '../../state/hooks/settings'; import { MessageLayout, settingsAtom } from '../../state/settings'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useAccessiblePowerTagColors, useGetMemberPowerTag, } from '../../hooks/useMemberPowerTag'; import { combineEmbedFilters, useRoomEmbedFilters, useRoomWideEmbedFilters, } from '../../hooks/useRoomEmbedFilters'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useIsDirectRoom } from '../../hooks/useRoom'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; import { RoomInput } from './RoomInput'; import { RoomViewTyping } from './RoomViewTyping'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { Message, Reactions, EncryptedContent } from './message'; import { Reply } from '../../components/message'; import { RenderMessageContent } from '../../components/RenderMessageContent'; import { LINKIFY_OPTS, factoryRenderLinkifyWithMention, getReactCustomHtmlParser, makeMentionCustomProps, renderMatrixMention, } from '../../plugins/react-custom-html-parser'; import { getEditedEvent, getEventReactions, getMemberDisplayName, getReactionContent, reactionOrEditEvent, } from '../../utils/room'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart, } from '../../utils/matrix'; import { GetContentCallback, MessageEvent } from '../../../types/matrix/room'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; import { autoScrollToBottom, scrollElementIntoView } from '../../utils/dom'; type ThreadViewProps = { room: Room; threadRootId: string; }; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { const { code } = evt; if (evt.metaKey || evt.altKey || evt.ctrlKey) { return false; } if (FN_KEYS_REGEX.test(code)) return false; if ( code.startsWith('OS') || code.startsWith('Meta') || code.startsWith('Shift') || code.startsWith('Alt') || code.startsWith('Control') || code.startsWith('Arrow') || code.startsWith('Page') || code.startsWith('End') || code.startsWith('Home') || code === 'Tab' || code === 'Space' || code === 'Enter' || code === 'NumLock' || code === 'ScrollLock' ) { return false; } return true; }; /** * Renders a thread inline, replacing the main room timeline. * Structured as direct flex siblings of `Page` to match the RoomView layout contract. */ export function ThreadView({ room, threadRootId }: ThreadViewProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(room.roomId)); const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); // eslint-disable-next-line no-console console.log('[ThreadView] Component mount/render', { roomId: room.roomId, threadRootId, allThreadsInRoom: room.getThreads().map(t => ({ id: t.id, rootEventId: t.rootEvent?.getId(), length: t.length, hasCurrentUserParticipated: t.hasCurrentUserParticipated, })), }); const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [developerTools] = useSetting(settingsAtom, 'developerTools'); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const direct = useIsDirectRoom(); 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 accessiblePowerTagColors = useAccessiblePowerTagColors( theme.kind, creatorsTag, powerLevelTags ); const permissions = useRoomPermissions(creators, powerLevels); const canRedact = permissions.action('redact', mx.getSafeUserId()); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); const canPinEvent = permissions.stateEvent('m.room.pinned_events', mx.getSafeUserId()); const roomToParents = useAtomValue(roomToParentsAtom); const imagePackRooms = useImagePackRooms(room.roomId, roomToParents); const space = useSpaceOptionally(); const openUserRoomProfile = useOpenUserRoomProfile(); const ignoredUsersList = useIgnoredUsers(); const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); const [personalEmbedFilters] = useRoomEmbedFilters(room); const roomWideEmbedFilters = useRoomWideEmbedFilters(room); const combinedEmbedFilters = useMemo( () => combineEmbedFilters(personalEmbedFilters, roomWideEmbedFilters), [personalEmbedFilters, roomWideEmbedFilters] ); 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, spoilerClickHandler, mentionClickHandler, useAuthentication] ); const jumboHtmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, handleMentionClick: mentionClickHandler, jumbo: true, }), [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] ); const thread = room.getThread(threadRootId); const roomTimelineSet = room.getUnfilteredTimelineSet(); // If thread doesn't exist, try to find it by checking if threadRootId is a root event React.useEffect(() => { if (!thread) { // eslint-disable-next-line no-console console.log('[ThreadView] Thread not found, checking if event exists in room timeline'); const rootEvent = room.findEventById(threadRootId); // eslint-disable-next-line no-console console.log('[ThreadView] Root event lookup', { threadRootId, rootEventFound: !!rootEvent, rootEventType: rootEvent?.getType(), rootEventThreadRootId: rootEvent?.threadRootId, allEventIdsInTimeline: roomTimelineSet.getLiveTimeline().getEvents().slice(0, 20).map(e => e.getId()), }); } }, [thread, room, threadRootId, roomTimelineSet]); const getThreadEvents = useCallback((): MatrixEvent[] => { const t = room.getThread(threadRootId); if (!t) { // eslint-disable-next-line no-console console.log('[ThreadView] No thread found for ID:', threadRootId); // eslint-disable-next-line no-console console.log('[ThreadView] Attempting to find replies manually from room timeline'); // Fallback: search the room timeline for events that are replies to this thread const allEvents = roomTimelineSet.getLiveTimeline().getEvents(); const threadReplies = allEvents.filter(event => { const relations = event.getRelation(); // Check if this event is a thread reply to our root event return relations?.rel_type === 'm.thread' && relations?.event_id === threadRootId; }); // eslint-disable-next-line no-console console.log('[ThreadView] Manual search found', { totalEventsInTimeline: allEvents.length, repliesFound: threadReplies.length, replies: threadReplies.map(e => ({ id: e.getId(), sender: e.getSender(), body: e.getContent()?.body?.substring(0, 50), })), }); return threadReplies; } // thread.events contains only replies, not the root event const replies = [...t.events]; const liveTimelineEvents = t.liveTimeline?.getEvents() || []; // eslint-disable-next-line no-console console.log('[ThreadView] getThreadEvents', { threadId: threadRootId, repliesCount: replies.length, threadLength: t.length, hasRootEvent: !!t.rootEvent, // eslint-disable-next-line @typescript-eslint/no-explicit-any initialEventsFetched: (t as any).initialEventsFetched, liveTimelineEventsCount: liveTimelineEvents.length, threadEventsMatch: replies.length === liveTimelineEvents.length, allRepliesFromThreadEvents: replies.map(e => ({ id: e.getId(), type: e.getType(), sender: e.getSender(), content: e.getContent()?.body?.substring(0, 50), })), allEventsFromLiveTimeline: liveTimelineEvents.map(e => ({ id: e.getId(), type: e.getType(), sender: e.getSender(), content: e.getContent()?.body?.substring(0, 50), })), }); // Try using liveTimeline events if thread.events is empty but liveTimeline has events if (replies.length === 0 && liveTimelineEvents.length > 0) { // eslint-disable-next-line no-console console.log('[ThreadView] Using liveTimeline events instead of thread.events'); return [...liveTimelineEvents]; } return replies; }, [room, threadRootId, roomTimelineSet]); const [events, setEvents] = useState(getThreadEvents); const [editId, setEditId] = useState(undefined); const scrollRef = useRef(null); const containerRef = useRef(null); const inputRef = useRef(null); const editor = useEditor(); useKeyDown( window, useCallback( (evt) => { if (editableActiveElement()) return; const portalContainer = document.getElementById('portalContainer'); if (portalContainer && portalContainer.children.length > 0) { return; } if (shouldFocusMessageField(evt)) { evt.preventDefault(); ReactEditor.focus(editor); if (evt.key.length === 1) { editor.insertText(evt.key); } } else if (isKeyHotkey('mod+v', evt)) { ReactEditor.focus(editor); } }, [editor] ) ); // Subscribe to thread and room timeline events. useEffect(() => { // eslint-disable-next-line no-console console.log('[ThreadView] Effect running', { threadId: threadRootId, threadExists: !!thread, threadLength: thread?.length, eventsCount: thread?.events?.length, // eslint-disable-next-line @typescript-eslint/no-explicit-any initialEventsFetched: (thread as any)?.initialEventsFetched, }); const handleUpdate = () => { const newEvents = getThreadEvents(); // eslint-disable-next-line no-console console.log('[ThreadView] handleUpdate - new events count:', newEvents.length); setEvents(newEvents); }; // Immediately refresh in case events loaded between render and this effect. handleUpdate(); if (thread) { thread.on(ThreadEvent.NewReply, handleUpdate); thread.on(ThreadEvent.Update, handleUpdate); // Fires when initial events are loaded after the SDK paginates the thread. // eslint-disable-next-line @typescript-eslint/no-explicit-any (thread as any).on(RoomEvent.TimelineReset, handleUpdate); // Also listen to Timeline events which fire when events are added thread.on(RoomEvent.Timeline, handleUpdate); } const handleRoomTimeline = (mEvent: MatrixEvent, eventRoom: Room | undefined) => { if (eventRoom?.roomId !== room.roomId) return; // Check if this event is related to our thread (either it's a reply to it, or it IS the root event) const relations = mEvent.getRelation(); const isThreadReply = relations?.rel_type === 'm.thread' && relations?.event_id === threadRootId; const isRootEvent = mEvent.getId() === threadRootId; if (isThreadReply || isRootEvent) { handleUpdate(); } }; room.on(RoomEvent.Timeline, handleRoomTimeline as never); room.on(RoomEvent.Redaction, handleRoomTimeline as never); return () => { if (thread) { thread.off(ThreadEvent.NewReply, handleUpdate); thread.off(ThreadEvent.Update, handleUpdate); // eslint-disable-next-line @typescript-eslint/no-explicit-any (thread as any).off(RoomEvent.TimelineReset, handleUpdate); thread.off(RoomEvent.Timeline, handleUpdate); } room.off(RoomEvent.Timeline, handleRoomTimeline as never); room.off(RoomEvent.Redaction, handleRoomTimeline as never); }; }, [thread, room, threadRootId, getThreadEvents]); // Scroll to bottom on mount and when new events arrive. useLayoutEffect(() => { const el = scrollRef.current; if (el) autoScrollToBottom(el); }, [events.length]); const handleClose = useCallback(() => { setActiveThreadId(undefined); setReplyDraft(undefined); }, [setActiveThreadId, setReplyDraft]); const handleUserClick: MouseEventHandler = useCallback( (evt) => { evt.preventDefault(); evt.stopPropagation(); const userId = evt.currentTarget.getAttribute('data-user-id'); if (!userId) return; openUserRoomProfile( room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect() ); }, [room, space, openUserRoomProfile] ); const handleUsernameClick: MouseEventHandler = useCallback( (evt) => { evt.preventDefault(); const userId = evt.currentTarget.getAttribute('data-user-id'); if (!userId) return; const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; editor.insertNode( createMentionElement( userId, name.startsWith('@') ? name : `@${name}`, userId === mx.getUserId() ) ); ReactEditor.focus(editor); moveCursor(editor); }, [mx, room, editor] ); const handleReplyClick: MouseEventHandler = useCallback( (evt) => { const replyId = evt.currentTarget.getAttribute('data-event-id'); if (!replyId) return; const replyEvt = thread?.findEventById(replyId) ?? room.findEventById(replyId); if (!replyEvt) return; const editedReply = getEditedEvent(replyId, replyEvt, roomTimelineSet); const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const { body, formatted_body: formattedBody } = content; const senderId = replyEvt.getSender(); if (senderId && typeof body === 'string') { setReplyDraft({ userId: senderId, eventId: replyId, body, formattedBody, relation: { rel_type: RelationType.Thread, event_id: threadRootId }, }); setTimeout(() => ReactEditor.focus(editor), 100); } }, [room, thread, roomTimelineSet, threadRootId, setReplyDraft, editor] ); const handleOpenReply: MouseEventHandler = useCallback((evt) => { const targetId = evt.currentTarget.getAttribute('data-event-id'); if (!targetId) return; const el = scrollRef.current?.querySelector( `[data-message-id="${targetId}"]` ) as HTMLElement | null; if (el) scrollElementIntoView(el, { behavior: 'smooth', block: 'center' }); }, []); const handleReactionToggle = useCallback( (targetEventId: string, key: string, shortcode?: string) => { const relations = getEventReactions(roomTimelineSet, targetEventId); const allReactions = relations?.getSortedAnnotationsByKey() ?? []; const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? []; const reactions = reactionsSet ? Array.from(reactionsSet) : []; const myReaction = reactions.find(factoryEventSentBy(mx.getSafeUserId())); if (myReaction && !!myReaction?.isRelation()) { const myReactionId = myReaction.getId(); if (myReactionId) mx.redactEvent(room.roomId, myReactionId); return; } const rShortcode = shortcode || (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); mx.sendEvent( room.roomId, // eslint-disable-next-line @typescript-eslint/no-explicit-any MessageEvent.Reaction as any, getReactionContent(targetEventId, key, rShortcode) ); }, [mx, room, roomTimelineSet] ); /** Renders a single thread event into JSX. Returns null for non-message events. */ const renderEvent = (mEvent: MatrixEvent, index: number, all: MatrixEvent[]) => { const mEventId = mEvent.getId(); if (!mEventId) return null; const eventSender = mEvent.getSender(); if (eventSender && ignoredUsersSet.has(eventSender)) return null; if (mEvent.isRedacted()) return null; if (reactionOrEditEvent(mEvent)) return null; const type = mEvent.getType(); if ( type !== MessageEvent.RoomMessage && type !== MessageEvent.RoomMessageEncrypted ) { return null; } const prevEvent = index > 0 ? all[index - 1] : undefined; const dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; const collapse = !dayDivider && prevEvent !== undefined && prevEvent.getSender() === eventSender && prevEvent.getType() === type && minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; const reactionRelations = getEventReactions(roomTimelineSet, mEventId); const hasReactions = !!reactionRelations?.getSortedAnnotationsByKey()?.length; const { replyEventId, threadRootId: evtThreadRootId } = mEvent; const editedEvent = getEditedEvent(mEventId, mEvent, roomTimelineSet); const getContent = (() => editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; const senderId = mEvent.getSender() ?? ''; const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; const dayDividerJSX = dayDivider ? ( {(() => { if (today(mEvent.getTs())) return 'Today'; if (yesterday(mEvent.getTs())) return 'Yesterday'; return timeDayMonthYear(mEvent.getTs()); })()} ) : null; const messageJSX = ( ) : undefined } reactions={ reactionRelations ? ( ) : undefined } > {type === MessageEvent.RoomMessageEncrypted ? ( {() => ( )} ) : ( )} ); return ( {dayDividerJSX} {messageJSX} ); }; const rootEvent = room.findEventById(threadRootId); const rootBody = getEditedEvent(threadRootId, rootEvent ?? new MatrixEvent(), roomTimelineSet) ?.getContent()['m.new_content']?.body ?? rootEvent?.getContent()?.body ?? ''; const rootSenderId = rootEvent?.getSender() ?? ''; const rootSenderName = getMemberDisplayName(room, rootSenderId) ?? getMxIdLocalPart(rootSenderId) ?? rootSenderId; // Use actual events count if thread doesn't exist but we found replies manually const replyCount = thread?.length ?? events.length; // Return as siblings rather than a wrapper so they slot into Page's flex // column the same way the normal RoomTimeline + RoomInput children do. return ( <> {/* Thread sub-header */} Back to Chat } > {(triggerRef) => ( )} Thread {rootBody && ( {rootSenderName}: {rootBody} )} {replyCount} {replyCount === 1 ? 'reply' : 'replies'} {/* Thread messages — grows to fill remaining space */} {/* Render the thread root event */} {rootEvent && renderEvent(rootEvent, -1, [])} {/* Show "no replies" message if no replies exist */} {events.length === 0 && ( No replies yet. Be the first! )} {/* Render all reply events */} {events.map((evt, idx, arr) => renderEvent(evt, idx, arr))} {/* Thread input */}
{hideActivity ? : }
); }