diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 9a335ba..18d727d 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -58,6 +58,8 @@ type ReplyProps = { replyEventId: string; threadRootId?: string | undefined; onClick?: MouseEventHandler | undefined; + /** Called when the thread indicator badge is clicked. Receives the thread root event ID. */ + onOpenThread?: (threadRootId: string) => void; getMemberPowerTag?: GetMemberPowerTag; accessibleTagColors?: Map; legacyUsernameColor?: boolean; @@ -71,6 +73,7 @@ export const Reply = as<'div', ReplyProps>( replyEventId, threadRootId, onClick, + onOpenThread, getMemberPowerTag, accessibleTagColors, legacyUsernameColor, @@ -107,7 +110,11 @@ export const Reply = as<'div', ReplyProps>( return ( {threadRootId && ( - + onOpenThread(threadRootId) : onClick} + /> )} ; roomId: string; room: Room; + /** When provided, all messages are sent as replies in this thread. */ + threadRootId?: string; } export const RoomInput = forwardRef( - ({ editor, fileDropContainerRef, roomId, room }, ref) => { + ({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); @@ -388,13 +390,20 @@ export const RoomInput = forwardRef( content['m.relates_to'].rel_type = RelationType.Thread; content['m.relates_to'].is_falling_back = false; } + } else if (threadRootId) { + content['m.relates_to'] = { + rel_type: RelationType.Thread, + event_id: threadRootId, + is_falling_back: true, + 'm.in_reply_to': { event_id: threadRootId }, + }; } mx.sendMessage(roomId, content as any); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); sendTypingStatus(false); - }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]); + }, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 49b50c4..d85695e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -101,6 +101,7 @@ import * as css from './RoomTimeline.css'; import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor'; import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; +import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { useKeyDown } from '../../hooks/useKeyDown'; @@ -480,6 +481,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); + const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(room.roomId)); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -1025,8 +1027,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }); setTimeout(() => ReactEditor.focus(editor), 100); } + if (startThread) { + setActiveThreadId(replyId); + } }, - [room, setReplyDraft, editor] + [room, setReplyDraft, setActiveThreadId, editor] + ); + + /** Opens the thread view for the given thread root event ID. */ + const handleOpenThread = useCallback( + (threadRootId: string) => { + setActiveThreadId(threadRootId); + }, + [setActiveThreadId] ); const handleReactionToggle = useCallback( @@ -1114,6 +1127,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli replyEventId={replyEventId} threadRootId={threadRootId} onClick={handleOpenReply} + onOpenThread={handleOpenThread} getMemberPowerTag={getMemberPowerTag} accessibleTagColors={accessiblePowerTagColors} legacyUsernameColor={legacyUsernameColor || direct} @@ -1197,6 +1211,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli replyEventId={replyEventId} threadRootId={threadRootId} onClick={handleOpenReply} + onOpenThread={handleOpenThread} getMemberPowerTag={getMemberPowerTag} accessibleTagColors={accessiblePowerTagColors} legacyUsernameColor={legacyUsernameColor || direct} diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 0f83759..ae3cab0 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -3,6 +3,7 @@ import { Box, Text, config } from 'folds'; import { EventType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; +import { useAtomValue } from 'jotai'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; @@ -22,6 +23,8 @@ import { settingsAtom } from '../../state/settings'; import { useSetting } from '../../state/hooks/settings'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { activeThreadIdAtomFamily } from '../../state/activeThread'; +import { ThreadView } from './ThreadView'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { @@ -74,6 +77,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { const permissions = useRoomPermissions(creators, powerLevels); const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); + const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId)); + useKeyDown( window, useCallback( @@ -94,49 +99,55 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) { return ( - - - - - -
- {tombstoneEvent ? ( - + ) : ( + <> + + - ) : ( - <> - {canMessage && ( - + + +
+ {tombstoneEvent ? ( + + ) : ( + <> + {canMessage && ( + + )} + {!canMessage && ( + + You do not have permission to post in this room + + )} + )} - {!canMessage && ( - - You do not have permission to post in this room - - )} - - )} -
- {hideActivity ? : } -
+
+ {hideActivity ? : } +
+ + )}
); } diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 30f2c56..f8b3b97 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -25,7 +25,8 @@ import { } from 'folds'; import { useNavigate } from 'react-router-dom'; import { JoinRule, Room } from 'matrix-js-sdk'; -import { useAtomValue } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; +import { activeThreadIdAtomFamily } from '../../state/activeThread'; import { useStateEvent } from '../../hooks/useStateEvent'; import { PageHeader } from '../../components/page'; @@ -499,6 +500,7 @@ export function RoomViewHeader() { : undefined; const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId)); const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { @@ -685,6 +687,25 @@ export function RoomViewHeader() { )} )} + + {activeThreadId ? 'Back to Chat' : 'Threads'} + + } + > + {(triggerRef) => ( + setActiveThreadId(undefined)} + aria-pressed={!!activeThreadId} + > + + + )} + ({ + 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 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(); + + // 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) scrollToBottom(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) el.scrollIntoView({ 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 ? : } +
+ + ); +} diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 3de27bf..5650eff 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -67,6 +67,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider'; import { JoinAddressPrompt } from '../../../components/join-address-prompt'; import { ManageRoomsPrompt } from '../../../components/manage-rooms-prompt'; import { _RoomSearchParams } from '../../paths'; +import { HomeThreadsCategory } from './HomeThreadsCategory'; type HomeMenuProps = { requestClose: () => void; @@ -395,6 +396,7 @@ export function Home() { })} +
)} diff --git a/src/app/pages/client/home/HomeThreadsCategory.tsx b/src/app/pages/client/home/HomeThreadsCategory.tsx new file mode 100644 index 0000000..34957a6 --- /dev/null +++ b/src/app/pages/client/home/HomeThreadsCategory.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Avatar, + Box, + Icon, + Icons, + Text, + Badge, +} from 'folds'; +import { useAtom, useSetAtom } from 'jotai'; +import { Room, Thread, ThreadEvent } from 'matrix-js-sdk'; +import { NavCategory, NavCategoryHeader, NavItem, NavItemContent, NavButton } from '../../../components/nav'; +import { RoomNavCategoryButton } from '../../../features/room-nav'; +import { makeNavCategoryId } from '../../../state/closedNavCategories'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; +import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; +import { activeThreadIdAtomFamily } from '../../../state/activeThread'; +import { getCanonicalAliasOrRoomId, getMxIdLocalPart } from '../../../utils/matrix'; +import { getHomeRoomPath } from '../../pathUtils'; +import { getMemberDisplayName } from '../../../utils/room'; + +const THREADS_CATEGORY_ID = makeNavCategoryId('home', 'threads'); + +type ThreadEntry = { + room: Room; + thread: Thread; +}; + +type ThreadNavItemProps = { + room: Room; + thread: Thread; + selected: boolean; +}; + +/** + * A single nav item representing a thread the user has participated in. + * Navigates to the room and opens the thread when clicked. + */ +function ThreadNavItem({ room, thread, selected }: ThreadNavItemProps) { + const navigate = useNavigate(); + const setActiveThreadId = useSetAtom(activeThreadIdAtomFamily(room.roomId)); + const mx = useMatrixClient(); + + const { rootEvent } = thread; + const senderId = rootEvent?.getSender() ?? ''; + const senderName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + const rootBody = rootEvent?.getContent()?.body ?? ''; + const replyCount = thread.length; + + const handleClick = useCallback(() => { + const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); + navigate(getHomeRoomPath(roomIdOrAlias)); + // Defer slightly so RoomView has time to render before we set the atom. + requestAnimationFrame(() => { + // eslint-disable-next-line no-console + console.log('[HomeThreadsCategory] Opening thread', { + threadId: thread.id, + rootEventId: thread.rootEvent?.getId(), + roomId: room.roomId, + }); + // Use rootEvent ID if available, fallback to thread.id + const threadIdToUse = thread.rootEvent?.getId() || thread.id; + setActiveThreadId(threadIdToUse); + }); + }, [navigate, mx, room.roomId, setActiveThreadId, thread.id, thread.rootEvent]); + + return ( + + + + + + + + + + {senderName} + + {rootBody && ( + + {rootBody} + + )} + + {replyCount > 0 && ( + + + {replyCount} + + + )} + + + + + ); +} + +type HomeThreadsCategoryProps = { + rooms: string[]; +}; + +/** + * Nav category for the left sidebar showing threads in Home rooms where the + * current user has participated. + */ +export function HomeThreadsCategory({ rooms }: HomeThreadsCategoryProps) { + const mx = useMatrixClient(); + const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + + const getParticipatedThreads = useCallback((): ThreadEntry[] => + rooms.reduce((acc, roomId) => { + const room = mx.getRoom(roomId); + if (!room) return acc; + return acc.concat( + room + .getThreads() + .filter((t) => t.hasCurrentUserParticipated) + .map((t) => ({ room, thread: t })) + ); + }, []), + [mx, rooms]); + + const [threads, setThreads] = useState(getParticipatedThreads); + + // Re-compute when thread events fire in any home room. + useEffect(() => { + const handleUpdate = () => setThreads(getParticipatedThreads()); + + const roomObjects = rooms.reduce((acc, roomId) => { + const room = mx.getRoom(roomId); + if (room) acc.push(room); + return acc; + }, []); + + roomObjects.forEach((room) => { + room.getThreads().forEach((thread) => { + thread.on(ThreadEvent.Update, handleUpdate); + thread.on(ThreadEvent.NewReply, handleUpdate); + }); + }); + + return () => { + roomObjects.forEach((room) => { + room.getThreads().forEach((thread) => { + thread.off(ThreadEvent.Update, handleUpdate); + thread.off(ThreadEvent.NewReply, handleUpdate); + }); + }); + }; + }, [mx, rooms, getParticipatedThreads]); + + const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => + closedCategories.has(categoryId) + ); + + if (threads.length === 0) return null; + + return ( + + + + My Threads + + + {!closedCategories.has(THREADS_CATEGORY_ID) && + threads.map(({ room, thread }) => ( + + ))} + + ); +} diff --git a/src/app/state/activeThread.ts b/src/app/state/activeThread.ts new file mode 100644 index 0000000..93ea889 --- /dev/null +++ b/src/app/state/activeThread.ts @@ -0,0 +1,11 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +/** Stores the active thread root event ID for a given room. `undefined` means no thread is open. */ +const createActiveThreadAtom = () => atom(undefined); + +export type TActiveThreadAtom = ReturnType; + +export const activeThreadIdAtomFamily = atomFamily( + createActiveThreadAtom +);