From a3b15a0561f6bcf49e04f2cf038f362a5bbdad43 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 10 Mar 2026 08:44:09 -0400 Subject: [PATCH 01/15] feat: Add adaptive background pagination for message history - Add device capability detection using same logic as sliding sync - Implement background pagination on room open with adaptive limits: - High-end (4g+/desktop/high-memory): 500 messages, 1s delay - Medium (3g or <=4GB RAM): 250 messages, 2s delay - Low-end (save-data/2g or mobile): 100 messages, 3s delay - Detection based on: connection type, device memory, save-data preference - Improves message history availability without blocking initial room load - Consistent with sliding sync's adaptive behavior --- .changeset/feat-background-pagination.md | 5 ++ src/app/features/room/Room.tsx | 32 ++++++- src/app/utils/device-capabilities.ts | 107 +++++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 .changeset/feat-background-pagination.md create mode 100644 src/app/utils/device-capabilities.ts diff --git a/.changeset/feat-background-pagination.md b/.changeset/feat-background-pagination.md new file mode 100644 index 000000000..73df75303 --- /dev/null +++ b/.changeset/feat-background-pagination.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add adaptive background pagination for improved message history availability. When entering a room, the app now proactively loads additional message history in the background based on device capabilities (high-end: 500 messages, medium: 250 messages, low-end/mobile: 100 messages) without blocking the initial room load. Uses the same adaptive detection logic as sliding sync for consistency. diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index b7aef9107..1abfd9399 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -1,8 +1,9 @@ -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 { Direction } from '$types/matrix-sdk'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; @@ -15,6 +16,7 @@ import { useRoomMembers } from '$hooks/useRoomMembers'; import { CallView } from '$features/call/CallView'; import { WidgetsDrawer } from '$features/widgets/WidgetsDrawer'; import { callChatAtom } from '$state/callEmbed'; +import { getBackgroundPaginationConfig } from '$utils/device-capabilities'; import { RoomViewHeader } from './RoomViewHeader'; import { MembersDrawer } from './MembersDrawer'; import { RoomView } from './RoomView'; @@ -45,6 +47,34 @@ export function Room() { ) ); + // Background pagination to load additional message history + useEffect(() => { + const config = getBackgroundPaginationConfig(); + if (!config.enabled) return undefined; + + let cancelled = false; + const timer = setTimeout(() => { + if (cancelled) return; + + const timeline = room.getLiveTimeline(); + const token = timeline.getPaginationToken(Direction.Backward); + + if (token) { + mx.paginateEventTimeline(timeline, { + backwards: true, + limit: config.limit, + }).catch((err) => { + console.warn('Background pagination failed:', err); + }); + } + }, config.delayMs); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [room, mx]); + const callView = room.isCallRoom(); return ( diff --git a/src/app/utils/device-capabilities.ts b/src/app/utils/device-capabilities.ts new file mode 100644 index 000000000..4c0df0b0a --- /dev/null +++ b/src/app/utils/device-capabilities.ts @@ -0,0 +1,107 @@ +/** + * Device performance tier for adaptive features + */ +export type DevicePerformanceTier = 'low' | 'medium' | 'high'; + +/** + * Configuration for background pagination based on device capabilities + */ +export interface BackgroundPaginationConfig { + enabled: boolean; + delayMs: number; + limit: number; +} + +/** + * Read adaptive signals from browser APIs + * This mirrors the logic used in slidingSync.ts for consistency + */ +function readAdaptiveSignals() { + const navigatorLike = typeof navigator !== 'undefined' ? navigator : undefined; + const connection = (navigatorLike as any)?.connection; + const effectiveType = connection?.effectiveType; + const deviceMemory = (navigatorLike as any)?.deviceMemory; + const uaMobile = (navigatorLike as any)?.userAgentData?.mobile; + const fallbackMobileUA = navigatorLike?.userAgent ?? ''; + const mobileByUA = + typeof uaMobile === 'boolean' + ? uaMobile + : /Mobi|Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(fallbackMobileUA); + const saveData = connection?.saveData === true; + const normalizedEffectiveType = typeof effectiveType === 'string' ? effectiveType : null; + const normalizedDeviceMemory = typeof deviceMemory === 'number' ? deviceMemory : null; + const missingSignals = + Number(normalizedEffectiveType === null) + Number(normalizedDeviceMemory === null); + + return { + saveData, + effectiveType: normalizedEffectiveType, + deviceMemoryGb: normalizedDeviceMemory, + mobile: mobileByUA, + missingSignals, + }; +} + +/** + * Detect device performance tier based on hardware capabilities + * Uses the same logic as sliding sync for consistency + */ +export function getDevicePerformanceTier(): DevicePerformanceTier { + const signals = readAdaptiveSignals(); + + // Low-end: save data enabled or very slow connection + if (signals.saveData || signals.effectiveType === 'slow-2g' || signals.effectiveType === '2g') { + return 'low'; + } + + // Medium: 3g connection or low memory device + if ( + signals.effectiveType === '3g' || + (signals.deviceMemoryGb !== null && signals.deviceMemoryGb <= 4) + ) { + return 'medium'; + } + + // Medium fallback: mobile with missing signal data + if (signals.mobile && signals.missingSignals > 0) { + return 'medium'; + } + + // High-end: everything else (4g+, desktop, or high memory) + return 'high'; +} + +/** + * Get background pagination configuration based on device capabilities + * Uses the same adaptive detection logic as sliding sync for consistency + */ +export function getBackgroundPaginationConfig(): BackgroundPaginationConfig { + const tier = getDevicePerformanceTier(); + + switch (tier) { + case 'high': + return { + enabled: true, + delayMs: 1000, // 1 second delay + limit: 500, // Load 500 messages + }; + case 'medium': + return { + enabled: true, + delayMs: 2000, // 2 second delay + limit: 250, // Load 250 messages + }; + case 'low': + return { + enabled: true, + delayMs: 3000, // 3 second delay + limit: 100, // Load 100 messages + }; + default: + return { + enabled: false, + delayMs: 0, + limit: 0, + }; + } +} From 61d302b235d8d3dc1887e1479c9a495438f45564 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 10 Mar 2026 09:21:57 -0400 Subject: [PATCH 02/15] Update changeset --- .changeset/feat-background-pagination.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/feat-background-pagination.md b/.changeset/feat-background-pagination.md index 73df75303..9b93320ba 100644 --- a/.changeset/feat-background-pagination.md +++ b/.changeset/feat-background-pagination.md @@ -2,4 +2,4 @@ default: minor --- -Add adaptive background pagination for improved message history availability. When entering a room, the app now proactively loads additional message history in the background based on device capabilities (high-end: 500 messages, medium: 250 messages, low-end/mobile: 100 messages) without blocking the initial room load. Uses the same adaptive detection logic as sliding sync for consistency. +Add adaptive background pagination for improved message history availability. From f1ebfb8839e988b0d3b4276d640e103a2f5e685c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 10 Mar 2026 09:25:24 -0400 Subject: [PATCH 03/15] refactor: Extract adaptive signal detection into shared utility - Export AdaptiveSignals type and readAdaptiveSignals() from device-capabilities.ts - Update slidingSync.ts to import shared logic instead of duplicating it - Single source of truth for device capability detection across the app - Reduces code duplication and ensures consistent behavior --- src/app/utils/device-capabilities.ts | 18 ++++++++++++--- src/client/slidingSync.ts | 34 ++-------------------------- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/src/app/utils/device-capabilities.ts b/src/app/utils/device-capabilities.ts index 4c0df0b0a..4f0dbc35d 100644 --- a/src/app/utils/device-capabilities.ts +++ b/src/app/utils/device-capabilities.ts @@ -13,10 +13,22 @@ export interface BackgroundPaginationConfig { } /** - * Read adaptive signals from browser APIs - * This mirrors the logic used in slidingSync.ts for consistency + * Adaptive signals from browser APIs for device/network capability detection. + * Shared by sliding sync and background pagination for consistent behavior. */ -function readAdaptiveSignals() { +export type AdaptiveSignals = { + saveData: boolean; + effectiveType: string | null; + deviceMemoryGb: number | null; + mobile: boolean; + missingSignals: number; +}; + +/** + * Read adaptive signals from browser APIs. + * Single source of truth for device capability detection across the app. + */ +export function readAdaptiveSignals(): AdaptiveSignals { const navigatorLike = typeof navigator !== 'undefined' ? navigator : undefined; const connection = (navigatorLike as any)?.connection; const effectiveType = connection?.effectiveType; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index f2c9257dc..34b73d453 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -86,38 +86,8 @@ const clampPositive = (value: number | undefined, fallback: number): number => { return Math.round(value); }; -type AdaptiveSignals = { - saveData: boolean; - effectiveType: string | null; - deviceMemoryGb: number | null; - mobile: boolean; - missingSignals: number; -}; - -const readAdaptiveSignals = (): AdaptiveSignals => { - const navigatorLike = typeof navigator !== 'undefined' ? navigator : undefined; - const connection = (navigatorLike as any)?.connection; - const effectiveType = connection?.effectiveType; - const deviceMemory = (navigatorLike as any)?.deviceMemory; - const uaMobile = (navigatorLike as any)?.userAgentData?.mobile; - const fallbackMobileUA = navigatorLike?.userAgent ?? ''; - const mobileByUA = - typeof uaMobile === 'boolean' - ? uaMobile - : /Mobi|Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(fallbackMobileUA); - const saveData = connection?.saveData === true; - const normalizedEffectiveType = typeof effectiveType === 'string' ? effectiveType : null; - const normalizedDeviceMemory = typeof deviceMemory === 'number' ? deviceMemory : null; - const missingSignals = - Number(normalizedEffectiveType === null) + Number(normalizedDeviceMemory === null); - return { - saveData, - effectiveType: normalizedEffectiveType, - deviceMemoryGb: normalizedDeviceMemory, - mobile: mobileByUA, - missingSignals, - }; -}; +// Import shared device capability detection logic +import { readAdaptiveSignals, type AdaptiveSignals } from '../app/utils/device-capabilities'; // Resolve the timeline limit for the active-room subscription based on device/network. // The list subscription always uses LIST_TIMELINE_LIMIT=1 regardless of conditions. From 336fc71d1bd892ef1b9512ddbe0e14794c403cc4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 10 Mar 2026 00:51:04 -0400 Subject: [PATCH 04/15] feat(threads): implement thread side panel with full functionality - Add ThreadDrawer component with message rendering and input - Add ThreadBrowser panel for viewing all threads in a room - Add thread chips on messages showing reply count and participants - Enable message actions in threads (edit, react, reply, delete) - Add emoji and sticker rendering support in threads - Filter thread replies from main timeline to avoid duplicates - Auto-create Thread objects when starting threads or syncing from other devices - Add unread thread badge to header icon (Discord-style) - Auto-open thread drawer when navigating to thread events from notifications - Reorder room header icons: search, pinned, threads, widgets, members, more - Add automatic read receipts when viewing threads Fixes thread browser showing empty list and inbox notifications not opening threads. --- .changeset/feat-threads.md | 5 + src/app/components/editor/Editor.tsx | 18 +- src/app/features/room/Room.tsx | 80 +- src/app/features/room/RoomInput.tsx | 77 +- src/app/features/room/RoomTimeline.tsx | 303 ++++++-- src/app/features/room/RoomViewHeader.tsx | 162 ++++- src/app/features/room/ThreadBrowser.tsx | 241 ++++++ src/app/features/room/ThreadDrawer.css.ts | 53 ++ src/app/features/room/ThreadDrawer.tsx | 543 ++++++++++++++ .../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/room/roomToOpenThread.ts | 14 + src/app/state/room/roomToThreadBrowser.ts | 13 + src/types/matrix-sdk.ts | 3 + 18 files changed, 2335 insertions(+), 67 deletions(-) create mode 100644 .changeset/feat-threads.md 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 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 create mode 100644 src/app/state/room/roomToOpenThread.ts create mode 100644 src/app/state/room/roomToThreadBrowser.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/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 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/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 026227dbc..c3c3a2aff 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -185,9 +185,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 +207,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 +217,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) @@ -326,6 +330,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]); @@ -341,7 +365,7 @@ export const RoomInput = forwardRef( resetEditor(editor); resetEditorHistory(editor); }, - [roomId, editor, setMsgDraft] + [draftKey, editor, setMsgDraft] ); const handleFileMetadata = useCallback( @@ -409,12 +433,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; }) @@ -537,7 +570,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) { @@ -561,7 +604,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(); @@ -570,7 +613,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); }); } @@ -580,6 +623,7 @@ export const RoomInput = forwardRef( canSendReaction, mx, roomId, + threadRootId, replyDraft, scheduledTime, editingScheduledDelayId, @@ -683,7 +727,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); }; @@ -841,7 +894,7 @@ export const RoomInput = forwardRef(
)} - {replyDraft && ( + {replyDraft && !threadRootId && (
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, preferring thread.events over the +// server-aggregated count (which may be 0 on homeservers without thread support). +const getThreadReplyCount = (room: Room, mEventId: string): number => { + const thread = room.getThread(mEventId); + if (thread) { + const fromEvents = thread.events.filter((ev) => ev.getId() !== mEventId).length; + return fromEvents > 0 ? fromEvents : thread.length; + } + return room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter((ev) => ev.threadRootId === mEventId).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 thread = room.getThread(mEventId); + const replyEvents = (() => { + if (thread) { + const fromThread = thread.events.filter((ev) => ev.getId() !== mEventId); + if (fromThread.length > 0) return fromThread; + } + return room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter((ev) => ev.threadRootId === mEventId); + })(); + + const replyCount = replyEvents.length > 0 ? replyEvents.length : (thread?.length ?? 0); + 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); @@ -589,6 +728,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 +908,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 +1042,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 })); + }, []) + ); + // Recover from transient empty timeline state when the live timeline // already has events (can happen when opening by event id, then fallbacking). useEffect(() => { @@ -1281,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( @@ -1437,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)} @@ -1531,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() ?? '')} @@ -1662,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() ?? '')} @@ -2077,6 +2289,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 (they may be present as local echoes or because they + // were first synced before threadSupport was enabled). + if (mEvent.threadRootId !== undefined) return null; + const eventJSX = reactionOrEditEvent(mEvent) ? null : renderMatrixEvent( diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 84b67cce9..2621f8921 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -24,7 +24,13 @@ import { Spinner, } from 'folds'; import { useNavigate } from 'react-router-dom'; -import { Room } from '$types/matrix-sdk'; +import { + Room, + ThreadEvent, + RoomEvent, + MatrixEvent, + NotificationCountType, +} from '$types/matrix-sdk'; import { useStateEvent } from '$hooks/useStateEvent'; import { PageHeader } from '$components/page'; @@ -79,6 +85,7 @@ 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 { JumpToTime } from './jump-to-time'; import { RoomPinMenu } from './room-pin-menu'; import * as css from './RoomViewHeader.css'; @@ -342,6 +349,9 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { const direct = useIsDirectRoom(); const [chat, setChat] = useAtom(callChatAtom); + const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( + roomIdToThreadBrowserAtomFamily(room.roomId) + ); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const encryptedRoom = !!encryptionEvent; @@ -361,6 +371,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(''); @@ -402,6 +414,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, @@ -600,6 +722,44 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { } /> + + Threads + + } + > + {(triggerRef) => ( + setThreadBrowserOpen(!threadBrowserOpen)} + aria-pressed={threadBrowserOpen} + 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..cf470056a --- /dev/null +++ b/src/app/features/room/ThreadBrowser.tsx @@ -0,0 +1,241 @@ +import { ChangeEventHandler, useEffect, useRef, useState } from 'react'; +import { Box, Header, Icon, IconButton, Icons, Input, Scroll, Text, Avatar, config } from 'folds'; +import { MatrixEvent, Room, Thread, ThreadEvent } from '$types/matrix-sdk'; +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { nicknamesAtom } from '$state/nicknames'; +import { getMemberAvatarMxc, getMemberDisplayName } from '$utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; +import { UserAvatar } from '$components/user-avatar'; +import { Time } from '$components/message'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; +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 nicknames = useAtomValue(nicknamesAtom); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const { rootEvent } = thread; + if (!rootEvent) return null; + + const senderId = rootEvent.getSender() ?? ''; + const displayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + const avatarMxc = getMemberAvatarMxc(room, senderId); + const avatarUrl = avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 36, 36, 'crop') ?? undefined) + : undefined; + + const content = rootEvent.getContent(); + const bodyText: string = typeof content?.body === 'string' ? content.body : rootEvent.getType(); + + const replyCount = thread.events.filter((ev: MatrixEvent) => ev.getId() !== thread.id).length; + + const lastReply = thread.events.filter((ev: MatrixEvent) => ev.getId() !== thread.id).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} + + + + {bodyText.slice(0, 120)} + + {replyCount > 0 && ( + + + {replyCount} {replyCount === 1 ? 'reply' : 'replies'} + + {lastReply && ( + + {'· '} + {lastDisplayName} + {lastBody ? `: ${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..8fc87e637 --- /dev/null +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -0,0 +1,53 @@ +import { style } from '@vanilla-extract/css'; +import { config, color, toRem } from 'folds'; + +export const ThreadDrawer = style({ + width: toRem(440), + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', +}); + +export const ThreadDrawerHeader = style({ + flexShrink: 0, + padding: `0 ${config.space.S200} 0 ${config.space.S300}`, + borderBottomWidth: config.borderWidth.B300, +}); + +export const ThreadDrawerContent = style({ + position: 'relative', + overflow: 'hidden', + flexGrow: 1, +}); + +export const ThreadDrawerInput = style({ + flexShrink: 0, + borderTopWidth: config.borderWidth.B300, + borderTopStyle: 'solid', + borderTopColor: color.Background.ContainerLine, +}); + +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..d7b6dbd87 --- /dev/null +++ b/src/app/features/room/ThreadDrawer.tsx @@ -0,0 +1,543 @@ +import { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Header, Icon, IconButton, Icons, Line, Scroll, Text, config } from 'folds'; +import { MatrixEvent, ReceiptType, Room, RoomEvent } from '$types/matrix-sdk'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { ReactEditor } from 'slate-react'; +import { ImageContent, MSticker, RedactedContent } 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 } 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 * as css from './ThreadDrawer.css'; + +type ThreadMessageProps = { + room: Room; + mEvent: MatrixEvent; + 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: ( + ev: Parameters>[0], + startThread?: boolean + ) => void; + onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; + collapse?: boolean; +}; + +function ThreadMessage({ + room, + mEvent, + editId, + onEditId, + messageLayout, + messageSpacing, + canDelete, + canSendReaction, + collapse = false, + canPinEvent, + imagePackRooms, + activeReplyId, + hour24Clock, + dateFormatString, + onUserClick, + onUsernameClick, + onReplyClick, + onReactionToggle, +}: ThreadMessageProps) { + const mx = useMatrixClient(); + const timelineSet = room.getUnfilteredTimelineSet(); + const mEventId = mEvent.getId()!; + const senderId = mEvent.getSender() ?? ''; + const nicknames = useAtomValue(nicknamesAtom); + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + + 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 mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + const useAuthentication = useMediaAuthentication(); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const [autoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); + + return ( + + ) : undefined + } + > + + {() => { + if (mEvent.isRedacted()) + return ( + + ); + + if (mEvent.getType() === MessageEvent.Sticker) + return ( + ( + { + if (!autoplayStickers && p.src) { + return ( + + + + ); + } + return ; + }} + renderViewer={(p) => } + /> + )} + /> + ); + + return ( + + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ) + ), + }, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + nicknames, + })} + linkifyOpts={{ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention( + mx, + room.roomId, + href, + makeMentionCustomProps(mentionClickHandler), + nicknames + ) + ), + }} + outlineAttachment={false} + /> + ); + }} + + + ); +} + +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); + + // Settings + const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); + const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + // 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. + useEffect(() => { + const onTimeline = (mEvent: MatrixEvent) => { + if (mEvent.threadRootId === threadRootId || mEvent.getId() === threadRootId) { + forceUpdate((n) => n + 1); + } + }; + mx.on(RoomEvent.Timeline, onTimeline as any); + return () => { + mx.off(RoomEvent.Timeline, onTimeline as any); + }; + }, [mx, 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); + } + return room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter((ev) => ev.threadRootId === threadRootId && ev.getId() !== threadRootId); + })(); + + 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 nicknames = undefined; // will be resolved via getMemberDisplayName in editor + const name = + getMemberDisplayName(room, userId, nicknames) ?? 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) { + setReplyDraft(undefined); + 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, + }; + setReplyDraft(activeReplyId === replyId ? undefined : draft); + } + }, + [room, setReplyDraft, activeReplyId] + ); + + const handleReactionToggle = useCallback( + (targetEventId: string, key: string, shortcode?: string) => + toggleReaction(mx, room, targetEventId, key, shortcode), + [mx, room] + ); + + const handleEdit = useCallback( + (evtId?: string) => { + setEditId(evtId); + if (!evtId) { + ReactEditor.focus(editor); + moveCursor(editor); + } + }, + [editor] + ); + + const sharedMessageProps = { + room, + editId, + onEditId: handleEdit, + messageLayout, + messageSpacing, + canDelete: canRedact || canDeleteOwn, + canSendReaction, + canPinEvent, + imagePackRooms, + activeReplyId, + hour24Clock, + dateFormatString, + onUserClick: handleUserClick, + onUsernameClick: handleUsernameClick, + onReplyClick: handleReplyClick, + onReactionToggle: handleReactionToggle, + }; + + return ( + + {/* Header */} +
+ + + + Thread + + + + + # {room.name} + + + + + +
+ + + + {/* Thread root message */} + {rootEvent && ( + + + + )} + + + + {/* Reply count label */} + {replyEvents.length > 0 && ( + + + {replyEvents.length} {replyEvents.length === 1 ? 'reply' : 'replies'} + + + )} + + {/* Replies */} + + + {replyEvents.length === 0 ? ( + + + + No replies yet. Start the thread below! + + + ) : ( + + {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 */} + +
+ +
+
+
+ ); +} 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 187220aa3..b9e637615 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/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/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 84b8f91e60692d4939328bbf69b04538139eff21 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 10 Mar 2026 09:33:20 -0400 Subject: [PATCH 05/15] fix: Move import to top of file - Move readAdaptiveSignals import to top with other imports - Fixes unconventional import placement from previous refactor --- src/client/slidingSync.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 34b73d453..c927eb559 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -16,6 +16,7 @@ import { User, } from '$types/matrix-sdk'; import { createLogger } from '$utils/debug'; +import { readAdaptiveSignals, type AdaptiveSignals } from '../app/utils/device-capabilities'; const log = createLogger('slidingSync'); @@ -86,9 +87,6 @@ const clampPositive = (value: number | undefined, fallback: number): number => { return Math.round(value); }; -// Import shared device capability detection logic -import { readAdaptiveSignals, type AdaptiveSignals } from '../app/utils/device-capabilities'; - // Resolve the timeline limit for the active-room subscription based on device/network. // The list subscription always uses LIST_TIMELINE_LIMIT=1 regardless of conditions. const resolveAdaptiveRoomTimelineLimit = ( From fba2e912fea647a9036b85736ca9adaac9438f8f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 10 Mar 2026 11:20:12 -0400 Subject: [PATCH 06/15] fix: prevent ResizeObserver from scrolling to bottom during back pagination Add actual scroll position check (150px threshold) to ResizeObserver forceScroll to prevent it from forcing scroll to bottom when back paginating. During back pagination, messages load at the top causing content resize, but if atBottomRef hasn't updated yet from the scroll, it would incorrectly scroll to bottom. Now checks both: atBottomRef.current AND distance from bottom < 150px before forcing scroll, ensuring back pagination doesn't get interrupted. --- src/app/features/room/RoomTimeline.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 668b33f7b..c79d7759a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1052,6 +1052,12 @@ export function RoomTimeline({ const forceScroll = () => { // if the user isn't scrolling jump down to latest content if (!atBottomRef.current) return; + + // Also verify the scroll is actually near the bottom (within 150px threshold) + // to prevent force-scrolling during back pagination when refs haven't updated yet + const distanceFromBottom = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.offsetHeight; + if (distanceFromBottom > 150) return; + scrollToBottom(scrollEl, 'instant'); }; From 1d65bfaf1538d8a7477d031d6662402aedbde46c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 10 Mar 2026 14:16:56 -0400 Subject: [PATCH 07/15] chore: remove thread mockup feature Remove ThreadMockupPage and related route - no longer needed as threads are fully implemented. --- .../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/paths.ts | 2 - 5 files changed, 878 deletions(-) delete mode 100644 src/app/features/thread-mockup/ThreadMockupPage.tsx delete mode 100644 src/app/features/thread-mockup/index.ts delete mode 100644 src/app/features/thread-mockup/thread-mockup.css.ts diff --git a/src/app/features/thread-mockup/ThreadMockupPage.tsx b/src/app/features/thread-mockup/ThreadMockupPage.tsx deleted file mode 100644 index 669270a7a..000000000 --- a/src/app/features/thread-mockup/ThreadMockupPage.tsx +++ /dev/null @@ -1,684 +0,0 @@ -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 deleted file mode 100644 index 3e101e51d..000000000 --- a/src/app/features/thread-mockup/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 2c640ae31..000000000 --- a/src/app/features/thread-mockup/thread-mockup.css.ts +++ /dev/null @@ -1,188 +0,0 @@ -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 f5e7d3b75..f14567f7d 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -26,13 +26,11 @@ 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, @@ -345,7 +343,6 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> - } /> Page not found

} />
diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 9ff0a8319..82f8c6dd2 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -1,7 +1,5 @@ export const ROOT_PATH = '/'; -export const THREAD_MOCKUP_PATH = '/thread-mockup/'; - export type LoginPathSearchParams = { username?: string; email?: string; From 94c83f2575df38bf95bb31227ca9404977c97073 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 10 Mar 2026 14:26:26 -0400 Subject: [PATCH 08/15] fix: remove thread mockup button from welcome page The thread mockup feature was removed in previous commits but the button and navigation code remained in WelcomePage.tsx, causing build failures. This removes: - Thread mockup button from welcome page - Unused useNavigate import and hook call --- src/app/pages/client/WelcomePage.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index b9e637615..187220aa3 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,11 +1,8 @@ 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 ( - From 00b5eb348d3dd4d9198ed4f471ce3dfd390c7f29 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 11 Mar 2026 22:17:35 -0400 Subject: [PATCH 09/15] feat(threads): replace ThreadMessage with full-featured event renderer - Add PushProcessor for notification highlights - Add Reply component support for in-thread replies - Add message forwarding props support - Add send status handling (resend/delete failed sends) - Add memoized linkifyOpts and htmlReactParserOptions - Add developer tools and read receipts support - Bring full feature parity with RoomTimeline event rendering --- src/app/features/room/ThreadDrawer.tsx | 318 ++++++++++++++++++------- 1 file changed, 234 insertions(+), 84 deletions(-) diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index d7b6dbd87..6f6dc1826 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -1,9 +1,29 @@ -import { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'; +import { + MouseEventHandler, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Box, Header, Icon, IconButton, Icons, Line, Scroll, Text, config } from 'folds'; -import { MatrixEvent, ReceiptType, Room, RoomEvent } from '$types/matrix-sdk'; +import { + MatrixEvent, + PushProcessor, + ReceiptType, + Room, + RoomEvent, +} from '$types/matrix-sdk'; import { useAtomValue, useSetAtom } from 'jotai'; import { ReactEditor } from 'slate-react'; -import { ImageContent, MSticker, RedactedContent } from '$components/message'; +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'; @@ -38,6 +58,14 @@ import { EncryptedContent, Message, Reactions } from './message'; import { RoomInput } from './RoomInput'; import * as css from './ThreadDrawer.css'; +type ForwardedMessageProps = { + isForwarded: boolean; + originalTimestamp: number; + originalRoomId: string; + originalEventId: string; + originalEventPrivate: boolean; +}; + type ThreadMessageProps = { room: Room; mEvent: MatrixEvent; @@ -54,11 +82,15 @@ type ThreadMessageProps = { dateFormatString: string; onUserClick: MouseEventHandler; onUsernameClick: MouseEventHandler; - onReplyClick: ( - ev: Parameters>[0], - startThread?: boolean - ) => void; + onReplyClick: MouseEventHandler; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; + onResend?: (eventId: string) => void; + onDeleteFailedSend?: (eventId: string) => void; + pushProcessor: PushProcessor; + linkifyOpts: LinkifyOpts; + htmlReactParserOptions: HTMLReactParserOptions; + showHideReads: boolean; + showDeveloperTools: boolean; collapse?: boolean; }; @@ -81,6 +113,13 @@ function ThreadMessage({ onUsernameClick, onReplyClick, onReactionToggle, + onResend, + onDeleteFailedSend, + pushProcessor, + linkifyOpts, + htmlReactParserOptions, + showHideReads, + showDeveloperTools, }: ThreadMessageProps) { const mx = useMatrixClient(); const timelineSet = room.getUnfilteredTimelineSet(); @@ -90,6 +129,13 @@ function ThreadMessage({ 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 [autoplayEmojis] = useSetting(settingsAtom, 'autoplayEmojis'); + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); const editedNewContent = editedEvent?.getContent()['m.new_content']; const baseContent = mEvent.getContent(); @@ -101,12 +147,36 @@ function ThreadMessage({ const reactions = reactionRelations?.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; - const mentionClickHandler = useMentionClickHandler(room.roomId); - const spoilerClickHandler = useSpoilerClickHandler(); - const useAuthentication = useMediaAuthentication(); - const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); - const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); - const [autoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); + 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, threadRootId } = mEvent; return ( + ) + } reactions={ hasReactions ? ( - - {() => { - if (mEvent.isRedacted()) - return ( - - ); + {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 ( + + ); + } - if (mEvent.getType() === MessageEvent.Sticker) return ( - ( - { - if (!autoplayStickers && p.src) { - return ( - - - - ); - } - return ; - }} - renderViewer={(p) => } - /> - )} + ); - - return ( - - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) - ), - }, - useAuthentication, - handleSpoilerClick: spoilerClickHandler, - handleMentionClick: mentionClickHandler, - nicknames, - })} - linkifyOpts={{ - ...LINKIFY_OPTS, - render: factoryRenderLinkifyWithMention((href) => - renderMatrixMention( - mx, - room.roomId, - href, - makeMentionCustomProps(mentionClickHandler), - nicknames - ) - ), - }} - outlineAttachment={false} - /> - ); - }} - + }} + + )} ); } @@ -238,12 +319,50 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const editor = useEditor(); const [, forceUpdate] = useState(0); const [editId, setEditId] = useState(undefined); + 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'); + const [autoplayEmojis] = useSetting(settingsAtom, 'autoplayEmojis'); + + // 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, + autoplayEmojis, + }), + [mx, room, linkifyOpts, autoplayEmojis, spoilerClickHandler, mentionClickHandler, useAuthentication, nicknames] + ); // Power levels & permissions const powerLevels = usePowerLevelsContext(); @@ -410,6 +529,30 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra [editor] ); + const handleResend = useCallback( + (eventId: string) => { + mx.resendEvent(room.findEventById(eventId)!, room); + }, + [mx, room] + ); + + const handleDeleteFailedSend = useCallback( + (eventId: string) => { + mx.cancelPendingEvent(room.findEventById(eventId)!); + }, + [mx, room] + ); + + const handleOpenReply: MouseEventHandler = useCallback( + (evt) => { + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (!targetId) return; + // In threads, we just want to scroll to the event if it's visible + // For now, we'll do nothing since we don't have scroll-to-event in threads yet + }, + [] + ); + const sharedMessageProps = { room, editId, @@ -427,6 +570,13 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra onUsernameClick: handleUsernameClick, onReplyClick: handleReplyClick, onReactionToggle: handleReactionToggle, + onResend: handleResend, + onDeleteFailedSend: handleDeleteFailedSend, + pushProcessor, + linkifyOpts, + htmlReactParserOptions, + showHideReads: hideReads, + showDeveloperTools, }; return ( From dcfa9ea0bda87abcb99712e87cd0d225f234d0fa Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 11 Mar 2026 22:25:32 -0400 Subject: [PATCH 10/15] fix(threads): add data-message-id attribute for hover effects Add key and data-message-id attributes to Message component in threads to enable proper hover effects and message identification. --- src/app/features/room/ThreadDrawer.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 6f6dc1826..be113a3c3 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -180,6 +180,8 @@ function ThreadMessage({ return ( Date: Wed, 11 Mar 2026 22:35:45 -0400 Subject: [PATCH 11/15] Fix thread drawer UI: hover highlighting, spacing, and input alignment - Add messageList class with hover styles to ThreadDrawer.css.ts - Apply messageList class to root message and replies containers - Update padding to match RoomTimeline: S600 vertical spacing - Fix input alignment: change from S200 to S400 horizontal padding --- src/app/features/room/ThreadDrawer.css.ts | 16 +++++++++++++++- src/app/features/room/ThreadDrawer.tsx | 6 +++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index 8fc87e637..101d8a0a6 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { style, globalStyle } from '@vanilla-extract/css'; import { config, color, toRem } from 'folds'; export const ThreadDrawer = style({ @@ -8,6 +8,20 @@ export const ThreadDrawer = style({ 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.S300}`, diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index be113a3c3..e2f9dd937 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -616,7 +616,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra {/* Thread root message */} {rootEvent && ( - + )} @@ -655,7 +655,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra ) : ( - + {replyEvents.map((mEvent, i) => { const prevEvent = i > 0 ? replyEvents[i - 1] : undefined; const collapse = @@ -679,7 +679,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra {/* Thread input */} -
+
Date: Wed, 11 Mar 2026 22:41:58 -0400 Subject: [PATCH 12/15] Fix thread messages incorrectly showing reply indicators Don't add m.in_reply_to for regular thread messages, only for actual replies within threads. Thread messages now use is_falling_back: true per Matrix spec, and replies within threads use is_falling_back: false. --- src/app/features/room/RoomInput.tsx | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c3c3a2aff..268e8be87 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; }; From f634ad86e48261b03ad4c2a99fd273487cc804eb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 11 Mar 2026 22:47:39 -0400 Subject: [PATCH 13/15] Fix Reply button not working in thread drawer Add thread relation to reply drafts in threads so replies are properly associated with the thread root event. --- src/app/features/room/ThreadDrawer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index e2f9dd937..3d77ec9ab 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -11,6 +11,7 @@ import { MatrixEvent, PushProcessor, ReceiptType, + RelationType, Room, RoomEvent, } from '$types/matrix-sdk'; @@ -507,11 +508,12 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra eventId: replyId, body: typeof body === 'string' ? body : '', formattedBody, + relation: { rel_type: RelationType.Thread, event_id: threadRootId }, }; setReplyDraft(activeReplyId === replyId ? undefined : draft); } }, - [room, setReplyDraft, activeReplyId] + [room, setReplyDraft, activeReplyId, threadRootId] ); const handleReactionToggle = useCallback( From 6b2f636fe67cd74adf1b8d275d09768f6ba02ba2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 11 Mar 2026 22:55:20 -0400 Subject: [PATCH 14/15] refactor(threads): Match thread drawer UI to room timeline styling - Remove horizontal line separators to match room timeline - Align header left padding (S400) with messages and input - Update reply count label padding to match spacing system (S200/S400) - Remove unused Line import --- src/app/features/room/ThreadDrawer.css.ts | 2 +- src/app/features/room/ThreadDrawer.tsx | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index 101d8a0a6..861d02a9a 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -24,7 +24,7 @@ globalStyle(`body ${messageList} [data-message-id]:hover`, { export const ThreadDrawerHeader = style({ flexShrink: 0, - padding: `0 ${config.space.S200} 0 ${config.space.S300}`, + padding: `0 ${config.space.S200} 0 ${config.space.S400}`, borderBottomWidth: config.borderWidth.B300, }); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 3d77ec9ab..ea99bee6c 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from 'react'; -import { Box, Header, Icon, IconButton, Icons, Line, Scroll, Text, config } from 'folds'; +import { Box, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; import { MatrixEvent, PushProcessor, @@ -614,8 +614,6 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra - - {/* Thread root message */} {rootEvent && ( @@ -623,11 +621,9 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra )} - - {/* Reply count label */} {replyEvents.length > 0 && ( - + {replyEvents.length} {replyEvents.length === 1 ? 'reply' : 'replies'} From 01204b230b3cef321d7b2608049be73d3e8ca870 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 11 Mar 2026 23:10:29 -0400 Subject: [PATCH 15/15] feat(threads): Improve thread drawer layout and following indicator - Increase header size from 400 to 600 to align bottom border with room header - Reduce root message and replies padding from S600 to S400 vertically - Add bottom border separator after root message section (ThreadRootSection) - Add bottom border separator after reply count label (ThreadReplyCountSection) - Add following indicator below thread input, filtered to thread participants only - Update RoomViewFollowing to accept optional threadEventId and participantIds props --- src/app/features/room/RoomViewFollowing.tsx | 10 +++++++--- src/app/features/room/ThreadDrawer.tsx | 11 ++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) 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/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index ea99bee6c..27c2c7a77 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -57,6 +57,7 @@ import { IReplyDraft, roomIdToReplyDraftAtomFamily } from '$state/room/roomInput 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 = { @@ -583,6 +584,13 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra showDeveloperTools, }; + // 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 */} -
+
@@ -687,6 +695,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra fileDropContainerRef={drawerRef} />
+ {hideReads ? : } );