diff --git a/.changeset/feat-background-pagination.md b/.changeset/feat-background-pagination.md new file mode 100644 index 000000000..9b93320ba --- /dev/null +++ b/.changeset/feat-background-pagination.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add adaptive background pagination for improved message history availability. 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..8f6957cb0 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 { useAtom, 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,10 +16,15 @@ import { useRoomMembers } from '$hooks/useRoomMembers'; import { CallView } from '$features/call/CallView'; import { WidgetsDrawer } from '$features/widgets/WidgetsDrawer'; import { callChatAtom } from '$state/callEmbed'; +import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; +import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; +import { getBackgroundPaginationConfig } from '$utils/device-capabilities'; 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 +38,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, @@ -45,11 +75,39 @@ 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 ( - + {callView && (screenSize === ScreenSize.Desktop || !chat) && ( @@ -87,6 +145,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(() => { @@ -1052,6 +1207,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'); }; @@ -1281,15 +1442,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 +1607,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 +1717,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 +1864,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 +2295,11 @@ export function RoomTimeline({ prevEvent.getType() === mEvent.getType() && minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; + // Thread replies belong only in the thread timeline; filter them from the + // main room timeline (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..101d8a0a6 --- /dev/null +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -0,0 +1,67 @@ +import { style, globalStyle } 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 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}`, + 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..e2f9dd937 --- /dev/null +++ b/src/app/features/room/ThreadDrawer.tsx @@ -0,0 +1,695 @@ +import { + MouseEventHandler, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Box, Header, Icon, IconButton, Icons, Line, Scroll, Text, config } from 'folds'; +import { + MatrixEvent, + PushProcessor, + ReceiptType, + Room, + RoomEvent, +} from '$types/matrix-sdk'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { ReactEditor } from 'slate-react'; +import { HTMLReactParserOptions } from 'html-react-parser'; +import { Opts as LinkifyOpts } from 'linkifyjs'; +import { + ImageContent, + MSticker, + RedactedContent, + Reply, +} from '$components/message'; +import { RenderMessageContent } from '$components/RenderMessageContent'; +import { Image } from '$components/media'; +import { ImageViewer } from '$components/image-viewer'; +import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '$plugins/react-custom-html-parser'; +import { getEditedEvent, getEventReactions, getMemberDisplayName } 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 ForwardedMessageProps = { + isForwarded: boolean; + originalTimestamp: number; + originalRoomId: string; + originalEventId: string; + originalEventPrivate: boolean; +}; + +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: 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; +}; + +function ThreadMessage({ + room, + mEvent, + editId, + onEditId, + messageLayout, + messageSpacing, + canDelete, + canSendReaction, + collapse = false, + canPinEvent, + imagePackRooms, + activeReplyId, + hour24Clock, + dateFormatString, + onUserClick, + onUsernameClick, + onReplyClick, + onReactionToggle, + onResend, + onDeleteFailedSend, + pushProcessor, + linkifyOpts, + htmlReactParserOptions, + showHideReads, + showDeveloperTools, +}: 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 [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(); + const safeContent = + Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); + const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; + + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + + const pushActions = pushProcessor.actionsForEvent(mEvent); + let notifyHighlight: 'silent' | 'loud' | undefined; + if (pushActions?.notify && pushActions.tweaks?.highlight) { + notifyHighlight = pushActions.tweaks?.sound ? 'loud' : 'silent'; + } + + // Extract message forwarding info + const forwardContent = safeContent['moe.sable.message.forward'] as + | { + original_timestamp?: unknown; + original_room_id?: string; + original_event_id?: string; + original_event_private?: boolean; + } + | undefined; + + const messageForwardedProps: ForwardedMessageProps | undefined = forwardContent + ? { + isForwarded: true, + originalTimestamp: + typeof forwardContent.original_timestamp === 'number' + ? forwardContent.original_timestamp + : mEvent.getTs(), + originalRoomId: forwardContent.original_room_id ?? room.roomId, + originalEventId: forwardContent.original_event_id ?? '', + originalEventPrivate: forwardContent.original_event_private ?? false, + } + : undefined; + + const { replyEventId, threadRootId } = mEvent; + + return ( + + ) + } + reactions={ + hasReactions ? ( + + ) : undefined + } + > + {mEvent.isRedacted() ? ( + + ) : ( + + {() => { + if (mEvent.isRedacted()) + return ( + + ); + + if (mEvent.getType() === MessageEvent.Sticker) + return ( + ( + { + if (!autoplayStickers && p.src) { + return ( + + + + ); + } + return ; + }} + renderViewer={(p) => } + /> + )} + /> + ); + + if (mEvent.getType() === MessageEvent.RoomMessage) { + return ( + + ); + } + + return ( + + ); + }} + + )} + + ); +} + +type ThreadDrawerProps = { + room: Room; + threadRootId: string; + onClose: () => void; + overlay?: boolean; +}; + +export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDrawerProps) { + const mx = useMatrixClient(); + const drawerRef = useRef(null); + const editor = useEditor(); + const [, forceUpdate] = useState(0); + const [editId, setEditId] = useState(undefined); + const 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(); + 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 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, + onEditId: handleEdit, + messageLayout, + messageSpacing, + canDelete: canRedact || canDeleteOwn, + canSendReaction, + canPinEvent, + imagePackRooms, + activeReplyId, + hour24Clock, + dateFormatString, + onUserClick: handleUserClick, + onUsernameClick: handleUsernameClick, + onReplyClick: handleReplyClick, + onReactionToggle: handleReactionToggle, + onResend: handleResend, + onDeleteFailedSend: handleDeleteFailedSend, + pushProcessor, + linkifyOpts, + htmlReactParserOptions, + showHideReads: hideReads, + showDeveloperTools, + }; + + 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/state/room/roomToOpenThread.ts b/src/app/state/room/roomToOpenThread.ts new file mode 100644 index 000000000..0a60fa4a7 --- /dev/null +++ b/src/app/state/room/roomToOpenThread.ts @@ -0,0 +1,14 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +const createOpenThreadAtom = () => atom(undefined); +export type TOpenThreadAtom = ReturnType; + +/** + * Tracks the currently-open thread root event ID per room. + * Key: roomId + * Value: eventId of the thread root, or undefined if no thread is open. + */ +export const roomIdToOpenThreadAtomFamily = atomFamily(() => + createOpenThreadAtom() +); diff --git a/src/app/state/room/roomToThreadBrowser.ts b/src/app/state/room/roomToThreadBrowser.ts new file mode 100644 index 000000000..3d8963165 --- /dev/null +++ b/src/app/state/room/roomToThreadBrowser.ts @@ -0,0 +1,13 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +const createThreadBrowserAtom = () => atom(false); +export type TThreadBrowserAtom = ReturnType; + +/** + * Tracks whether the thread browser panel is open per room. + * Key: roomId + */ +export const roomIdToThreadBrowserAtomFamily = atomFamily(() => + createThreadBrowserAtom() +); diff --git a/src/app/utils/device-capabilities.ts b/src/app/utils/device-capabilities.ts new file mode 100644 index 000000000..4f0dbc35d --- /dev/null +++ b/src/app/utils/device-capabilities.ts @@ -0,0 +1,119 @@ +/** + * 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; +} + +/** + * Adaptive signals from browser APIs for device/network capability detection. + * Shared by sliding sync and background pagination for consistent behavior. + */ +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; + 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, + }; + } +} diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index f2c9257dc..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,39 +87,6 @@ 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, - }; -}; - // 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 = ( 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';