share main timeline logic with thread timeline

This commit is contained in:
Ajay Bura 2026-10-26 22:38:12 +05:30
commit d8cb89ca57
9 changed files with 189 additions and 130 deletions

View file

@ -49,6 +49,7 @@ import {
useEventDivider,
useFocusItem,
useGetItemElement,
useJumpHandlers,
useMessageEditorOutOfViewFocus,
useOnRangeChange,
useReplyClickHandler,
@ -214,41 +215,14 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
);
useMessageEditorOutOfViewFocus(scrollRef, scrollToElement, editId);
const handleOpenEvent = useCallback(
async (evtId: string) => {
const index = timeline.findIndex(evtId);
if (index === -1) {
setEventLoading(true);
await timeline.jumpToEvent(evtId);
setEventLoading(false);
setFocusItem({ eventId: evtId, highlight: true });
return;
}
scrollToItem(index, { align: 'center', behavior: 'smooth', stopInView: true });
setFocusItem({ eventId: evtId, highlight: true, preventScroll: true });
},
[timeline, scrollToItem, setFocusItem]
const { handleOpenEvent, handleJumpToUnread } = useJumpHandlers(
timeline,
readReceipt,
scrollToItem,
setFocusItem,
setEventLoading
);
const handleJumpToUnread = useCallback(async () => {
if (!readReceipt) return;
const unreadEvent = timeline.findFirstUnread(ct.session.userId, readReceipt.ts);
if (!unreadEvent) {
setEventLoading(true);
await timeline.jumpToEvent(readReceipt.eventId);
setEventLoading(false);
const unreadEvId =
timeline.findFirstUnread(ct.session.userId, readReceipt.ts)?.event_id ??
readReceipt.eventId;
setFocusItem({ eventId: unreadEvId, highlight: false });
return;
}
const index = timeline.findIndex(unreadEvent.event_id);
scrollToItem(index, { align: 'center', behavior: 'instant', stopInView: true });
}, [ct, readReceipt, timeline, scrollToItem, setFocusItem]);
useEffect(() => {
if (eventId) {
handleOpenEvent(eventId);

View file

@ -40,6 +40,8 @@ import { getThreadId } from '../../../utils/room';
import { ItemRange, ScrollToElement, ScrollToItem } from '../../../hooks/useVirtualPaginator';
import { randomStr } from '../../../utils/common';
import { useObservableValue } from '../../../hooks/useObservableValue';
import { ReadReceiptObject } from '../../../../client/storage/collections';
import { useAlive } from '../../../hooks/useAlive';
const TimelineViewContext = createContext<TimelineView | null>(null);
export const TimelineViewProvider = TimelineViewContext.Provider;
@ -344,6 +346,60 @@ export const useFocusItem = (timeline: TimelineView, scrollToItem: ScrollToItem)
};
};
export const useJumpHandlers = (
timeline: TimelineView,
readReceipt: ReadReceiptObject | undefined,
scrollToItem: ScrollToItem,
setFocusItem: (focus: FocusItem) => void,
setEventLoading: (loading: boolean) => void,
threadId?: string
) => {
const ct = useClient();
const alive = useAlive();
const handleOpenEvent = useCallback(
async (evtId: string) => {
const index = timeline.findIndex(evtId);
if (index !== -1) {
scrollToItem(index, { align: 'center', behavior: 'smooth', stopInView: true });
setFocusItem({ eventId: evtId, highlight: true, preventScroll: true });
return;
}
if (evtId !== threadId) {
// TODO: handle it properly
setEventLoading(true);
await timeline.jumpToEvent(evtId);
if (!alive()) return;
setEventLoading(false);
}
setFocusItem({ eventId: evtId, highlight: true });
},
[timeline, scrollToItem, setFocusItem, setEventLoading, alive, threadId]
);
const handleJumpToUnread = useCallback(async () => {
if (!readReceipt) return;
const unreadEvent = timeline.findFirstUnread(ct.session.userId, readReceipt.ts);
if (unreadEvent) {
const index = timeline.findIndex(unreadEvent.event_id);
scrollToItem(index, { align: 'center', behavior: 'instant', stopInView: true });
return;
}
setEventLoading(true);
await timeline.jumpToEvent(readReceipt.eventId);
if (!alive()) return;
setEventLoading(false);
const unreadEvId =
timeline.findFirstUnread(ct.session.userId, readReceipt.ts)?.event_id ?? readReceipt.eventId;
setFocusItem({ eventId: unreadEvId, highlight: false });
}, [ct, readReceipt, timeline, scrollToItem, setFocusItem, setEventLoading, alive]);
return {
handleOpenEvent,
handleJumpToUnread,
};
};
export const useReplyClickHandler = (
editor: Editor,
threadId?: string

View file

@ -64,11 +64,7 @@ export function ThreadView({ threadTimeline, threadFocusRef, requestClose }: Thr
</Box>
</PageHeader>
<TimelineView
threadTimeline={threadTimeline}
threadInputRef={threadInputRef}
editor={editor}
/>
<TimelineView timeline={threadTimeline} threadInputRef={threadInputRef} editor={editor} />
<Box shrink="No" direction="Column">
<div style={{ padding: `0 ${config.space.S400}` }}>
<RoomInput

View file

@ -1,11 +1,9 @@
import React, { RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ReactEditor } from 'slate-react';
import { Editor } from 'slate';
import { Box, Scroll, Text, config } from 'folds';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import { useVirtualPaginator } from '../../../hooks/useVirtualPaginator';
import { useAlive } from '../../../hooks/useAlive';
import { scrollToBottom } from '../../../utils/dom';
import {
DefaultPlaceholder,
CompactPlaceholder,
@ -34,7 +32,7 @@ import { useTheme } from '../../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags, usePowerLevelTagsState } from '../../../hooks/usePowerLevelTags';
import { ThreadTimeline } from '../../../../client/room/ThreadTimeline';
import { useObservableValue } from '../../../hooks/useObservableValue';
import { useObservableValue, useObservableValueListener } from '../../../hooks/useObservableValue';
import { useClient } from '../../../hooks/useClient';
import { useRoomMembersAPI } from '../../../hooks/useRoomMembers';
import { useGetRoomName } from '../../../hooks/storage/useGetRoomName';
@ -44,16 +42,20 @@ import {
useEditorResizeStickBottom,
useEventCollapse,
useEventDivider,
useFocusItem,
useGetItemElement,
useJumpHandlers,
useMessageEditorOutOfViewFocus,
useOnRangeChange,
useReplyClickHandler,
useRestoreScrollToBottom,
useTimelineBatch,
useTimelineContainerResizeStickBottom,
useTimelinePaginate,
useTimelinePrefetch,
useTimelineRelations,
useTimelineScrollRestore,
useUnreadMarker,
useUserClickHandler,
useUsernameClickHandler,
} from '../hooks/useTimeline';
@ -71,16 +73,18 @@ import { useMessageParserOptions } from '../../../hooks/useMessageParserOptions'
import { MessageContent } from '../../../components/MessageContent';
import { MessageEditor } from '../message/MessageEditor';
import { useSendReaction } from '../hooks/useSendReaction';
import { useWindowFocus } from '../../../hooks/useWindowFocus';
type TimelineViewProps = {
threadTimeline: ThreadTimeline;
timeline: ThreadTimeline;
threadInputRef: RefObject<HTMLElement>;
editor: Editor;
eventId: string;
};
const PAGINATION_LIMIT = 80;
export function TimelineView({ threadTimeline, threadInputRef, editor }: TimelineViewProps) {
export function TimelineView({ timeline, threadInputRef, editor, eventId }: TimelineViewProps) {
const ct = useClient();
const roomId = useRoom();
const encryptedRooms = useObservableValue(ct.encryptedRooms);
@ -134,102 +138,91 @@ export function TimelineView({ threadTimeline, threadInputRef, editor }: Timelin
const imagePackRooms = useImagePackRooms(roomId);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottomRef = useRef({
count: 0,
smooth: true,
});
const [focusItem, setFocusItem] = useState<
| {
index: number;
scrollTo: boolean;
highlight: boolean;
}
| undefined
>();
const alive = useAlive();
const timelineContainerRef = useRef<HTMLDivElement>(null);
const { linkifyOpts, htmlReactParserOptions, htmlReactParserInlineOptions } =
useMessageParserOptions(roomId, getRoomName, membersAPI.getName);
const threadMeta = useObservableValue(threadTimeline.meta);
const { redacted, reactions, edits } = useTimelineRelations(threadTimeline);
const threadMeta = useObservableValue(timeline.meta);
const { redacted, reactions, edits } = useTimelineRelations(timeline);
const localEventSenders = useObservableValue(threadTimeline.outbox.senders);
const localEventSenders = useObservableValue(timeline.outbox.senders);
const { messageBatch, events, range, rangeAtStart, rangeAtEnd, atLiveEnd } =
useTimelineBatch(threadTimeline);
useTimelineBatch(timeline);
const getScrollElement = useCallback(() => scrollRef.current, []);
useTimelinePrefetch(threadTimeline, PAGINATION_LIMIT);
const handlePaginate = useTimelinePaginate(threadTimeline, PAGINATION_LIMIT);
useTimelineScrollRestore(threadTimeline, scrollRef);
useRestoreScrollToBottom(scrollRef, messageBatch);
const readReceipt = useUserReadReceipt(timeline.threadId);
const lastReadEventInTimeline = useMemo(
() => !!readReceipt && !!messageBatch.messages.get(readReceipt.eventId),
[readReceipt, messageBatch]
);
const [eventLoading, setEventLoading] = useState(false);
useTimelinePrefetch(timeline, PAGINATION_LIMIT, !eventLoading);
const handlePaginate = useTimelinePaginate(timeline, PAGINATION_LIMIT, !eventLoading);
const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
useVirtualPaginator({
count: messageBatch.size,
limit: PAGINATION_LIMIT,
range,
onRangeChange: useOnRangeChange(threadTimeline),
onRangeChange: useOnRangeChange(timeline),
getScrollElement,
getItemElement: useGetItemElement(scrollRef),
onEnd: handlePaginate,
});
const tryAutoMarkAsRead = useCallback(() => {
console.log('mark thread as read.');
}, []);
const { focusItem, setFocusItem } = useFocusItem(timeline, scrollToItem);
const focused = useWindowFocus();
useTimelineScrollRestore(timeline, scrollRef);
useRestoreScrollToBottom(scrollRef, messageBatch);
const { atBottomLazy, atBottomObservable, atBottomAnchorRef } = useAtBottomToggle(
getScrollElement,
messageBatch.next.end ?? false
atLiveEnd
);
const tryAutoMarkAsRead = useCallback(() => {
if (!atBottomObservable.read) return;
console.log('%cmark as read.', 'background: blue; color: white; padding: 5px 10px;');
}, [atBottomObservable]);
useObservableValueListener(atBottomObservable, tryAutoMarkAsRead);
const { markerTs } = useUnreadMarker(atBottomLazy, focused, readReceipt?.ts);
useTimelineContainerResizeStickBottom(
timeline,
getScrollElement,
useCallback(() => timelineContainerRef.current, []),
atBottomObservable,
!focusItem && atLiveEnd
);
useEditorResizeStickBottom(
getScrollElement,
useCallback(() => threadInputRef?.current ?? null, [threadInputRef]),
useCallback(() => threadInputRef.current, [threadInputRef]),
atBottomObservable
);
// scroll to focused message
useLayoutEffect(() => {
if (focusItem && focusItem.scrollTo) {
scrollToItem(focusItem.index, {
behavior: 'instant',
align: 'center',
stopInView: true,
});
}
setTimeout(() => {
if (!alive()) return;
setFocusItem((currentItem) => {
if (currentItem === focusItem) return undefined;
return currentItem;
});
}, 2000);
}, [alive, focusItem, scrollToItem]);
// scroll to bottom of timeline
const scrollToBottomCount = scrollToBottomRef.current.count;
useLayoutEffect(() => {
if (scrollToBottomCount > 0) {
const scrollEl = scrollRef.current;
if (scrollEl)
scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
}
}, [scrollToBottomCount]);
useMessageEditorOutOfViewFocus(scrollRef, scrollToElement, editId);
const handleOpenReply = useCallback((replyId: string) => {
console.log(replyId);
}, []);
const { handleOpenEvent, handleJumpToUnread } = useJumpHandlers(
timeline,
readReceipt,
scrollToItem,
setFocusItem,
setEventLoading,
timeline.threadId
);
useEffect(() => {
if (eventId) {
handleOpenEvent(eventId);
}
}, [eventId, handleOpenEvent]);
const handleUserClick = useUserClickHandler();
const handleUsernameClick = useUsernameClickHandler(editor);
const [replyDraft, handleReplyClick] = useReplyClickHandler(editor, threadTimeline.threadId);
const [replyDraft, handleReplyClick] = useReplyClickHandler(editor, timeline.threadId);
const { reactionSender, handleAddReaction } = useSendReaction(reactions, redacted);
@ -254,20 +247,20 @@ export function TimelineView({ threadTimeline, threadInputRef, editor }: Timelin
const editEvents = edits.get(event.event_id);
const editedEvent = getLatestEditEvent(event, editEvents, redacted);
const highlighted = focusItem?.index === item && focusItem.highlight;
const highlighted = focusItem?.eventId === event.event_id && focusItem.highlight;
const replyEventId = fallbackReply(event.content) ? undefined : getReplyId(event.content);
const replyJSX = replyEventId ? (
<Reply
roomId={roomId}
eventId={replyEventId}
onClick={handleOpenReply}
onClick={handleOpenEvent}
onUserClick={handleUserClick}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
getMemberName={membersAPI.getName}
timeline={threadTimeline}
timeline={timeline}
htmlReactParserOptions={htmlReactParserInlineOptions}
linkifyOpts={linkifyOpts}
/>
@ -360,7 +353,6 @@ export function TimelineView({ threadTimeline, threadInputRef, editor }: Timelin
);
};
const readReceipt = useUserReadReceipt(threadTimeline.threadId);
const {
getDayDivider,
getThreadRootDivider,
@ -368,8 +360,8 @@ export function TimelineView({ threadTimeline, threadInputRef, editor }: Timelin
updateDivider,
clearDayDivider,
clearNewDivider,
} = useEventDivider(readReceipt?.ts);
const { getCollapsed, updateCollapse } = useEventCollapse(threadTimeline.threadId);
} = useEventDivider(markerTs);
const { getCollapsed, updateCollapse } = useEventCollapse(timeline.threadId);
const eventRenderer = (event: MessageEventWithoutRoomId, item: number) => {
if (event.sender && ignoredUsersSet.has(event.sender)) {
return null;
@ -409,11 +401,8 @@ export function TimelineView({ threadTimeline, threadInputRef, editor }: Timelin
return (
<Box grow="Yes" style={{ position: 'relative' }}>
{readReceipt && (
<UnreadControls
onJumpToUnread={() => threadTimeline.jumpToEvent(readReceipt.eventId)}
onMarkAsRead={() => undefined}
/>
{readReceipt && !lastReadEventInTimeline && (
<UnreadControls onJumpToUnread={handleJumpToUnread} onMarkAsRead={() => undefined} />
)}
<Scroll ref={scrollRef} size="0" visibility="Hover" hideTrack>
<Box direction="Column" style={{ minHeight: '100%' }}>
@ -448,7 +437,7 @@ export function TimelineView({ threadTimeline, threadInputRef, editor }: Timelin
padding: `${config.space.S600} ${config.space.S400} ${config.space.S600} 0`,
}}
>
{!messageBatch.prev.end || !rangeAtStart || !threadMeta ? (
{!messageBatch.prev.end || !rangeAtStart || !threadMeta || eventLoading ? (
<PlaceholderList
ref={observeBackAnchor}
count={5}
@ -459,11 +448,12 @@ export function TimelineView({ threadTimeline, threadInputRef, editor }: Timelin
) : (
<>{eventRenderer(threadMeta.rootEvent, -1)}</>
)}
{getItems().map((item) => {
const event = events[item];
if (!event) return null;
return eventRenderer(event, item);
})}
{!eventLoading &&
getItems().map((item) => {
const event = events[item];
if (!event) return null;
return eventRenderer(event, item);
})}
{(!messageBatch.next.end || !rangeAtEnd) && (
<PlaceholderList
ref={observeFrontAnchor}

View file

@ -255,6 +255,12 @@ export class ThreadTimeline implements TimelineView, Disposable {
}
public async jumpToEvent(eventId: string): Promise<JumpTarget> {
// thread root is not part of timeline.
// return early so we do not have to wait for response
if (eventId === this.threadId) {
return { type: 'main', eventId };
}
const resp = await this.request(
getContext({
pathParams: {
@ -287,8 +293,9 @@ export class ThreadTimeline implements TimelineView, Disposable {
if (threadId && threadId !== this.threadId) {
return { type: 'thread', eventId, threadId };
}
// event is not the root of this thread
if (!threadId && event.event_id !== this.threadId) {
// no untheaed reply should be put in timeline
// not event thread root
if (!threadId) {
return { type: 'main', eventId };
}
@ -300,9 +307,18 @@ export class ThreadTimeline implements TimelineView, Disposable {
};
}
public async jumpToOldest(): Promise<void> {
if (this.batch.read.prev.end) {
this.range.setRange(MessageRange.atBackwardEnd(this.batch.read.size));
return;
}
this.batchManager.reload(MessageBatch.emptyAtBackwardEnd());
}
public async jumpToLatest(): Promise<void> {
if (this.batch.read.next.end) {
this.range.setRange(MessageRange.atLiveEnd(this.batch.read.size));
this.range.setRange(MessageRange.atForwardEnd(this.batch.read.size));
return;
}

View file

@ -287,9 +287,18 @@ export class Timeline implements TimelineView, Disposable {
};
}
public async jumpToOldest(): Promise<void> {
if (this.batch.read.prev.end) {
this.range.setRange(MessageRange.atBackwardEnd(this.batch.read.size));
return;
}
this.batchManager.reload(MessageBatch.emptyAtBackwardEnd());
}
public async jumpToLatest(): Promise<void> {
if (this.batch.read.next.end) {
this.range.setRange(MessageRange.atLiveEnd(this.batch.read.size));
this.range.setRange(MessageRange.atForwardEnd(this.batch.read.size));
return;
}

View file

@ -17,6 +17,7 @@ export interface TimelineView {
paginateBackward(limit: number): Promise<void>;
paginateForward(limit: number): Promise<void>;
jumpToEvent(eventId: string): Promise<JumpTarget>;
jumpToOldest(): Promise<void>;
jumpToLatest(): Promise<void>;
findEvent(eventId: string): MessageEventWithoutRoomId | undefined;
findIndex(eventId: string): number;

View file

@ -14,6 +14,16 @@ export class MessageBatch {
public readonly change: BatchChange;
static emptyAtBackwardEnd(): MessageBatch {
return new MessageBatch(
randomStr(),
new Messages([]),
new BatchInfo(undefined, true),
new BatchInfo(),
new BatchChange()
);
}
static emptyAtForwardEnd(): MessageBatch {
return new MessageBatch(
randomStr(),

View file

@ -24,7 +24,14 @@ export class MessageRange implements Disposable {
static rangeLimit = 80;
static atLiveEnd(len: number): RangeInfo {
static atBackwardEnd(len: number): RangeInfo {
return {
start: 0,
end: Math.min(len, 0 + this.rangeLimit),
};
}
static atForwardEnd(len: number): RangeInfo {
return {
start: Math.max(0, len - this.rangeLimit),
end: len,
@ -34,7 +41,7 @@ export class MessageRange implements Disposable {
constructor(batch: ObservableValue<MessageBatch>) {
const len = batch.read.size;
this.info = new ObservableValue(MessageRange.atLiveEnd(len), this.publishKey);
this.info = new ObservableValue(MessageRange.atForwardEnd(len), this.publishKey);
this.oldLength = len;
this.disposer.collect(batch.subscribe(this.onBatchUpdate.bind(this)));
@ -49,7 +56,7 @@ export class MessageRange implements Disposable {
const lenAdded = size - oldLen;
this.setRange(
oldLen === 0 && batch.next.end
? MessageRange.atLiveEnd(size)
? MessageRange.atForwardEnd(size)
: this.adjustBackwardGrowth(lenAdded)
);
return;
@ -71,7 +78,7 @@ export class MessageRange implements Disposable {
return;
}
this.setRange(MessageRange.atLiveEnd(size));
this.setRange(MessageRange.atForwardEnd(size));
// clear the top cache since the timeline batch has changed
this.scrollTopCache = undefined;