web/timeline: use column reverse layout and content-visibility auto

This commit is contained in:
Tulir Asokan 2024-12-31 17:15:42 +02:00
parent 43f25727e6
commit d08cbb4433
6 changed files with 28 additions and 45 deletions

View file

@ -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)

View file

@ -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
}

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<HTMLDivElement | null> = 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

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -13,10 +13,10 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<HTMLDivElement>(null)
const timelineViewRef = useRef<HTMLDivElement>(null)
const prevOldestTimelineRow = useRef(0)
const oldestTimelineRow = timeline[0]?.timeline_rowid
const oldestTimelineRow = useRef<TimelineRowID>(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 <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
<div className="timeline-beginning">
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
{isLoadingHistory
? <><ScaleLoader color="var(--primary-color)"/> Loading history...</>
: "Load more history"}
</button> : "No more history available in this room"}
</div>
<div className="timeline-list">
<div className="timeline-top-ref" ref={topRef}/>
{/*<div className="timeline-bottom-ref" ref={bottomRef}/>*/}
{timeline.map(entry => {
if (!entry) {
return null
@ -138,8 +122,15 @@ const TimelineView = () => {
/>
prevEvt = entry
return thisEvt
})}
<div className="timeline-bottom-ref" ref={bottomRef}/>
}).reverse()}
<div className="timeline-top-ref" ref={topRef}/>
</div>
<div className="timeline-beginning">
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
{isLoadingHistory
? <><ScaleLoader color="var(--primary-color)"/> Loading history...</>
: "Load more history"}
</button> : "No more history available in this room"}
</div>
</div>
}