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 textInput.current.rows = newTextRows
textRows.current = newTextRows textRows.current = newTextRows
// This has to be called unconditionally, because setting rows = 1 messes up the scroll state otherwise }, [state.text])
roomCtx.scrollToBottom()
// scrollToBottom needs to be called when replies/attachments/etc change,
// so listen to state instead of only state.text
}, [state, roomCtx])
// Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect. // Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect.
useEffect(() => { useEffect(() => {
roomCtx.isEditing.emit(editing !== null) roomCtx.isEditing.emit(editing !== null)

View file

@ -33,9 +33,7 @@ const RoomView = ({ room, rightPanelResizeHandle, rightPanel }: RoomViewProps) =
const [roomContextData] = useState(() => new RoomContextData(room)) const [roomContextData] = useState(() => new RoomContextData(room))
useEffect(() => { useEffect(() => {
window.activeRoomContext = roomContextData window.activeRoomContext = roomContextData
window.addEventListener("resize", roomContextData.scrollToBottom)
return () => { return () => {
window.removeEventListener("resize", roomContextData.scrollToBottom)
if (window.activeRoomContext === roomContextData) { if (window.activeRoomContext === roomContextData) {
window.activeRoomContext = undefined window.activeRoomContext = undefined
} }

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // 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 { RoomStateStore } from "@/api/statestore"
import { EventID, EventRowID, MemDBEvent } from "@/api/types" import { EventID, EventRowID, MemDBEvent } from "@/api/types"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
@ -24,7 +24,6 @@ const noop = (name: string) => () => {
} }
export class RoomContextData { export class RoomContextData {
public readonly timelineBottomRef: RefObject<HTMLDivElement | null> = createRef()
public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo") public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo")
public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing") public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing")
public insertText: (text: string) => void = noop("insertText") public insertText: (text: string) => void = noop("insertText")
@ -35,12 +34,6 @@ export class RoomContextData {
constructor(public store: RoomStateStore) {} constructor(public store: RoomStateStore) {}
scrollToBottom = () => {
if (this.scrolledToBottom) {
this.timelineBottomRef.current?.scrollIntoView()
}
}
setFocusedEventRowID = (eventRowID: number | null) => { setFocusedEventRowID = (eventRowID: number | null) => {
this.directSetFocusedEventRowID(eventRowID) this.directSetFocusedEventRowID(eventRowID)
this.focusedEventRowID = 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); / var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size);
contain: layout; contain: layout;
margin-top: var(--timeline-message-gap); margin-top: var(--timeline-message-gap);
content-visibility: auto;
contain-intrinsic-height: auto 3rem;
&.highlight { &.highlight {
background-color: var(--timeline-highlight-bg-color); background-color: var(--timeline-highlight-bg-color);
@ -25,6 +27,7 @@ div.timeline-event {
} }
&:hover:not(.no-hover), &.focused-event { &:hover:not(.no-hover), &.focused-event {
content-visibility: visible;
background-color: var(--timeline-hover-bg-color); background-color: var(--timeline-hover-bg-color);
&.highlight { &.highlight {

View file

@ -2,7 +2,7 @@ div.timeline-view {
overflow-y: scroll; overflow-y: scroll;
display: flex; display: flex;
flex-direction: column; flex-direction: column-reverse;
justify-content: space-between; justify-content: space-between;
> div.timeline-beginning { > div.timeline-beginning {
@ -19,5 +19,7 @@ div.timeline-view {
> div.timeline-list { > div.timeline-list {
padding-bottom: 2rem; 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 // 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/>. // 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 { ScaleLoader } from "react-spinners"
import { usePreference, useRoomTimeline } from "@/api/statestore" 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 useFocus from "@/util/focus.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import { useRoomContext } from "../roomview/roomcontext.ts" import { useRoomContext } from "../roomview/roomcontext.ts"
@ -36,11 +36,9 @@ const TimelineView = () => {
.catch(err => console.error("Failed to load history", err)) .catch(err => console.error("Failed to load history", err))
.finally(() => setLoadingHistory(false)) .finally(() => setLoadingHistory(false))
}, [client, room]) }, [client, room])
const bottomRef = roomCtx.timelineBottomRef
const topRef = useRef<HTMLDivElement>(null) const topRef = useRef<HTMLDivElement>(null)
const timelineViewRef = useRef<HTMLDivElement>(null) const timelineViewRef = useRef<HTMLDivElement>(null)
const prevOldestTimelineRow = useRef(0) const oldestTimelineRow = useRef<TimelineRowID>(timeline[0]?.timeline_rowid ?? 0)
const oldestTimelineRow = timeline[0]?.timeline_rowid
const oldScrollHeight = useRef(0) const oldScrollHeight = useRef(0)
const focused = useFocus() const focused = useFocus()
const smallReplies = usePreference(client.store, room, "small_replies") const smallReplies = usePreference(client.store, room, "small_replies")
@ -58,21 +56,11 @@ const TimelineView = () => {
if (timelineViewRef.current) { if (timelineViewRef.current) {
oldScrollHeight.current = timelineViewRef.current.scrollHeight 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(() => { useEffect(() => {
roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID
}, [roomCtx]) }, [roomCtx])
useEffect(() => { useEffect(() => {
oldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
const newestEvent = timeline[timeline.length - 1] const newestEvent = timeline[timeline.length - 1]
if ( if (
roomCtx.scrolledToBottom roomCtx.scrolledToBottom
@ -101,8 +89,11 @@ const TimelineView = () => {
return return
} }
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
if (entries[0]?.isIntersecting && room.paginationRequestedForRow !== prevOldestTimelineRow.current) { if (
room.paginationRequestedForRow = prevOldestTimelineRow.current entries[0]?.isIntersecting
&& room.paginationRequestedForRow !== oldestTimelineRow.current
) {
room.paginationRequestedForRow = oldestTimelineRow.current
loadHistory() loadHistory()
} }
}, { }, {
@ -112,19 +103,12 @@ const TimelineView = () => {
}) })
observer.observe(topElem) observer.observe(topElem)
return () => observer.unobserve(topElem) return () => observer.unobserve(topElem)
}, [room, room.hasMoreHistory, loadHistory, oldestTimelineRow]) }, [room, room.hasMoreHistory, loadHistory])
let prevEvt: MemDBEvent | null = null let prevEvt: MemDBEvent | null = null
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}> 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-list">
<div className="timeline-top-ref" ref={topRef}/> {/*<div className="timeline-bottom-ref" ref={bottomRef}/>*/}
{timeline.map(entry => { {timeline.map(entry => {
if (!entry) { if (!entry) {
return null return null
@ -138,8 +122,15 @@ const TimelineView = () => {
/> />
prevEvt = entry prevEvt = entry
return thisEvt return thisEvt
})} }).reverse()}
<div className="timeline-bottom-ref" ref={bottomRef}/> <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>
</div> </div>
} }