From eb3b7dccc21dbbcd48be59096923930bd5b62194 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 16:28:56 -0400 Subject: [PATCH 1/5] feat(threads): add core thread state management and types - Add roomToOpenThread atom family for tracking active thread per room - Add roomToThreadBrowser atom family for thread browser visibility - Add Thread, ThreadEvent, and NotificationCountType exports to matrix-sdk types - Update toggleReaction to support thread timeline sets - Foundation for thread feature state management --- src/app/state/room/roomToOpenThread.ts | 14 ++++++++++++++ src/app/state/room/roomToThreadBrowser.ts | 13 +++++++++++++ src/app/utils/matrix.ts | 9 +++++++-- src/types/matrix-sdk.ts | 3 +++ 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/app/state/room/roomToOpenThread.ts create mode 100644 src/app/state/room/roomToThreadBrowser.ts 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'; From 85a6f7c6d407acaefd0098153dbcd9b99acaca28 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 16:29:08 -0400 Subject: [PATCH 2/5] feat(threads): implement ThreadDrawer and ThreadBrowser components ThreadDrawer: - Side panel for viewing and replying within threads - Full message rendering with reactions, edits, deletions, replies - Auto-scrolls to bottom on new replies - Marks thread as read when viewing - Isolated draft state per thread - Emoji picker and sticker support ThreadBrowser: - List view of all threads in room sorted by latest activity - Search functionality to filter threads by content - Thread preview with root message, reply count, and latest reply - Click to open thread in drawer - Jump button to navigate to root message in main timeline Both: - Desktop: side panel mode - Mobile: full-screen overlay mode - Re-render on ThreadEvent updates --- src/app/features/room/ThreadBrowser.tsx | 387 ++++++++++ src/app/features/room/ThreadDrawer.css.ts | 66 ++ src/app/features/room/ThreadDrawer.tsx | 823 ++++++++++++++++++++++ 3 files changed, 1276 insertions(+) create mode 100644 src/app/features/room/ThreadBrowser.tsx create mode 100644 src/app/features/room/ThreadDrawer.css.ts create mode 100644 src/app/features/room/ThreadDrawer.tsx diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx new file mode 100644 index 000000000..586e727ab --- /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) => + 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, b) => { + 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) => { + 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) => ( + + ))} + + )} + + +
+ ); +} 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 ? ( + + ) : ( + + )} +
+
+ ); +} From 37db7eee554ee7fe69b40f7eeaa58a7a1995eec2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 16:31:24 -0400 Subject: [PATCH 3/5] feat(threads): integrate threads into room timeline and header RoomTimeline: - Add ThreadReplyChip component showing reply count, avatars, latest reply preview - Filter thread replies from main timeline (keep only root messages) - Add thread button to start threads from messages - Exclude thread events from triggering timeline pagination - Re-render on ThreadEvent updates for live reply counts - Thread reply counts exclude root message, reactions, and edits RoomViewHeader: - Add thread button with unread badge using SDK notification APIs - Initialize Thread objects from room history on mount - Scan timeline for thread roots and thread replies - Auto-create Thread objects for new thread events - Toggle between thread drawer and thread browser - Close drawer and open browser when clicking button with drawer open Room: - Render ThreadDrawer when thread is open (desktop: side panel, mobile: overlay) - Render ThreadBrowser when browser open and no thread active - Auto-open thread drawer when navigating to thread event IDs - Coordinate openThreadId and threadBrowserOpen state RoomViewFollowing: - Support filtering read receipts by thread participant IDs - Show thread-specific following indicator --- src/app/features/room/Room.tsx | 80 +++++- src/app/features/room/RoomTimeline.tsx | 295 +++++++++++++++++--- src/app/features/room/RoomViewFollowing.tsx | 10 +- src/app/features/room/RoomViewHeader.tsx | 174 +++++++++++- 4 files changed, 509 insertions(+), 50 deletions(-) diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index b7aef9107..72608dc69 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -1,8 +1,8 @@ -import { useCallback } from 'react'; +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,10 +15,14 @@ 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 { RoomViewHeader } from './RoomViewHeader'; import { MembersDrawer } from './MembersDrawer'; import { RoomView } from './RoomView'; import { CallChatView } from './CallChatView'; +import { ThreadDrawer } from './ThreadDrawer'; +import { ThreadBrowser } from './ThreadBrowser'; export function Room() { const { eventId } = useParams(); @@ -32,6 +36,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, @@ -49,7 +77,7 @@ export function Room() { return ( - + {callView && (screenSize === ScreenSize.Desktop || !chat) && ( @@ -87,6 +115,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/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 39fc9d1cc..acf6f7f2b 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'; @@ -522,6 +527,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); @@ -590,6 +721,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); @@ -767,6 +900,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 @@ -896,6 +1034,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 @@ -1289,15 +1436,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( @@ -1445,19 +1601,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)} @@ -1539,19 +1711,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() ?? '')} @@ -1670,19 +1858,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() ?? '')} @@ -2091,6 +2295,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} + + + )} + + + )} + )} From ab1829daff7b619c51784064a9d28d6d131a75d4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 16:32:10 -0400 Subject: [PATCH 4/5] feat(threads): add thread reply input handling RoomInput: - Seed thread reply draft when opening drawer from notification - Handle thread relation in message composition - Support isolated draft state per thread - Auto-populate replyTo when replying in thread - Send messages with thread relation metadata - Clear thread draft after sending reply --- src/app/features/room/RoomInput.tsx | 115 +++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 18 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 6cbf52de1..a93c0954b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -163,15 +163,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; }; @@ -185,9 +200,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'); @@ -203,8 +222,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( @@ -213,7 +232,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) @@ -328,6 +347,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]); @@ -343,7 +382,7 @@ export const RoomInput = forwardRef( resetEditor(editor); resetEditorHistory(editor); }, - [roomId, editor, setMsgDraft] + [draftKey, editor, setMsgDraft] ); useEffect(() => { @@ -417,12 +456,21 @@ 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).catch((error: unknown) => { + mx.sendMessage(roomId, threadRootId ?? null, content as any).catch((error: unknown) => { log.error('failed to send uploaded message', { roomId }, error); throw error; }) @@ -545,7 +593,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) { @@ -569,7 +627,7 @@ export const RoomInput = forwardRef( } else if (editingScheduledDelayId) { try { await cancelDelayedEvent(mx, editingScheduledDelayId); - mx.sendMessage(roomId, content as any); + mx.sendMessage(roomId, threadRootId ?? null, content as any); invalidate(); setEditingScheduledDelayId(null); resetInput(); @@ -578,7 +636,7 @@ export const RoomInput = forwardRef( } } else { resetInput(); - mx.sendMessage(roomId, content as any).catch((error: unknown) => { + mx.sendMessage(roomId, threadRootId ?? null, content as any).catch((error: unknown) => { log.error('failed to send message', { roomId }, error); }); } @@ -588,6 +646,7 @@ export const RoomInput = forwardRef( canSendReaction, mx, roomId, + threadRootId, replyDraft, silentReply, scheduledTime, @@ -692,7 +751,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); }; @@ -851,7 +919,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" From cd4cc56c9b30ac0b565dd4a3acf9a851fa825315 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 13 Mar 2026 16:32:23 -0400 Subject: [PATCH 5/5] feat(threads): add supporting features and infrastructure Editor: - Fix Slate initialValue per-instance to support multiple editors - Each editor (main timeline, thread drawer) maintains independent state - Prevents value conflicts between simultaneous editors Thread Mockup: - Add ThreadMockupPage for development and design testing - Full mockup UI with realistic thread interactions - Navigation from WelcomePage for easy access - Routes added for /mockup/threads path Quality Tooling: - Add check-quality.sh script for pre-push validation - Runs type checks, lint, and builds before pushing - Helps catch issues before CI/CD CI/CD: - Update GitHub Actions workflows for quality checks - Update dependabot and cloudflare preview workflows - Update docker-publish workflow - Update require-changeset workflow Call Preferences: - Clean up unused imports in CallControls.tsx - Remove orphaned callPreferences hook code Changesets: - Add feat-threads.md for minor version bump - Remove fix_call_preferences.md (superseded) --- .changeset/feat-threads.md | 5 + .changeset/fix_call_preferences.md | 5 - .github/dependabot.yml | 2 +- .github/workflows/cloudflare-web-preview.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- .github/workflows/quality-checks.yml | 22 - .github/workflows/require-changeset.yml | 2 - scripts/check-quality.sh | 19 + src/app/components/editor/Editor.tsx | 18 +- src/app/features/call/CallControls.tsx | 21 +- .../thread-mockup/ThreadMockupPage.tsx | 684 ++++++++++++++++++ src/app/features/thread-mockup/index.ts | 1 + .../thread-mockup/thread-mockup.css.ts | 188 +++++ src/app/pages/Router.tsx | 3 + src/app/pages/client/WelcomePage.tsx | 12 + src/app/pages/paths.ts | 2 + src/app/state/hooks/callPreferences.ts | 2 - 17 files changed, 934 insertions(+), 56 deletions(-) create mode 100644 .changeset/feat-threads.md delete mode 100644 .changeset/fix_call_preferences.md create mode 100755 scripts/check-quality.sh create mode 100644 src/app/features/thread-mockup/ThreadMockupPage.tsx create mode 100644 src/app/features/thread-mockup/index.ts create mode 100644 src/app/features/thread-mockup/thread-mockup.css.ts 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/.github/dependabot.yml b/.github/dependabot.yml index 848674637..e6ba80eef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,7 +36,7 @@ updates: - package-ecosystem: npm cooldown: - default-days: 7 + default-days: 1 directory: / schedule: interval: daily diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 5ddfe5a0e..892fd8eab 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -66,7 +66,7 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ]; then echo "alias=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" else - branch="${GITHUB_REF_NAME}" + branch="${{ github.ref_name }}" alias="$(echo "$branch" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-\|-$//g')" echo "alias=${alias}" >> "$GITHUB_OUTPUT" fi diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b5f9bff29..f977ee2f6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,7 +4,7 @@ on: push: branches: [dev] tags: - - 'v*' + - 'sable/v*' pull_request: paths: - 'Dockerfile' diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 6928af6ea..811c83a98 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -4,13 +4,11 @@ on: pull_request: push: branches: [dev] - merge_group: jobs: format: name: Format check runs-on: ubuntu-latest - if: github.head_ref != 'release' permissions: contents: read steps: @@ -28,7 +26,6 @@ jobs: lint: name: Lint runs-on: ubuntu-latest - if: github.head_ref != 'release' permissions: contents: read steps: @@ -46,7 +43,6 @@ jobs: typecheck: name: Typecheck runs-on: ubuntu-latest - if: github.head_ref != 'release' permissions: contents: read steps: @@ -64,7 +60,6 @@ jobs: knip: name: Knip runs-on: ubuntu-latest - if: github.head_ref != 'release' permissions: contents: read steps: @@ -78,20 +73,3 @@ jobs: - name: Run Knip run: pnpm run knip - - build: - name: Build - runs-on: ubuntu-latest - if: github.head_ref != 'release' - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Setup app and build - uses: ./.github/actions/setup - with: - build: 'true' diff --git a/.github/workflows/require-changeset.yml b/.github/workflows/require-changeset.yml index 17dfda277..d01286639 100644 --- a/.github/workflows/require-changeset.yml +++ b/.github/workflows/require-changeset.yml @@ -3,7 +3,6 @@ name: Require Changeset on: pull_request: types: [opened, synchronize, reopened, labeled, unlabeled] - merge_group: branches: [dev] permissions: {} @@ -11,7 +10,6 @@ permissions: {} jobs: require-changeset: runs-on: ubuntu-latest - if: github.head_ref != 'release' && github.event_name != 'merge_group' permissions: contents: read pull-requests: write diff --git a/scripts/check-quality.sh b/scripts/check-quality.sh new file mode 100755 index 000000000..e59cd1a5f --- /dev/null +++ b/scripts/check-quality.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e + +echo "Running quality checks..." +echo "" + +echo "1/3 Checking formatting..." +pnpm run fmt:check + +echo "" +echo "2/3 Running linter..." +pnpm run lint + +echo "" +echo "3/3 Running type checker..." +pnpm run typecheck + +echo "" +echo "✅ All quality checks passed!" 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/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index 6643069c5..4688ce8a7 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -1,4 +1,4 @@ -import { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'; +import { MouseEventHandler, useCallback, useRef, useState } from 'react'; import { Box, Button, @@ -15,13 +15,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { SequenceCard } from '$components/sequence-card'; -import { CallEmbed, useCallControlState } from '$plugins/call'; -import { stopPropagation } from '$utils/keyboard'; -import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; -import { useRoom } from '$hooks/useRoom'; -import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; -import { useCallPreferences } from '$state/hooks/callPreferences'; +import { SequenceCard } from '../../components/sequence-card'; import * as css from './styles.css'; import { ChatButton, @@ -31,6 +25,11 @@ import { SoundButton, VideoButton, } from './Controls'; +import { CallEmbed, useCallControlState } from '../../plugins/call'; +import { stopPropagation } from '../../utils/keyboard'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { useRoom } from '../../hooks/useRoom'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; type CallControlsProps = { callEmbed: CallEmbed; @@ -46,12 +45,6 @@ export function CallControls({ callEmbed }: CallControlsProps) { callEmbed.control ); - const { setPreferences } = useCallPreferences(); - - useEffect(() => { - setPreferences({ microphone, video, sound }); - }, [microphone, video, sound, setPreferences]); - const [cords, setCords] = useState(); const handleOpenMenu: MouseEventHandler = (evt) => { diff --git a/src/app/features/thread-mockup/ThreadMockupPage.tsx b/src/app/features/thread-mockup/ThreadMockupPage.tsx new file mode 100644 index 000000000..669270a7a --- /dev/null +++ b/src/app/features/thread-mockup/ThreadMockupPage.tsx @@ -0,0 +1,684 @@ +import { useState } from 'react'; +import { + Box, + Chip, + Header, + Icon, + IconButton, + Icons, + Line, + Scroll, + Text, + config, + toRem, +} from 'folds'; +import { Page, PageHeader } from '$components/page'; +import * as css from './thread-mockup.css'; + +// --------------------------------------------------------------------------- +// Mock data +// --------------------------------------------------------------------------- + +type MockReply = { + id: string; + sender: string; + senderColor: string; + initial: string; + body: string; + time: string; +}; + +type MockMessage = { + id: string; + sender: string; + senderColor: string; + initial: string; + body: string; + time: string; + threadCount?: number; + threadPreview?: string; + threadParticipants?: { initial: string; color: string }[]; +}; + +const MESSAGES: MockMessage[] = [ + { + id: 'msg1', + sender: 'Alice', + senderColor: '#a855f7', + initial: 'A', + body: 'Has anyone looked at the new design system yet?', + time: '11:02 AM', + }, + { + id: 'msg2', + sender: 'Bob', + senderColor: '#3b82f6', + initial: 'B', + body: 'Yeah! I think we should move the navigation to the left sidebar. What does everyone think about that?', + time: '11:05 AM', + threadCount: 4, + threadPreview: 'Carol: I agree, it makes more sense for larger screens', + threadParticipants: [ + { initial: 'C', color: '#ec4899' }, + { initial: 'D', color: '#10b981' }, + { initial: 'A', color: '#a855f7' }, + ], + }, + { + id: 'msg3', + sender: 'Carol', + senderColor: '#ec4899', + initial: 'C', + body: 'I pushed the updated mockups to Figma, check them out when you get a chance 🎨', + time: '11:22 AM', + }, + { + id: 'msg4', + sender: 'Alice', + senderColor: '#a855f7', + initial: 'A', + body: 'Looks great! One question — are we keeping the current colour palette or exploring new options?', + time: '11:24 AM', + threadCount: 2, + threadPreview: 'Bob: I think we should try a few options first', + threadParticipants: [{ initial: 'B', color: '#3b82f6' }], + }, +]; + +const REPLIES: Record = { + msg2: [ + { + id: 'r1', + sender: 'Carol', + senderColor: '#ec4899', + initial: 'C', + body: 'I agree, it makes more sense for larger screens', + time: '11:08 AM', + }, + { + id: 'r2', + sender: 'Dave', + senderColor: '#10b981', + initial: 'D', + body: 'Could work on mobile too with a bottom sheet pattern', + time: '11:10 AM', + }, + { + id: 'r3', + sender: 'Alice', + senderColor: '#a855f7', + initial: 'A', + body: 'Good point! Maybe collapsible by default on mobile?', + time: '11:12 AM', + }, + { + id: 'r4', + sender: 'Bob', + senderColor: '#3b82f6', + initial: 'B', + body: 'Yes, and we can persist the open/closed state per session', + time: '11:14 AM', + }, + ], + msg4: [ + { + id: 'r5', + sender: 'Bob', + senderColor: '#3b82f6', + initial: 'B', + body: "I think we should try a few options first — let's create some variations", + time: '11:26 AM', + }, + { + id: 'r6', + sender: 'Carol', + senderColor: '#ec4899', + initial: 'C', + body: "Agreed, let's make it fully themeable from the start", + time: '11:31 AM', + }, + ], +}; + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +type AvatarCircleProps = { + initial: string; + color: string; + small?: boolean; +}; + +function AvatarCircle({ initial, color, small }: AvatarCircleProps) { + return ( +
+ {initial} +
+ ); +} + +type MockReplyItemProps = { + reply: MockReply; +}; + +function MockReplyItem({ reply }: MockReplyItemProps) { + return ( +
+ + + + + {reply.sender} + + + {reply.time} + + + {reply.body} + +
+ ); +} + +type ThreadChipProps = { + message: MockMessage; + onClick?: () => void; + active?: boolean; +}; + +function ThreadCountChip({ message, onClick, active }: ThreadChipProps) { + if (!message.threadCount) return null; + return ( +
+
+ {message.threadParticipants?.map((p) => ( + + ))} +
+ } + > + + {message.threadCount} {message.threadCount === 1 ? 'reply' : 'replies'} + + + {message.threadPreview && ( + + {message.threadPreview} + + )} +
+ ); +} + +type MessageItemProps = { + message: MockMessage; + onOpenThread?: () => void; + active?: boolean; + showThreadChip?: boolean; +}; + +function MessageItem({ message, onOpenThread, active, showThreadChip = true }: MessageItemProps) { + return ( + +
+ + + + + {message.sender} + + + {message.time} + + + {message.body} + + {message.threadCount && ( + + + + )} +
+ {showThreadChip && message.threadCount && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Thread Panel (shared between Side Panel and Overlay variants) +// --------------------------------------------------------------------------- + +type ThreadPanelProps = { + messageId: string; + onClose: () => void; +}; + +function ThreadPanelContents({ messageId, onClose }: ThreadPanelProps) { + const rootMsg = MESSAGES.find((m) => m.id === messageId); + const replies = REPLIES[messageId] ?? []; + if (!rootMsg) return null; + + return ( + <> +
+ + + + + Thread + + + + # general + + + + + +
+ + + {/* Root message */} +
+ + + + + + {rootMsg.sender} + + + {rootMsg.time} + + + {rootMsg.body} + + +
+ + {/* Reply count label */} + + + {replies.length} {replies.length === 1 ? 'reply' : 'replies'} + + + + + {/* Replies */} + + {replies.map((reply) => ( + + ))} + +
+ + {/* Thread input */} +
+ + +
+ + Reply in thread… + +
+ + + +
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Variant A: Side Panel +// --------------------------------------------------------------------------- + +function SidePanelVariant() { + const [openThread, setOpenThread] = useState('msg2'); + + return ( +
+ {/* Timeline */} +
+ + + {MESSAGES.map((msg) => ( + + setOpenThread(openThread === msg.id ? '' : msg.id)} + active={openThread === msg.id} + /> + + ))} + + + + {/* Room input */} +
+ + +
+ + Message # general… + +
+ + + +
+
+
+ + {/* Thread panel */} + {openThread && ( + <> + +
+ setOpenThread('')} /> +
+ + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Variant B: Inline Replies +// --------------------------------------------------------------------------- + +function InlineVariant() { + const [expanded, setExpanded] = useState>({ msg2: true }); + + const toggle = (id: string) => setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); + + return ( +
+
+ + + {MESSAGES.map((msg) => ( + + {/* Message row (no chip, controls handled inline) */} + + + {/* Inline expand/collapse */} + {msg.threadCount && ( +
+ toggle(msg.id)} + before={ + + } + > + + {expanded[msg.id] ? 'Collapse' : `Show ${msg.threadCount} replies`} + {!expanded[msg.id] && msg.threadPreview && ( + + — {msg.threadPreview} + + )} + + +
+ )} + + {/* Expanded inline replies */} + {msg.threadCount && expanded[msg.id] && ( +
+ + {(REPLIES[msg.id] ?? []).map((reply) => ( + + ))} + + {/* Inline reply input */} + + +
+ + Reply to thread… + +
+
+
+ )} +
+ ))} +
+
+ +
+ + +
+ + Message # general… + +
+ + + +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Variant C: Overlay Panel +// --------------------------------------------------------------------------- + +function OverlayVariant() { + const [openThread, setOpenThread] = useState(null); + + return ( +
+ {/* Timeline */} +
+ + + {MESSAGES.map((msg) => ( + setOpenThread(msg.id)} + active={openThread === msg.id} + /> + ))} + + + +
+ + +
+ + Message # general… + +
+ + + +
+
+
+ + {/* Overlay */} + {openThread && ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
{ + if (e.target === e.currentTarget) setOpenThread(null); + }} + > +
+ setOpenThread(null)} /> +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Variant descriptions +// --------------------------------------------------------------------------- + +const VARIANTS = [ + { + id: 'side-panel' as const, + label: 'Side Panel', + icon: Icons.Thread, + description: + 'Thread opens as a persistent side panel next to the timeline (à la Discord/Slack)', + }, + { + id: 'inline' as const, + label: 'Inline', + icon: Icons.ThreadReply, + description: 'Replies expand inline below their parent message — no extra panel needed', + }, + { + id: 'overlay' as const, + label: 'Overlay', + icon: Icons.ThreadUnread, + description: 'Thread slides in as a floating overlay on top of the timeline', + }, +] as const; + +type VariantId = (typeof VARIANTS)[number]['id']; + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +export function ThreadMockupPage() { + const [variant, setVariant] = useState('side-panel'); + const currentVariant = VARIANTS.find((v) => v.id === variant)!; + + return ( + + {/* Simulated room header */} + + + + + general + + + Design system discussion + + + + {/* Variant badge */} + + + + Mockup: + + + {currentVariant.label} + + + + + {/* Variant selector bar */} +
+ + + Thread UI + + + Toggle between approaches: + + {VARIANTS.map((v) => ( + setVariant(v.id)} + before={} + > + {v.label} + + ))} + + + {currentVariant.description} + +
+ + {/* Mockup content */} + {variant === 'side-panel' && } + {variant === 'inline' && } + {variant === 'overlay' && } +
+ ); +} diff --git a/src/app/features/thread-mockup/index.ts b/src/app/features/thread-mockup/index.ts new file mode 100644 index 000000000..3e101e51d --- /dev/null +++ b/src/app/features/thread-mockup/index.ts @@ -0,0 +1 @@ +export { ThreadMockupPage } from './ThreadMockupPage'; diff --git a/src/app/features/thread-mockup/thread-mockup.css.ts b/src/app/features/thread-mockup/thread-mockup.css.ts new file mode 100644 index 000000000..2c640ae31 --- /dev/null +++ b/src/app/features/thread-mockup/thread-mockup.css.ts @@ -0,0 +1,188 @@ +import { style } from '@vanilla-extract/css'; +import { config, toRem } from 'folds'; + +export const MockupPage = style({ + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', +}); + +export const VariantBar = style({ + padding: `${config.space.S100} ${config.space.S300}`, + flexShrink: 0, + display: 'flex', + alignItems: 'center', + gap: config.space.S100, + borderBottomWidth: config.borderWidth.B300, + borderBottomStyle: 'solid', +}); + +export const ContentArea = style({ + flex: 1, + overflow: 'hidden', + display: 'flex', + position: 'relative', +}); + +export const Timeline = style({ + flex: 1, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', +}); + +export const TimelineScroll = style({ + flex: 1, + overflow: 'hidden', +}); + +export const MessageRow = style({ + padding: `${config.space.S100} ${config.space.S400}`, + display: 'flex', + gap: config.space.S300, + borderRadius: config.radii.R300, + transition: 'background 80ms', + selectors: { + '&:hover': { + backgroundColor: 'var(--mx-bg-surface-hover)', + }, + '&[data-active="true"]': { + backgroundColor: 'var(--mx-bg-surface-active)', + }, + }, +}); + +export const ThreadChipRow = style({ + paddingLeft: toRem(80), + paddingBottom: config.space.S100, + display: 'flex', + alignItems: 'center', + gap: config.space.S100, +}); + +export const InlineThreadContainer = style({ + marginLeft: toRem(80), + marginRight: config.space.S400, + marginBottom: config.space.S200, + paddingLeft: config.space.S300, + borderRadius: config.radii.R300, + borderLeftWidth: '2px', + borderLeftStyle: 'solid', + overflow: 'hidden', +}); + +export const InlineReplyRow = style({ + padding: `${config.space.S100} 0`, + display: 'flex', + gap: config.space.S200, +}); + +export const ThreadPanel = style({ + width: toRem(340), + flexShrink: 0, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + borderLeftWidth: config.borderWidth.B300, + borderLeftStyle: 'solid', +}); + +export const ThreadPanelHeader = style({ + padding: `0 ${config.space.S200} 0 ${config.space.S300}`, + flexShrink: 0, + borderBottomWidth: config.borderWidth.B300, + borderBottomStyle: 'solid', +}); + +export const ThreadPanelScroll = style({ + flex: 1, + overflow: 'hidden', +}); + +export const ThreadRootMsg = style({ + padding: config.space.S300, + marginBottom: config.space.S100, + borderBottomWidth: config.borderWidth.B300, + borderBottomStyle: 'solid', +}); + +export const InputArea = style({ + padding: `${config.space.S200} ${config.space.S300}`, + flexShrink: 0, + borderTopWidth: config.borderWidth.B300, + borderTopStyle: 'solid', +}); + +export const MockInput = style({ + padding: `${config.space.S200} ${config.space.S300}`, + borderRadius: config.radii.R300, + borderWidth: config.borderWidth.B300, + borderStyle: 'solid', + flex: 1, + display: 'flex', + alignItems: 'center', +}); + +export const OverlayBackdrop = style({ + position: 'absolute', + inset: 0, + backgroundColor: 'rgba(0, 0, 0, 0.45)', + zIndex: 10, + display: 'flex', + justifyContent: 'flex-end', +}); + +export const OverlayPanel = style({ + width: toRem(400), + maxWidth: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +}); + +export const AvatarCircle = style({ + width: toRem(36), + height: toRem(36), + borderRadius: '50%', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 700, + fontSize: toRem(15), + color: 'white', + userSelect: 'none', +}); + +export const SmallAvatarCircle = style({ + width: toRem(24), + height: toRem(24), + borderRadius: '50%', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 600, + fontSize: toRem(11), + color: 'white', + userSelect: 'none', +}); + +export const ParticipantAvatars = style({ + display: 'flex', + alignItems: 'center', + gap: toRem(-4), +}); + +export const NewBadge = style({ + padding: `0 ${config.space.S100}`, + borderRadius: config.radii.R300, + fontSize: toRem(10), + fontWeight: 700, + letterSpacing: '0.04em', + color: 'white', + backgroundColor: 'var(--mx-tc-primary)', + flexShrink: 0, +}); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index f14567f7d..f5e7d3b75 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -26,11 +26,13 @@ import { NotificationJumper } from '$hooks/useNotificationJumper'; import { SearchModalRenderer } from '$features/search'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; +import { ThreadMockupPage } from '$features/thread-mockup'; import { AuthLayout, Login, Register, ResetPassword } from './auth'; import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, + THREAD_MOCKUP_PATH, LOGIN_PATH, INBOX_PATH, REGISTER_PATH, @@ -343,6 +345,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> + } /> Page not found

} />
diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 4b605e59e..11705de7d 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,8 +1,11 @@ import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds'; +import { useNavigate } from 'react-router-dom'; import { Page, PageHero, PageHeroSection } from '$components/page'; import CinnySVG from '$public/res/svg/cinny.svg'; +import { THREAD_MOCKUP_PATH } from '../paths'; export function WelcomePage() { + const navigate = useNavigate(); return ( + diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 82f8c6dd2..9ff0a8319 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -1,5 +1,7 @@ export const ROOT_PATH = '/'; +export const THREAD_MOCKUP_PATH = '/thread-mockup/'; + export type LoginPathSearchParams = { username?: string; email?: string; diff --git a/src/app/state/hooks/callPreferences.ts b/src/app/state/hooks/callPreferences.ts index d02700f05..829ed4b43 100644 --- a/src/app/state/hooks/callPreferences.ts +++ b/src/app/state/hooks/callPreferences.ts @@ -18,7 +18,6 @@ export const useCallPreferences = (): CallPreferences & { toggleMicrophone: () => void; toggleVideo: () => void; toggleSound: () => void; - setPreferences: (prefs: CallPreferences) => void; } => { const callPrefAtom = useCallPreferencesAtom(); const [pref, setPref] = useAtom(callPrefAtom); @@ -58,6 +57,5 @@ export const useCallPreferences = (): CallPreferences & { toggleMicrophone, toggleVideo, toggleSound, - setPreferences: setPref, }; };