From d08cbb44339f804c05ee90e776422fcae759c7c3 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 31 Dec 2024 17:15:42 +0200 Subject: [PATCH] web/timeline: use column reverse layout and content-visibility auto --- web/src/ui/composer/MessageComposer.tsx | 6 +-- web/src/ui/roomview/RoomView.tsx | 2 - web/src/ui/roomview/roomcontext.ts | 9 +---- web/src/ui/timeline/TimelineEvent.css | 3 ++ web/src/ui/timeline/TimelineView.css | 4 +- web/src/ui/timeline/TimelineView.tsx | 49 ++++++++++--------------- 6 files changed, 28 insertions(+), 45 deletions(-) diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index f9437b3..d10cdfc 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -420,11 +420,7 @@ const MessageComposer = () => { } textInput.current.rows = newTextRows textRows.current = newTextRows - // This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise - roomCtx.scrollToBottom() - // scrollToBottom needs to be called when replies/attachments/etc change, - // so listen to state instead of only state.text - }, [state, roomCtx]) + }, [state.text]) // Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect. useEffect(() => { roomCtx.isEditing.emit(editing !== null) diff --git a/web/src/ui/roomview/RoomView.tsx b/web/src/ui/roomview/RoomView.tsx index a646395..7d68c63 100644 --- a/web/src/ui/roomview/RoomView.tsx +++ b/web/src/ui/roomview/RoomView.tsx @@ -33,9 +33,7 @@ const RoomView = ({ room, rightPanelResizeHandle, rightPanel }: RoomViewProps) = const [roomContextData] = useState(() => new RoomContextData(room)) useEffect(() => { window.activeRoomContext = roomContextData - window.addEventListener("resize", roomContextData.scrollToBottom) return () => { - window.removeEventListener("resize", roomContextData.scrollToBottom) if (window.activeRoomContext === roomContextData) { window.activeRoomContext = undefined } diff --git a/web/src/ui/roomview/roomcontext.ts b/web/src/ui/roomview/roomcontext.ts index 0758612..772cc6f 100644 --- a/web/src/ui/roomview/roomcontext.ts +++ b/web/src/ui/roomview/roomcontext.ts @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { RefObject, createContext, createRef, use } from "react" +import { createContext, use } from "react" import { RoomStateStore } from "@/api/statestore" import { EventID, EventRowID, MemDBEvent } from "@/api/types" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" @@ -24,7 +24,6 @@ const noop = (name: string) => () => { } export class RoomContextData { - public readonly timelineBottomRef: RefObject = createRef() public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo") public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing") public insertText: (text: string) => void = noop("insertText") @@ -35,12 +34,6 @@ export class RoomContextData { constructor(public store: RoomStateStore) {} - scrollToBottom = () => { - if (this.scrolledToBottom) { - this.timelineBottomRef.current?.scrollIntoView() - } - } - setFocusedEventRowID = (eventRowID: number | null) => { this.directSetFocusedEventRowID(eventRowID) this.focusedEventRowID = eventRowID diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index c5b38b4..48e56f5 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -11,6 +11,8 @@ div.timeline-event { / var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size); contain: layout; margin-top: var(--timeline-message-gap); + content-visibility: auto; + contain-intrinsic-height: auto 3rem; &.highlight { background-color: var(--timeline-highlight-bg-color); @@ -25,6 +27,7 @@ div.timeline-event { } &:hover:not(.no-hover), &.focused-event { + content-visibility: visible; background-color: var(--timeline-hover-bg-color); &.highlight { diff --git a/web/src/ui/timeline/TimelineView.css b/web/src/ui/timeline/TimelineView.css index 79a575d..5057460 100644 --- a/web/src/ui/timeline/TimelineView.css +++ b/web/src/ui/timeline/TimelineView.css @@ -2,7 +2,7 @@ div.timeline-view { overflow-y: scroll; display: flex; - flex-direction: column; + flex-direction: column-reverse; justify-content: space-between; > div.timeline-beginning { @@ -19,5 +19,7 @@ div.timeline-view { > div.timeline-list { padding-bottom: 2rem; + display: flex; + flex-direction: column-reverse; } } diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 2148258..a856c76 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -13,10 +13,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" +import { use, useCallback, useEffect, useRef, useState } from "react" import { ScaleLoader } from "react-spinners" import { usePreference, useRoomTimeline } from "@/api/statestore" -import { EventRowID, MemDBEvent } from "@/api/types" +import { EventRowID, MemDBEvent, TimelineRowID } from "@/api/types" import useFocus from "@/util/focus.ts" import ClientContext from "../ClientContext.ts" import { useRoomContext } from "../roomview/roomcontext.ts" @@ -36,11 +36,9 @@ const TimelineView = () => { .catch(err => console.error("Failed to load history", err)) .finally(() => setLoadingHistory(false)) }, [client, room]) - const bottomRef = roomCtx.timelineBottomRef const topRef = useRef(null) const timelineViewRef = useRef(null) - const prevOldestTimelineRow = useRef(0) - const oldestTimelineRow = timeline[0]?.timeline_rowid + const oldestTimelineRow = useRef(timeline[0]?.timeline_rowid ?? 0) const oldScrollHeight = useRef(0) const focused = useFocus() const smallReplies = usePreference(client.store, room, "small_replies") @@ -58,21 +56,11 @@ const TimelineView = () => { if (timelineViewRef.current) { oldScrollHeight.current = timelineViewRef.current.scrollHeight } - useLayoutEffect(() => { - const bottomRef = roomCtx.timelineBottomRef - if (bottomRef.current && roomCtx.scrolledToBottom) { - // For any timeline changes, if we were at the bottom, scroll to the new bottom - bottomRef.current.scrollIntoView() - } else if (timelineViewRef.current && prevOldestTimelineRow.current > (timeline[0]?.timeline_rowid ?? 0)) { - // When new entries are added to the top of the timeline, scroll down to keep the same position - timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current - } - prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0 - }, [client.userID, roomCtx, timeline]) useEffect(() => { roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID }, [roomCtx]) useEffect(() => { + oldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0 const newestEvent = timeline[timeline.length - 1] if ( roomCtx.scrolledToBottom @@ -101,8 +89,11 @@ const TimelineView = () => { return } const observer = new IntersectionObserver(entries => { - if (entries[0]?.isIntersecting && room.paginationRequestedForRow !== prevOldestTimelineRow.current) { - room.paginationRequestedForRow = prevOldestTimelineRow.current + if ( + entries[0]?.isIntersecting + && room.paginationRequestedForRow !== oldestTimelineRow.current + ) { + room.paginationRequestedForRow = oldestTimelineRow.current loadHistory() } }, { @@ -112,19 +103,12 @@ const TimelineView = () => { }) observer.observe(topElem) return () => observer.unobserve(topElem) - }, [room, room.hasMoreHistory, loadHistory, oldestTimelineRow]) + }, [room, room.hasMoreHistory, loadHistory]) let prevEvt: MemDBEvent | null = null return
-
- {room.hasMoreHistory ? : "No more history available in this room"} -
-
+ {/*
*/} {timeline.map(entry => { if (!entry) { return null @@ -138,8 +122,15 @@ const TimelineView = () => { /> prevEvt = entry return thisEvt - })} -
+ }).reverse()} +
+
+
+ {room.hasMoreHistory ? : "No more history available in this room"}
}