diff --git a/.changeset/feat-threads.md b/.changeset/feat-threads.md new file mode 100644 index 000000000..77bbb618e --- /dev/null +++ b/.changeset/feat-threads.md @@ -0,0 +1,5 @@ +--- +sable: minor +--- + +Add thread support with side panel, browser, unread badges, and cross-device sync diff --git a/.changeset/fix_call_preferences.md b/.changeset/fix_call_preferences.md deleted file mode 100644 index b089563a1..000000000 --- a/.changeset/fix_call_preferences.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -default: patch ---- - -Fix call preferences not persisting. diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 32121685f..92ef1cb12 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -26,13 +26,6 @@ import { CustomElement } from './slate'; import * as css from './Editor.css'; import { toggleKeyboardShortcut } from './keyboard'; -const initialValue: CustomElement[] = [ - { - type: BlockType.Paragraph, - children: [{ text: '' }], - }, -]; - const withInline = (editor: Editor): Editor => { const { isInline } = editor; @@ -96,6 +89,15 @@ export const CustomEditor = forwardRef( }, ref ) => { + // Each instance must receive its own fresh node objects. + // Sharing a module-level constant causes Slate's global NODE_TO_ELEMENT + // WeakMap to be overwritten when multiple editors are mounted at the same + // time (e.g. RoomInput + MessageEditor in the thread drawer), leading to + // "Unable to find the path for Slate node" crashes. + const [slateInitialValue] = useState(() => [ + { type: BlockType.Paragraph, children: [{ text: '' }] }, + ]); + const renderElement = useCallback( (props: RenderElementProps) => , [] @@ -132,7 +134,7 @@ export const CustomEditor = forwardRef( return (
- + {top} {before && ( diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 694b1c7f8..2a067d4bf 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect } from 'react'; import { Box, Line } from 'folds'; import { useParams } from 'react-router-dom'; import { isKeyHotkey } from 'is-hotkey'; -import { useAtomValue } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; @@ -15,11 +15,15 @@ import { useRoomMembers } from '$hooks/useRoomMembers'; import { CallView } from '$features/call/CallView'; import { WidgetsDrawer } from '$features/widgets/WidgetsDrawer'; import { callChatAtom } from '$state/callEmbed'; +import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; +import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; import { createDebugLogger } from '$utils/debugLogger'; import { RoomViewHeader } from './RoomViewHeader'; import { MembersDrawer } from './MembersDrawer'; import { RoomView } from './RoomView'; import { CallChatView } from './CallChatView'; +import { ThreadDrawer } from './ThreadDrawer'; +import { ThreadBrowser } from './ThreadBrowser'; const debugLog = createDebugLogger('Room'); @@ -55,6 +59,30 @@ export function Room() { const powerLevels = usePowerLevels(room); const members = useRoomMembers(mx, room.roomId); const chat = useAtomValue(callChatAtom); + const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); + const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( + roomIdToThreadBrowserAtomFamily(room.roomId) + ); + + // If navigating to an event in a thread, open the thread drawer + useEffect(() => { + if (!eventId) return; + + const event = room.findEventById(eventId); + if (!event) return; + + const { threadRootId } = event; + if (threadRootId) { + // Ensure Thread object exists + if (!room.getThread(threadRootId)) { + const rootEvent = room.findEventById(threadRootId); + if (rootEvent) { + room.createThread(threadRootId, rootEvent, [], false); + } + } + setOpenThread(threadRootId); + } + }, [eventId, room, setOpenThread]); useKeyDown( window, @@ -77,7 +105,7 @@ export function Room() { return ( - + {callView && (screenSize === ScreenSize.Desktop || !chat) && ( @@ -115,6 +143,52 @@ export function Room() { )} + {screenSize === ScreenSize.Desktop && openThreadId && ( + <> + + setOpenThread(undefined)} + /> + + )} + {screenSize === ScreenSize.Desktop && threadBrowserOpen && !openThreadId && ( + <> + + { + setOpenThread(id); + setThreadBrowserOpen(false); + }} + onClose={() => setThreadBrowserOpen(false)} + /> + + )} + {screenSize !== ScreenSize.Desktop && openThreadId && ( + setOpenThread(undefined)} + overlay + /> + )} + {screenSize !== ScreenSize.Desktop && threadBrowserOpen && !openThreadId && ( + { + setOpenThread(id); + setThreadBrowserOpen(false); + }} + onClose={() => setThreadBrowserOpen(false)} + overlay + /> + )} ); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 3f7becd55..5d2985f53 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -164,15 +164,30 @@ const getReplyContent = (replyDraft: IReplyDraft | undefined): IEventRelation => const relatesTo: IEventRelation = {}; - relatesTo['m.in_reply_to'] = { - event_id: replyDraft.eventId, - }; - + // If this is a thread relation if (replyDraft.relation?.rel_type === RelationType.Thread) { relatesTo.event_id = replyDraft.relation.event_id; relatesTo.rel_type = RelationType.Thread; - relatesTo.is_falling_back = false; + + // Check if this is a reply to a specific message in the thread + // (replyDraft.body being empty means it's just a seeded thread draft) + if (replyDraft.body && replyDraft.eventId !== replyDraft.relation.event_id) { + // This is a reply to a message within the thread + relatesTo['m.in_reply_to'] = { + event_id: replyDraft.eventId, + }; + relatesTo.is_falling_back = false; + } else { + // This is just a regular thread message + relatesTo.is_falling_back = true; + } + } else { + // Regular reply (not in a thread) + relatesTo['m.in_reply_to'] = { + event_id: replyDraft.eventId, + }; } + return relatesTo; }; @@ -187,9 +202,13 @@ interface RoomInputProps { fileDropContainerRef: RefObject; roomId: string; room: Room; + threadRootId?: string; } export const RoomInput = forwardRef( - ({ editor, fileDropContainerRef, roomId, room }, ref) => { + ({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => { + // When in thread mode, isolate drafts by thread root ID so thread replies + // don't clobber the main room draft (and vice versa). + const draftKey = threadRootId ?? roomId; const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); @@ -205,8 +224,8 @@ export const RoomInput = forwardRef( const permissions = useRoomPermissions(creators, powerLevels); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); - const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); - const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); + const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey)); + const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey)); const replyUserID = replyDraft?.userId; const { color: replyUsernameColor, font: replyUsernameFont } = useSableCosmetics( @@ -215,7 +234,7 @@ export const RoomInput = forwardRef( ); const [uploadBoard, setUploadBoard] = useState(true); - const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); + const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey)); const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( roomUploadAtomFamily, selectedFiles.map((f) => f.file) @@ -330,6 +349,26 @@ export const RoomInput = forwardRef( replyBodyJSX = scaleSystemEmoji(strippedBody); } + // Seed the reply draft with the thread relation whenever we're in thread + // mode (e.g. on first render or when the thread root changes). We use the + // current user's ID as userId so that the mention logic skips it. + useEffect(() => { + if (!threadRootId) return; + setReplyDraft((prev) => { + if ( + prev?.relation?.rel_type === RelationType.Thread && + prev.relation.event_id === threadRootId + ) + return prev; + return { + userId: mx.getUserId() ?? '', + eventId: threadRootId, + body: '', + relation: { rel_type: RelationType.Thread, event_id: threadRootId }, + }; + }); + }, [threadRootId, setReplyDraft, mx]); + useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); @@ -345,7 +384,7 @@ export const RoomInput = forwardRef( resetEditor(editor); resetEditorHistory(editor); }, - [roomId, editor, setMsgDraft] + [draftKey, editor, setMsgDraft] ); useEffect(() => { @@ -419,13 +458,22 @@ export const RoomInput = forwardRef( if (contents.length > 0) { const replyContent = plainText?.length === 0 ? getReplyContent(replyDraft) : undefined; if (replyContent) contents[0]['m.relates_to'] = replyContent; - setReplyDraft(undefined); + if (threadRootId) { + setReplyDraft({ + userId: mx.getUserId() ?? '', + eventId: threadRootId, + body: '', + relation: { rel_type: RelationType.Thread, event_id: threadRootId }, + }); + } else { + setReplyDraft(undefined); + } } await Promise.all( contents.map((content) => mx - .sendMessage(roomId, content as any) + .sendMessage(roomId, threadRootId ?? null, content as any) .then((res) => { debugLog.info('message', 'Uploaded file message sent', { roomId, @@ -561,7 +609,17 @@ export const RoomInput = forwardRef( resetEditor(editor); resetEditorHistory(editor); setInputKey((prev) => prev + 1); - setReplyDraft(undefined); + if (threadRootId) { + // Re-seed the thread reply draft so the next message also goes to the thread. + setReplyDraft({ + userId: mx.getUserId() ?? '', + eventId: threadRootId, + body: '', + relation: { rel_type: RelationType.Thread, event_id: threadRootId }, + }); + } else { + setReplyDraft(undefined); + } sendTypingStatus(false); }; if (scheduledTime) { @@ -589,7 +647,7 @@ export const RoomInput = forwardRef( roomId, scheduledDelayId: editingScheduledDelayId, }); - const res = await mx.sendMessage(roomId, content as any); + const res = await mx.sendMessage(roomId, threadRootId ?? null, content as any); debugLog.info('message', 'Message sent successfully', { roomId, eventId: res.event_id }); invalidate(); setEditingScheduledDelayId(null); @@ -604,7 +662,7 @@ export const RoomInput = forwardRef( } else { resetInput(); debugLog.info('message', 'Sending message', { roomId, msgtype: (content as any).msgtype }); - mx.sendMessage(roomId, content as any) + mx.sendMessage(roomId, threadRootId ?? null, content as any) .then((res) => { debugLog.info('message', 'Message sent successfully', { roomId, @@ -625,6 +683,7 @@ export const RoomInput = forwardRef( canSendReaction, mx, roomId, + threadRootId, replyDraft, silentReply, scheduledTime, @@ -729,7 +788,16 @@ export const RoomInput = forwardRef( }; if (replyDraft) { content['m.relates_to'] = getReplyContent(replyDraft); - setReplyDraft(undefined); + if (threadRootId) { + setReplyDraft({ + userId: mx.getUserId() ?? '', + eventId: threadRootId, + body: '', + relation: { rel_type: RelationType.Thread, event_id: threadRootId }, + }); + } else { + setReplyDraft(undefined); + } } mx.sendEvent(roomId, EventType.Sticker, content); }; @@ -888,7 +956,7 @@ export const RoomInput = forwardRef(
)} - {replyDraft && ( + {replyDraft && (!threadRootId || replyDraft.body) && (
( style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > setReplyDraft(undefined)} + onClick={() => { + if (threadRootId) { + setReplyDraft({ + userId: mx.getUserId() ?? '', + eventId: threadRootId, + body: '', + relation: { rel_type: RelationType.Thread, event_id: threadRootId }, + }); + } else { + setReplyDraft(undefined); + } + }} variant="SurfaceVariant" size="300" radii="300" diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6bffcfd2e..6365f851f 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -28,6 +28,7 @@ import { Room, RoomEvent, RoomEventHandlerMap, + ThreadEvent, } from '$types/matrix-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import classNames from 'classnames'; @@ -38,6 +39,7 @@ import to from 'await-to-js'; import { useAtomValue, useSetAtom } from 'jotai'; import { as, + Avatar, Badge, Box, Chip, @@ -55,7 +57,7 @@ import { import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useTranslation } from 'react-i18next'; -import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; +import { getMxIdLocalPart, mxcUrlToHttp, toggleReaction } from '$utils/matrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { ItemRange, useVirtualPaginator } from '$hooks/useVirtualPaginator'; import { useAlive } from '$hooks/useAlive'; @@ -88,6 +90,7 @@ import { getEditedEvent, getEventReactions, getLatestEditableEvt, + getMemberAvatarMxc, getMemberDisplayName, isMembershipChanged, reactionOrEditEvent, @@ -108,6 +111,7 @@ import { getResizeObserverEntry, useResizeObserver } from '$hooks/useResizeObser import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '$utils/time'; import { createMentionElement, isEmptyEditor, moveCursor } from '$components/editor'; import { roomIdToReplyDraftAtomFamily } from '$state/room/roomInputDrafts'; +import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { GetContentCallback, MessageEvent, StateEvent } from '$types/matrix/room'; import { useKeyDown } from '$hooks/useKeyDown'; @@ -122,6 +126,7 @@ import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { UserAvatar } from '$components/user-avatar'; import { useIgnoredUsers } from '$hooks/useIgnoredUsers'; import { useImagePackRooms } from '$hooks/useImagePackRooms'; import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; @@ -538,6 +543,132 @@ const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { }, [room, onRefresh]); }; +// Trigger re-render when thread reply counts change so the thread chip updates. +const useThreadUpdate = (room: Room, onUpdate: () => void) => { + useEffect(() => { + room.on(ThreadEvent.New, onUpdate); + room.on(ThreadEvent.Update, onUpdate); + room.on(ThreadEvent.NewReply, onUpdate); + return () => { + room.removeListener(ThreadEvent.New, onUpdate); + room.removeListener(ThreadEvent.Update, onUpdate); + room.removeListener(ThreadEvent.NewReply, onUpdate); + }; + }, [room, onUpdate]); +}; + +// Returns the number of replies in a thread, counting actual reply events +// (excluding the root event, reactions, and edits) from the live timeline. +// Always uses timeline-based counting for accuracy and live updates. +const getThreadReplyCount = (room: Room, mEventId: string): number => + room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter( + (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) + ).length; + +type ThreadReplyChipProps = { + room: Room; + mEventId: string; + openThreadId: string | undefined; + onToggle: () => void; +}; + +function ThreadReplyChip({ room, mEventId, openThreadId, onToggle }: ThreadReplyChipProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const nicknames = useAtomValue(nicknamesAtom); + + const replyEvents = room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter( + (ev) => ev.threadRootId === mEventId && ev.getId() !== mEventId && !reactionOrEditEvent(ev) + ); + + const replyCount = replyEvents.length; + if (replyCount === 0) return null; + + const uniqueSenders: string[] = []; + const seen = new Set(); + replyEvents.forEach((ev) => { + const s = ev.getSender(); + if (s && !seen.has(s)) { + seen.add(s); + uniqueSenders.push(s); + } + }); + + const latestReply = replyEvents[replyEvents.length - 1]; + const latestSenderId = latestReply?.getSender() ?? ''; + const latestSenderName = + getMemberDisplayName(room, latestSenderId, nicknames) ?? + getMxIdLocalPart(latestSenderId) ?? + latestSenderId; + const latestBody = (latestReply?.getContent()?.body as string | undefined) ?? ''; + + const isOpen = openThreadId === mEventId; + + return ( + + {uniqueSenders.slice(0, 3).map((senderId, index) => { + const avatarMxc = getMemberAvatarMxc(room, senderId); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 20, 20, 'crop') ?? undefined) + : undefined; + const displayName = + getMemberDisplayName(room, senderId, nicknames) ?? + getMxIdLocalPart(senderId) ?? + senderId; + return ( + 0 ? '-4px' : 0 }}> + ( + + {displayName[0]?.toUpperCase() ?? '?'} + + )} + /> + + ); + })} + + } + onClick={onToggle} + style={{ marginTop: config.space.S200 }} + > + + {replyCount} {replyCount === 1 ? 'reply' : 'replies'} + + {latestBody && ( + +  · {latestSenderName}: {latestBody.slice(0, 60)} + + )} + + ); +} + const getInitialTimeline = (room: Room) => { const linkedTimelines = getLinkedTimelines(getLiveTimeline(room)); const evLength = getTimelinesEventsCount(linkedTimelines); @@ -606,6 +737,8 @@ export function RoomTimeline({ const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); const replyDraft = useAtomValue(roomIdToReplyDraftAtomFamily(room.roomId)); const activeReplyId = replyDraft?.eventId; + const openThreadId = useAtomValue(roomIdToOpenThreadAtomFamily(room.roomId)); + const setOpenThread = useSetAtom(roomIdToOpenThreadAtomFamily(room.roomId)); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -814,6 +947,11 @@ export function RoomTimeline({ room, useCallback( (mEvt: MatrixEvent) => { + // Thread reply events are re-emitted from the Thread to the Room and + // must not increment the main timeline range or scroll it. + // useThreadUpdate handles the chip re-render for these events. + if (mEvt.threadRootId !== undefined) return; + // if user is at bottom of timeline // keep paginating timeline and conditionally mark as read // otherwise we update timeline without paginating @@ -959,6 +1097,15 @@ export function RoomTimeline({ }, []) ); + // Re-render when thread reply counts change (new reply or thread update) so + // the thread chip on root messages reflects the correct count. + useThreadUpdate( + room, + useCallback(() => { + setTimeline((ct) => ({ ...ct })); + }, []) + ); + // When historical events load (e.g., from active subscription), stay at bottom // by adjusting the range. The virtual paginator expects the range to match the // position we want to display. Without this, loading more history makes it look @@ -1354,15 +1501,24 @@ export function RoomTimeline({ ); const handleReplyClick: MouseEventHandler = useCallback( - (evt) => { + (evt, startThread = false) => { const replyId = evt.currentTarget.getAttribute('data-event-id'); if (!replyId) { setReplyDraft(undefined); return; } - if (replyId) triggerReply(replyId); + if (startThread) { + // Create thread if it doesn't exist, then open the thread drawer + const rootEvent = room.findEventById(replyId); + if (rootEvent && !room.getThread(replyId)) { + room.createThread(replyId, rootEvent, [], false); + } + setOpenThread(openThreadId === replyId ? undefined : replyId); + return; + } + triggerReply(replyId, false); }, - [triggerReply, setReplyDraft] + [triggerReply, setReplyDraft, setOpenThread, openThreadId, room] ); const handleReactionToggle = useCallback( @@ -1510,19 +1666,35 @@ export function RoomTimeline({ /> ) } - reactions={ - reactionRelations && ( - - ) - } + reactions={(() => { + const threadReplyCount = getThreadReplyCount(room, mEventId); + const threadChip = + threadReplyCount > 0 ? ( + setOpenThread(openThreadId === mEventId ? undefined : mEventId)} + /> + ) : null; + if (!reactionRelations && !threadChip) return undefined; + return ( + <> + {reactionRelations && ( + + )} + {threadChip} + + ); + })()} hideReadReceipts={hideReads} showDeveloperTools={showDeveloperTools} memberPowerTag={getMemberPowerTag(senderId)} @@ -1604,19 +1776,35 @@ export function RoomTimeline({ /> ) } - reactions={ - reactionRelations && ( - - ) - } + reactions={(() => { + const threadReplyCount = getThreadReplyCount(room, mEventId); + const threadChip = + threadReplyCount > 0 ? ( + setOpenThread(openThreadId === mEventId ? undefined : mEventId)} + /> + ) : null; + if (!reactionRelations && !threadChip) return undefined; + return ( + <> + {reactionRelations && ( + + )} + {threadChip} + + ); + })()} hideReadReceipts={hideReads} showDeveloperTools={showDeveloperTools} memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} @@ -1735,19 +1923,35 @@ export function RoomTimeline({ /> ) } - reactions={ - reactionRelations && ( - - ) - } + reactions={(() => { + const threadReplyCount = getThreadReplyCount(room, mEventId); + const threadChip = + threadReplyCount > 0 ? ( + setOpenThread(openThreadId === mEventId ? undefined : mEventId)} + /> + ) : null; + if (!reactionRelations && !threadChip) return undefined; + return ( + <> + {reactionRelations && ( + + )} + {threadChip} + + ); + })()} hideReadReceipts={hideReads} showDeveloperTools={showDeveloperTools} memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} @@ -2156,6 +2360,11 @@ export function RoomTimeline({ prevEvent.getType() === mEvent.getType() && minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; + // Thread REPLIES belong only in the thread timeline; filter them from the + // main room timeline. Keep thread ROOT events (threadRootId === their own + // event ID) so they remain visible with the ThreadReplyChip attached. + if (mEvent.threadRootId !== undefined && mEvent.threadRootId !== mEventId) return null; + const eventJSX = reactionOrEditEvent(mEvent) ? null : renderMatrixEvent( diff --git a/src/app/features/room/RoomViewFollowing.tsx b/src/app/features/room/RoomViewFollowing.tsx index f9b457547..c7285aceb 100644 --- a/src/app/features/room/RoomViewFollowing.tsx +++ b/src/app/features/room/RoomViewFollowing.tsx @@ -32,22 +32,26 @@ export function RoomViewFollowingPlaceholder() { export type RoomViewFollowingProps = { room: Room; + threadEventId?: string; + participantIds?: Set; }; export const RoomViewFollowing = as<'div', RoomViewFollowingProps>( - ({ className, room, ...props }, ref) => { + ({ className, room, threadEventId, participantIds, ...props }, ref) => { const mx = useMatrixClient(); const [open, setOpen] = useState(false); const latestEvent = useRoomLatestRenderedEvent(room); - const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId()); + const resolvedEventId = threadEventId ?? latestEvent?.getId(); + const latestEventReaders = useRoomEventReaders(room, resolvedEventId); const nicknames = useAtomValue(nicknamesAtom); const names = latestEventReaders .filter((readerId) => readerId !== mx.getUserId()) + .filter((readerId) => !participantIds || participantIds.has(readerId)) .map( (readerId) => getMemberDisplayName(room, readerId, nicknames) ?? getMxIdLocalPart(readerId) ?? readerId ); - const eventId = latestEvent?.getId(); + const eventId = resolvedEventId; return ( <> diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 412d5804c..a73df7871 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -24,7 +24,14 @@ import { Spinner, } from 'folds'; import { useNavigate } from 'react-router-dom'; -import { EventTimeline, Room } from '$types/matrix-sdk'; +import { + EventTimeline, + Room, + ThreadEvent, + RoomEvent, + MatrixEvent, + NotificationCountType, +} from '$types/matrix-sdk'; import { useStateEvent } from '$hooks/useStateEvent'; import { PageHeader } from '$components/page'; @@ -79,6 +86,8 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { mDirectAtom } from '$state/mDirectList'; import { callChatAtom } from '$state/callEmbed'; import { RoomSettingsPage } from '$state/roomSettings'; +import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; +import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; import { JumpToTime } from './jump-to-time'; import { RoomPinMenu } from './room-pin-menu'; import * as css from './RoomViewHeader.css'; @@ -343,6 +352,10 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { const direct = useIsDirectRoom(); const [chat, setChat] = useAtom(callChatAtom); + const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( + roomIdToThreadBrowserAtomFamily(room.roomId) + ); + const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); const canUseCalls = room .getLiveTimeline() @@ -367,6 +380,8 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { .getAccountData(AccountDataEvent.SablePinStatus) ?.getContent() as PinReadMarker; const [unreadPinsCount, setUnreadPinsCount] = useState(0); + const [unreadThreadsCount, setUnreadThreadsCount] = useState(0); + const [hasThreadHighlights, setHasThreadHighlights] = useState(false); const [currentHash, setCurrentHash] = useState(''); @@ -408,6 +423,116 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { }); }, [pinnedIds, pinMarker]); + // Initialize Thread objects from room history on mount and create them for new timeline events + useEffect(() => { + const scanTimelineForThreads = (timeline: any) => { + const events = timeline.getEvents(); + const threadRoots = new Set(); + + // Scan for both: + // 1. Events that ARE thread roots (have isThreadRoot = true or have replies) + // 2. Events that are IN threads (have threadRootId) + events.forEach((event: MatrixEvent) => { + // Check if this event is a thread root + if (event.isThreadRoot) { + const rootId = event.getId(); + if (rootId && !room.getThread(rootId)) { + threadRoots.add(rootId); + } + } + + // Check if this event is a reply in a thread + const { threadRootId } = event; + if (threadRootId && !room.getThread(threadRootId)) { + threadRoots.add(threadRootId); + } + }); + + // Create Thread objects for discovered thread roots + threadRoots.forEach((rootId) => { + const rootEvent = room.findEventById(rootId); + if (rootEvent) { + room.createThread(rootId, rootEvent, [], false); + } + }); + }; + + // Scan all existing timelines on mount + const liveTimeline = room.getLiveTimeline(); + scanTimelineForThreads(liveTimeline); + + // Also scan backward timelines (historical messages already loaded) + let backwardTimeline = liveTimeline.getNeighbouringTimeline('b' as any); + while (backwardTimeline) { + scanTimelineForThreads(backwardTimeline); + backwardTimeline = backwardTimeline.getNeighbouringTimeline('b' as any); + } + + // Listen for new timeline events (including pagination) + const handleTimelineEvent = (mEvent: MatrixEvent) => { + // Check if this event is a thread root + if (mEvent.isThreadRoot) { + const rootId = mEvent.getId(); + if (rootId && !room.getThread(rootId)) { + const rootEvent = room.findEventById(rootId); + if (rootEvent) { + room.createThread(rootId, rootEvent, [], false); + } + } + } + + // Check if this is a reply in a thread + const { threadRootId } = mEvent; + if (threadRootId && !room.getThread(threadRootId)) { + const rootEvent = room.findEventById(threadRootId); + if (rootEvent) { + room.createThread(threadRootId, rootEvent, [], false); + } + } + }; + + mx.on(RoomEvent.Timeline as any, handleTimelineEvent); + return () => { + mx.off(RoomEvent.Timeline as any, handleTimelineEvent); + }; + }, [room, mx]); + + // Count unread threads where user has participated + useEffect(() => { + const checkThreadUnreads = () => { + // Use SDK's thread notification counting which respects user notification preferences, + // properly distinguishes highlights (mentions) from regular messages, and handles muted threads + const threads = room.getThreads(); + let totalCount = 0; + + // Sum up notification counts across all threads + threads.forEach((thread) => { + totalCount += room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total); + }); + + // Use SDK's aggregate type to determine if any thread has highlights + const aggregateType = room.threadsAggregateNotificationType; + const hasHighlights = aggregateType === NotificationCountType.Highlight; + + setUnreadThreadsCount(totalCount); + setHasThreadHighlights(hasHighlights); + }; + + checkThreadUnreads(); + + // Listen for thread updates + const onThreadUpdate = () => checkThreadUnreads(); + room.on(ThreadEvent.New as any, onThreadUpdate); + room.on(ThreadEvent.Update as any, onThreadUpdate); + room.on(ThreadEvent.NewReply as any, onThreadUpdate); + + return () => { + room.off(ThreadEvent.New as any, onThreadUpdate); + room.off(ThreadEvent.Update as any, onThreadUpdate); + room.off(ThreadEvent.NewReply as any, onThreadUpdate); + }; + }, [room, mx]); + const handleSearchClick = () => { const searchParams: SearchPathSearchParams = { rooms: room.roomId, @@ -607,6 +732,53 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { } /> + + Threads + + } + > + {(triggerRef) => ( + { + // If a thread is open, close it and open thread browser + if (openThreadId) { + setOpenThread(undefined); + setThreadBrowserOpen(true); + } else { + // Otherwise, toggle the thread browser + setThreadBrowserOpen(!threadBrowserOpen); + } + }} + aria-pressed={threadBrowserOpen || !!openThreadId} + style={{ position: 'relative' }} + > + {unreadThreadsCount > 0 && ( + + + {unreadThreadsCount} + + + )} + + + )} + )} diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx new file mode 100644 index 000000000..2762cb688 --- /dev/null +++ b/src/app/features/room/ThreadBrowser.tsx @@ -0,0 +1,387 @@ +import { + ChangeEventHandler, + MouseEventHandler, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Box, + Header, + Icon, + IconButton, + Icons, + Input, + Scroll, + Text, + Avatar, + config, + Chip, +} from 'folds'; +import { MatrixEvent, Room, Thread, ThreadEvent } from '$types/matrix-sdk'; +import { useAtomValue } from 'jotai'; +import { HTMLReactParserOptions } from 'html-react-parser'; +import { Opts as LinkifyOpts } from 'linkifyjs'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { nicknamesAtom } from '$state/nicknames'; +import { getMemberAvatarMxc, getMemberDisplayName, reactionOrEditEvent } from '$utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; +import { UserAvatar } from '$components/user-avatar'; +import { + AvatarBase, + ModernLayout, + RedactedContent, + Time, + Username, + UsernameBold, + Reply, +} from '$components/message'; +import { RenderMessageContent } from '$components/RenderMessageContent'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; +import { GetContentCallback } from '$types/matrix/room'; +import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '$plugins/react-custom-html-parser'; +import { EncryptedContent } from './message'; +import * as css from './ThreadDrawer.css'; + +type ThreadPreviewProps = { + room: Room; + thread: Thread; + onClick: (threadId: string) => void; +}; + +function ThreadPreview({ room, thread, onClick }: ThreadPreviewProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const { navigateRoom } = useRoomNavigate(); + const nicknames = useAtomValue(nicknamesAtom); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href: string) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ) + ), + }), + [mx, room.roomId, nicknames, mentionClickHandler] + ); + + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + useAuthentication, + nicknames, + }), + [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication, nicknames] + ); + + const handleJumpClick: MouseEventHandler = useCallback( + (evt) => { + evt.stopPropagation(); + navigateRoom(room.roomId, thread.id); + }, + [navigateRoom, room.roomId, thread.id] + ); + + const { rootEvent } = thread; + if (!rootEvent) return null; + + const senderId = rootEvent.getSender() ?? ''; + const displayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + const senderAvatarMxc = getMemberAvatarMxc(room, senderId); + const getContent = (() => rootEvent.getContent()) as GetContentCallback; + + const replyCount = thread.events.filter( + (ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev) + ).length; + + const lastReply = thread.events + .filter((ev: MatrixEvent) => ev.getId() !== thread.id && !reactionOrEditEvent(ev)) + .at(-1); + const lastSenderId = lastReply?.getSender() ?? ''; + const lastDisplayName = + getMemberDisplayName(room, lastSenderId, nicknames) ?? + getMxIdLocalPart(lastSenderId) ?? + lastSenderId; + const lastContent = lastReply?.getContent(); + const lastBody: string = typeof lastContent?.body === 'string' ? lastContent.body : ''; + + return ( + onClick(thread.id)} + > + + + } + /> + + + } + > + + + + + {displayName} + + + + + + Jump + + + + {rootEvent.replyEventId && ( + + )} + + + {() => { + if (rootEvent.isRedacted()) { + return ; + } + + return ( + + ); + }} + + + {replyCount > 0 && ( + + + {replyCount} {replyCount === 1 ? 'reply' : 'replies'} + + {lastReply && lastBody && ( + + · {lastDisplayName}: {lastBody.slice(0, 60)} + + )} + + )} + + + ); +} + +type ThreadBrowserProps = { + room: Room; + onOpenThread: (threadId: string) => void; + onClose: () => void; + overlay?: boolean; +}; + +export function ThreadBrowser({ room, onOpenThread, onClose, overlay }: ThreadBrowserProps) { + const [, forceUpdate] = useState(0); + const [query, setQuery] = useState(''); + const searchRef = useRef(null); + + // Re-render when threads change. + 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); + return () => { + room.off(ThreadEvent.New as any, onUpdate); + room.off(ThreadEvent.Update as any, onUpdate); + room.off(ThreadEvent.NewReply as any, onUpdate); + }; + }, [room]); + + const allThreads = room.getThreads().sort((a: Thread, b: Thread) => { + const aTs = a.events.at(-1)?.getTs() ?? a.rootEvent?.getTs() ?? 0; + const bTs = b.events.at(-1)?.getTs() ?? b.rootEvent?.getTs() ?? 0; + return bTs - aTs; + }); + + const lowerQuery = query.trim().toLowerCase(); + const threads = lowerQuery + ? allThreads.filter((t: Thread) => { + const body = t.rootEvent?.getContent()?.body ?? ''; + return typeof body === 'string' && body.toLowerCase().includes(lowerQuery); + }) + : allThreads; + + const handleSearchChange: ChangeEventHandler = (e) => { + setQuery(e.target.value); + }; + + return ( + +
+ + + + Threads + + + + + # {room.name} + + + + + +
+ + + } + after={ + query ? ( + { + setQuery(''); + searchRef.current?.focus(); + }} + aria-label="Clear search" + > + + + ) : undefined + } + /> + + + + + {threads.length === 0 ? ( + + + + {lowerQuery ? 'No threads match your search.' : 'No threads yet.'} + + + ) : ( + + {threads.map((thread: Thread) => ( + + ))} + + )} + + +
+ ); +} diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts new file mode 100644 index 000000000..5cb755075 --- /dev/null +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -0,0 +1,66 @@ +import { style, globalStyle } from '@vanilla-extract/css'; +import { config, color, toRem } from 'folds'; + +export const ThreadDrawer = style({ + width: toRem(440), + height: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +}); + +export const messageList = style({ + display: 'flex', + flexDirection: 'column', + width: '100%', +}); + +globalStyle(`body ${messageList} [data-message-id]`, { + transition: 'background-color 0.1s ease-in-out !important', +}); + +globalStyle(`body ${messageList} [data-message-id]:hover`, { + backgroundColor: 'var(--sable-surface-container-hover) !important', +}); + +export const ThreadDrawerHeader = style({ + flexShrink: 0, + padding: `0 ${config.space.S200} 0 ${config.space.S400}`, + borderBottomWidth: config.borderWidth.B300, +}); + +export const ThreadDrawerContent = style({ + position: 'relative', + overflow: 'hidden', + flexGrow: 1, + minHeight: 0, // Ensure flex child can shrink below content size +}); + +export const ThreadDrawerInput = style({ + flexShrink: 0, +}); + +export const ThreadDrawerOverlay = style({ + position: 'absolute', + inset: 0, + zIndex: 10, + width: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + backgroundColor: color.Background.Container, +}); + +export const ThreadBrowserItem = style({ + width: '100%', + padding: `${config.space.S200} ${config.space.S100}`, + borderRadius: config.radii.R300, + textAlign: 'left', + cursor: 'pointer', + background: 'none', + border: 'none', + color: 'inherit', + ':hover': { + backgroundColor: color.SurfaceVariant.Container, + }, +}); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx new file mode 100644 index 000000000..31af18972 --- /dev/null +++ b/src/app/features/room/ThreadDrawer.tsx @@ -0,0 +1,823 @@ +import { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; +import { + MatrixEvent, + PushProcessor, + ReceiptType, + RelationType, + Room, + RoomEvent, + ThreadEvent, +} from '$types/matrix-sdk'; +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, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '$plugins/react-custom-html-parser'; +import { + getEditedEvent, + getEventReactions, + 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 { useSetting } from '$state/hooks/settings'; +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 { usePowerLevelsContext } from '$hooks/usePowerLevels'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +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 { RoomInput } from './RoomInput'; +import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; +import * as css from './ThreadDrawer.css'; + +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; + + 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; + onClose: () => void; + overlay?: boolean; +}; + +export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDrawerProps) { + const mx = useMatrixClient(); + const drawerRef = useRef(null); + const editor = useEditor(); + const [, forceUpdate] = useState(0); + const [editId, setEditId] = useState(undefined); + const [jumpToEventId, setJumpToEventId] = useState(undefined); + const scrollRef = useRef(null); + const prevReplyCountRef = useRef(0); + const replyEventsRef = useRef([]); + const nicknames = useAtomValue(nicknamesAtom); + const pushProcessor = useMemo(() => new PushProcessor(mx), [mx]); + const useAuthentication = useMediaAuthentication(); + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + + // Settings + const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); + const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + + // Memoized parsing options + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ) + ), + }), + [mx, room, mentionClickHandler, nicknames] + ); + + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + nicknames, + }), + [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication, nicknames] + ); + + // Power levels & permissions + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + const canRedact = permissions.action('redact', mx.getSafeUserId()); + const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); + const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); + const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); + + // Image packs + const roomToParents = useAtomValue(roomToParentsAtom); + const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); + + // Reply draft (keyed by threadRootId to match RoomInput's draftKey logic) + const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(threadRootId)); + const replyDraft = useAtomValue(roomIdToReplyDraftAtomFamily(threadRootId)); + const activeReplyId = replyDraft?.eventId; + + // User profile popup + const openUserRoomProfile = useOpenUserRoomProfile(); + + const rootEvent = room.findEventById(threadRootId); + + // Re-render when new thread events arrive (including reactions via ThreadEvent.Update). + useEffect(() => { + const isEventInThread = (mEvent: MatrixEvent): boolean => { + // Direct thread message or the root itself + if (mEvent.threadRootId === threadRootId || mEvent.getId() === threadRootId) { + return true; + } + + // Check if this is a reaction/edit targeting an event in this thread + if (reactionOrEditEvent(mEvent)) { + const relation = mEvent.getRelation(); + const targetEventId = relation?.event_id; + if (targetEventId) { + const targetEvent = room.findEventById(targetEventId); + if ( + targetEvent && + (targetEvent.threadRootId === threadRootId || targetEvent.getId() === threadRootId) + ) { + return true; + } + } + } + + return false; + }; + + const onTimeline = (mEvent: MatrixEvent) => { + if (isEventInThread(mEvent)) { + forceUpdate((n) => n + 1); + } + }; + const onRedaction = (mEvent: MatrixEvent) => { + // Redactions (removing reactions/messages) should also trigger updates + if (isEventInThread(mEvent)) { + forceUpdate((n) => n + 1); + } + }; + const onThreadUpdate = () => forceUpdate((n) => n + 1); + mx.on(RoomEvent.Timeline, onTimeline as any); + room.on(RoomEvent.Redaction, onRedaction as any); + room.on(ThreadEvent.Update, onThreadUpdate as any); + room.on(ThreadEvent.NewReply, onThreadUpdate as any); + return () => { + mx.off(RoomEvent.Timeline, onTimeline as any); + room.removeListener(RoomEvent.Redaction, onRedaction as any); + room.removeListener(ThreadEvent.Update, onThreadUpdate as any); + room.removeListener(ThreadEvent.NewReply, onThreadUpdate as any); + }; + }, [mx, room, threadRootId]); + + // Mark thread as read when viewing it + useEffect(() => { + const markThreadAsRead = async () => { + const thread = room.getThread(threadRootId); + if (!thread) return; + + const events = thread.events || []; + if (events.length === 0) return; + + const lastEvent = events[events.length - 1]; + if (!lastEvent || lastEvent.isSending()) return; + + const userId = mx.getUserId(); + if (!userId) return; + + const readUpToId = thread.getEventReadUpTo(userId, false); + const lastEventId = lastEvent.getId(); + + // Only send receipt if we haven't already read up to the last event + if (readUpToId !== lastEventId) { + try { + await mx.sendReadReceipt(lastEvent, ReceiptType.Read); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Failed to send thread read receipt:', err); + } + } + }; + + // Mark as read when opened and when new messages arrive + markThreadAsRead(); + }, [mx, room, threadRootId, forceUpdate]); + + // Use the Thread object if available (authoritative source with full history). + // Fall back to scanning the live room timeline for local echoes and the + // window before the Thread object is registered by the SDK. + const replyEvents: MatrixEvent[] = (() => { + const thread = room.getThread(threadRootId); + const fromThread = thread?.events ?? []; + if (fromThread.length > 0) { + return fromThread.filter((ev) => ev.getId() !== threadRootId && !reactionOrEditEvent(ev)); + } + return room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter( + (ev) => + ev.threadRootId === threadRootId && + ev.getId() !== threadRootId && + !reactionOrEditEvent(ev) + ); + })(); + + replyEventsRef.current = replyEvents; + + // Auto-scroll to bottom when event count grows (if the user is near the bottom). + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; + if (prevReplyCountRef.current === 0 || isAtBottom) { + el.scrollTop = el.scrollHeight; + } + prevReplyCountRef.current = replyEvents.length; + }, [replyEvents.length]); + + const handleUserClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + openUserRoomProfile( + room.roomId, + undefined, + userId, + evt.currentTarget.getBoundingClientRect() + ); + }, + [room, openUserRoomProfile] + ); + + const handleUsernameClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + const localNicknames = undefined; // will be resolved via getMemberDisplayName in editor + const name = + getMemberDisplayName(room, userId, localNicknames) ?? 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) { + // In thread mode, resetting means going back to base thread draft + setReplyDraft({ + userId: mx.getUserId() ?? '', + eventId: threadRootId, + body: '', + relation: { rel_type: RelationType.Thread, event_id: threadRootId }, + }); + return; + } + const replyEvt = room.findEventById(replyId); + if (!replyEvt) return; + const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); + const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); + const { body, formatted_body: formattedBody } = content; + const senderId = replyEvt.getSender(); + if (senderId) { + const draft: IReplyDraft = { + userId: senderId, + eventId: replyId, + body: typeof body === 'string' ? body : '', + formattedBody, + relation: { rel_type: RelationType.Thread, event_id: threadRootId }, + }; + // Only toggle off if we're actively replying to this event (non-empty body distinguishes + // a real reply draft from the seeded base-thread draft, which has body: ''). + if (activeReplyId === replyId && replyDraft?.body) { + // Toggle off — reset to base thread draft + setReplyDraft({ + userId: mx.getUserId() ?? '', + eventId: threadRootId, + body: '', + relation: { rel_type: RelationType.Thread, event_id: threadRootId }, + }); + } else { + setReplyDraft(draft); + } + } + }, + [mx, room, setReplyDraft, activeReplyId, threadRootId, replyDraft] + ); + + const handleReactionToggle = useCallback( + (targetEventId: string, key: string, shortcode?: string) => { + const threadTimelineSet = room.getThread(threadRootId)?.timelineSet; + toggleReaction(mx, room, targetEventId, key, shortcode, threadTimelineSet); + }, + [mx, room, threadRootId] + ); + + const handleEdit = useCallback( + (evtId?: string) => { + setEditId(evtId); + if (!evtId) { + ReactEditor.focus(editor); + moveCursor(editor); + } + }, + [editor] + ); + + const handleResend = useCallback( + (event: MatrixEvent) => { + mx.resendEvent(event, room); + }, + [mx, room] + ); + + const handleDeleteFailedSend = useCallback( + (event: MatrixEvent) => { + mx.cancelPendingEvent(event); + }, + [mx] + ); + + const handleOpenReply: MouseEventHandler = useCallback( + (evt) => { + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (!targetId) return; + const isRoot = targetId === threadRootId; + const isInReplies = replyEventsRef.current.some((e) => e.getId() === targetId); + if (!isRoot && !isInReplies) return; + setJumpToEventId(targetId); + setTimeout(() => setJumpToEventId(undefined), 2500); + const el = drawerRef.current; + if (el) { + const target = el.querySelector(`[data-message-id="${targetId}"]`); + target?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, + [threadRootId] + ); + + const sharedMessageProps = { + 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, + pushProcessor, + linkifyOpts, + htmlReactParserOptions, + showHideReads: hideReads, + showDeveloperTools, + onReferenceClick: handleOpenReply, + jumpToEventId, + }; + + // 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[] + ); + const latestThreadEventId = ( + replyEvents.length > 0 ? replyEvents[replyEvents.length - 1] : rootEvent + )?.getId(); + + return ( + + {/* Header */} +
+ + + + Thread + + + + + # {room.name} + + + + + +
+ + {/* Thread root message */} + {rootEvent && ( + + + + + + )} + + {/* Replies */} + + + {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 ( + + ); + })} + + + )} + + + + {/* Thread input */} + +
+ +
+ {hideReads ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/app/state/room/roomToOpenThread.ts b/src/app/state/room/roomToOpenThread.ts new file mode 100644 index 000000000..0a60fa4a7 --- /dev/null +++ b/src/app/state/room/roomToOpenThread.ts @@ -0,0 +1,14 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +const createOpenThreadAtom = () => atom(undefined); +export type TOpenThreadAtom = ReturnType; + +/** + * Tracks the currently-open thread root event ID per room. + * Key: roomId + * Value: eventId of the thread root, or undefined if no thread is open. + */ +export const roomIdToOpenThreadAtomFamily = atomFamily(() => + createOpenThreadAtom() +); diff --git a/src/app/state/room/roomToThreadBrowser.ts b/src/app/state/room/roomToThreadBrowser.ts new file mode 100644 index 000000000..3d8963165 --- /dev/null +++ b/src/app/state/room/roomToThreadBrowser.ts @@ -0,0 +1,13 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +const createThreadBrowserAtom = () => atom(false); +export type TThreadBrowserAtom = ReturnType; + +/** + * Tracks whether the thread browser panel is open per room. + * Key: roomId + */ +export const roomIdToThreadBrowserAtomFamily = atomFamily(() => + createThreadBrowserAtom() +); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index ea1a85147..69fadc021 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -5,6 +5,7 @@ import { } from 'browser-encrypt-attachment'; import { EventTimeline, + EventTimelineSet, MatrixClient, MatrixError, MatrixEvent, @@ -387,9 +388,13 @@ export const toggleReaction = ( room: Room, targetEventId: string, key: string, - shortcode?: string + shortcode?: string, + timelineSet?: EventTimelineSet ) => { - const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId); + const relations = getEventReactions( + timelineSet ?? room.getUnfilteredTimelineSet(), + targetEventId + ); const allReactions = relations?.getSortedAnnotationsByKey() ?? []; const [, reactionsSet] = allReactions.find(([k]: [string, any]) => k === key) ?? []; const reactions: MatrixEvent[] = reactionsSet ? Array.from(reactionsSet) : []; diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts index 71621d885..06a47368b 100644 --- a/src/types/matrix-sdk.ts +++ b/src/types/matrix-sdk.ts @@ -51,3 +51,6 @@ export * from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; export * from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; export * from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; + +export { ThreadEvent } from 'matrix-js-sdk/lib/models/thread'; +export type { Thread } from 'matrix-js-sdk/lib/models/thread';