Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/feat-background-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add adaptive background pagination for improved message history availability.
5 changes: 5 additions & 0 deletions .changeset/feat-threads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
sable: minor
---

Add thread support with side panel, browser, unread badges, and cross-device sync
18 changes: 10 additions & 8 deletions src/app/components/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -96,6 +89,15 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
},
ref
) => {
// Each <Slate> 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<CustomElement[]>(() => [
{ type: BlockType.Paragraph, children: [{ text: '' }] },
]);

const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
Expand Down Expand Up @@ -132,7 +134,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(

return (
<div className={`${css.Editor} ${className || ''}`} ref={ref}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
<Slate editor={editor} initialValue={slateInitialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
Expand Down
110 changes: 107 additions & 3 deletions src/app/features/room/Room.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,10 +16,15 @@
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();
Expand All @@ -32,6 +38,30 @@
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,
Expand All @@ -45,11 +75,39 @@
)
);

// 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);

Check warning on line 95 in src/app/features/room/Room.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
});
}
}, config.delayMs);

return () => {
cancelled = true;
clearTimeout(timer);
};
}, [room, mx]);

const callView = room.isCallRoom();

return (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
<Box grow="Yes" style={{ position: 'relative' }}>
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box grow="Yes" direction="Column">
<RoomViewHeader callView />
Expand Down Expand Up @@ -87,6 +145,52 @@
<WidgetsDrawer key={`widgets-${room.roomId}`} room={room} />
</>
)}
{screenSize === ScreenSize.Desktop && openThreadId && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<ThreadDrawer
key={`thread-${room.roomId}-${openThreadId}`}
room={room}
threadRootId={openThreadId}
onClose={() => setOpenThread(undefined)}
/>
</>
)}
{screenSize === ScreenSize.Desktop && threadBrowserOpen && !openThreadId && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<ThreadBrowser
key={`thread-browser-${room.roomId}`}
room={room}
onOpenThread={(id) => {
setOpenThread(id);
setThreadBrowserOpen(false);
}}
onClose={() => setThreadBrowserOpen(false)}
/>
</>
)}
{screenSize !== ScreenSize.Desktop && openThreadId && (
<ThreadDrawer
key={`thread-${room.roomId}-${openThreadId}`}
room={room}
threadRootId={openThreadId}
onClose={() => setOpenThread(undefined)}
overlay
/>
)}
{screenSize !== ScreenSize.Desktop && threadBrowserOpen && !openThreadId && (
<ThreadBrowser
key={`thread-browser-${room.roomId}`}
room={room}
onOpenThread={(id) => {
setOpenThread(id);
setThreadBrowserOpen(false);
}}
onClose={() => setThreadBrowserOpen(false)}
overlay
/>
)}
</Box>
</PowerLevelsContextProvider>
);
Expand Down
77 changes: 65 additions & 12 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,13 @@ interface RoomInputProps {
fileDropContainerRef: RefObject<HTMLElement>;
roomId: string;
room: Room;
threadRootId?: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ 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');
Expand All @@ -203,8 +207,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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(
Expand All @@ -213,7 +217,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);

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)
Expand Down Expand Up @@ -326,6 +330,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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]);
Expand All @@ -341,7 +365,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
resetEditor(editor);
resetEditorHistory(editor);
},
[roomId, editor, setMsgDraft]
[draftKey, editor, setMsgDraft]
);

const handleFileMetadata = useCallback(
Expand Down Expand Up @@ -409,12 +433,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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;
})
Expand Down Expand Up @@ -537,7 +570,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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) {
Expand All @@ -561,7 +604,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} 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();
Expand All @@ -570,7 +613,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
} 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);
});
}
Expand All @@ -580,6 +623,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
canSendReaction,
mx,
roomId,
threadRootId,
replyDraft,
scheduledTime,
editingScheduledDelayId,
Expand Down Expand Up @@ -683,7 +727,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
};
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);
};
Expand Down Expand Up @@ -841,7 +894,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Box>
</div>
)}
{replyDraft && (
{replyDraft && !threadRootId && (
<div>
<Box
alignItems="Center"
Expand Down
Loading
Loading