13
13
//
14
14
// You should have received a copy of the GNU Affero General Public License
15
15
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16
- import { use , useCallback , useEffect , useLayoutEffect , useRef , useState } from "react"
16
+ import { Virtualizer , useVirtualizer } from "@tanstack/react-virtual"
17
+ import { use , useEffect , useLayoutEffect , useRef , useState } from "react"
17
18
import { ScaleLoader } from "react-spinners"
18
19
import { usePreference , useRoomTimeline } from "@/api/statestore"
19
20
import { EventRowID , MemDBEvent } from "@/api/types"
20
21
import useFocus from "@/util/focus.ts"
21
22
import ClientContext from "../ClientContext.ts"
22
23
import { useRoomContext } from "../roomview/roomcontext.ts"
23
24
import TimelineEvent from "./TimelineEvent.tsx"
25
+ import { getBodyType , isSmallEvent } from "./content/index.ts"
24
26
import "./TimelineView.css"
25
27
28
+ const measureElement = ( element : Element , entry : ResizeObserverEntry | undefined , instance : Virtualizer < HTMLDivElement , Element > ) => {
29
+ const horizontal = instance . options . horizontal
30
+ const style = window . getComputedStyle ( element )
31
+ if ( entry == null ? void 0 : entry . borderBoxSize ) {
32
+ const box = entry ?. borderBoxSize [ 0 ]
33
+ if ( box ) {
34
+ const size = Math . round (
35
+ box [ horizontal ? "inlineSize" : "blockSize" ] ,
36
+ )
37
+ return size + parseFloat ( style [ horizontal ? "marginInlineStart" : "marginBlockStart" ] ) + parseFloat ( style [ horizontal ? "marginInlineEnd" : "marginBlockEnd" ] )
38
+ }
39
+ }
40
+ return Math . round (
41
+ element . getBoundingClientRect ( ) [ instance . options . horizontal ? "width" : "height" ] + parseFloat ( style [ horizontal ? "marginLeft" : "marginTop" ] ) + parseFloat ( style [ horizontal ? "marginRight" : "marginBottom" ] ) ,
42
+ )
43
+ }
44
+
45
+ const estimateEventHeight = ( event : MemDBEvent ) => isSmallEvent ( getBodyType ( event ) ) ? ( event ?. reactions ? 26 : 0 ) + ( event ?. content . body ? ( event ?. local_content ?. big_emoji ? 92 : 44 ) : 0 ) + ( event ?. content . info ?. h || 0 ) : 26
46
+
26
47
const TimelineView = ( ) => {
27
48
const roomCtx = useRoomContext ( )
28
49
const room = roomCtx . store
29
50
const timeline = useRoomTimeline ( room )
30
51
const client = use ( ClientContext ) !
31
52
const [ isLoadingHistory , setLoadingHistory ] = useState ( false )
32
53
const [ focusedEventRowID , directSetFocusedEventRowID ] = useState < EventRowID | null > ( null )
33
- const loadHistory = useCallback ( ( ) => {
54
+ const loadHistory = ( ) => {
34
55
setLoadingHistory ( true )
35
56
client . loadMoreHistory ( room . roomID )
36
57
. catch ( err => console . error ( "Failed to load history" , err ) )
37
- . finally ( ( ) => setLoadingHistory ( false ) )
38
- } , [ client , room ] )
58
+ . then ( ( loadedEventCount ) => {
59
+ // Prevent scroll getting stuck loading more history
60
+ if ( loadedEventCount && timelineViewRef . current && timelineViewRef . current . scrollTop <= virtualListOffsetRef . current ) {
61
+ virtualizer . scrollToIndex ( loadedEventCount , { align : "end" } )
62
+ }
63
+ } )
64
+ . finally ( ( ) => {
65
+ setLoadingHistory ( false )
66
+ } )
67
+ }
39
68
const bottomRef = roomCtx . timelineBottomRef
40
- const topRef = useRef < HTMLDivElement > ( null )
41
69
const timelineViewRef = useRef < HTMLDivElement > ( null )
42
- const prevOldestTimelineRow = useRef ( 0 )
43
- const oldestTimelineRow = timeline [ 0 ] ?. timeline_rowid
44
- const oldScrollHeight = useRef ( 0 )
45
70
const focused = useFocus ( )
46
71
const smallReplies = usePreference ( client . store , room , "small_replies" )
47
72
73
+ const virtualListRef = useRef < HTMLDivElement > ( null )
74
+
75
+ const virtualListOffsetRef = useRef ( 0 )
76
+
77
+ useLayoutEffect ( ( ) => {
78
+ virtualListOffsetRef . current = virtualListRef . current ?. offsetTop ?? 0
79
+ } , [ ] )
80
+
81
+ const virtualizer = useVirtualizer ( {
82
+ count : timeline . length ,
83
+ getScrollElement : ( ) => timelineViewRef . current ,
84
+ estimateSize : ( index ) => timeline [ index ] ? estimateEventHeight ( timeline [ index ] ) : 0 ,
85
+ getItemKey : ( index ) => timeline [ index ] ?. rowid || index ,
86
+ overscan : 6 ,
87
+ measureElement,
88
+ } )
89
+
90
+ const items = virtualizer . getVirtualItems ( )
91
+
92
+ useLayoutEffect ( ( ) => {
93
+ if ( roomCtx . scrolledToBottom ) {
94
+ // timelineViewRef.current && (timelineViewRef.current.scrollTop = timelineViewRef.current.scrollHeight)
95
+ bottomRef . current ?. scrollIntoView ( )
96
+ }
97
+ } , [ roomCtx , timeline , virtualizer . getTotalSize ( ) ] )
98
+
48
99
// When the user scrolls the timeline manually, remember if they were at the bottom,
49
100
// so that we can keep them at the bottom when new events are added.
50
101
const handleScroll = ( ) => {
@@ -54,24 +105,11 @@ const TimelineView = () => {
54
105
const timelineView = timelineViewRef . current
55
106
roomCtx . scrolledToBottom = timelineView . scrollTop + timelineView . clientHeight + 1 >= timelineView . scrollHeight
56
107
}
57
- // Save the scroll height prior to updating the timeline, so that we can adjust the scroll position if needed.
58
- if ( timelineViewRef . current ) {
59
- oldScrollHeight . current = timelineViewRef . current . scrollHeight
60
- }
61
- useLayoutEffect ( ( ) => {
62
- const bottomRef = roomCtx . timelineBottomRef
63
- if ( bottomRef . current && roomCtx . scrolledToBottom ) {
64
- // For any timeline changes, if we were at the bottom, scroll to the new bottom
65
- bottomRef . current . scrollIntoView ( )
66
- } else if ( timelineViewRef . current && prevOldestTimelineRow . current > ( timeline [ 0 ] ?. timeline_rowid ?? 0 ) ) {
67
- // When new entries are added to the top of the timeline, scroll down to keep the same position
68
- timelineViewRef . current . scrollTop += timelineViewRef . current . scrollHeight - oldScrollHeight . current
69
- }
70
- prevOldestTimelineRow . current = timeline [ 0 ] ?. timeline_rowid ?? 0
71
- } , [ client . userID , roomCtx , timeline ] )
108
+
72
109
useEffect ( ( ) => {
73
110
roomCtx . directSetFocusedEventRowID = directSetFocusedEventRowID
74
111
} , [ roomCtx ] )
112
+
75
113
useEffect ( ( ) => {
76
114
const newestEvent = timeline [ timeline . length - 1 ]
77
115
if (
@@ -95,26 +133,31 @@ const TimelineView = () => {
95
133
)
96
134
}
97
135
} , [ focused , client , roomCtx , room , timeline ] )
136
+
98
137
useEffect ( ( ) => {
99
- const topElem = topRef . current
100
- if ( ! topElem || ! room . hasMoreHistory ) {
138
+ if ( ! room . hasMoreHistory || room . paginating ) {
139
+ return
140
+ }
141
+
142
+ const firstItem = virtualizer . getVirtualItems ( ) [ 0 ]
143
+
144
+ // Load history if there is none
145
+ if ( ! firstItem ) {
146
+ loadHistory ( )
101
147
return
102
148
}
103
- const observer = new IntersectionObserver ( entries => {
104
- if ( entries [ 0 ] ?. isIntersecting && room . paginationRequestedForRow !== prevOldestTimelineRow . current ) {
105
- room . paginationRequestedForRow = prevOldestTimelineRow . current
106
- loadHistory ( )
107
- }
108
- } , {
109
- root : topElem . parentElement ! . parentElement ,
110
- rootMargin : "0px" ,
111
- threshold : 1.0 ,
112
- } )
113
- observer . observe ( topElem )
114
- return ( ) => observer . unobserve ( topElem )
115
- } , [ room , room . hasMoreHistory , loadHistory , oldestTimelineRow ] )
116
-
117
- let prevEvt : MemDBEvent | null = null
149
+
150
+ // Load more history when the virtualiser loads the last item
151
+ if ( firstItem . index == 0 ) {
152
+ console . log ( "Loading more history..." )
153
+ loadHistory ( )
154
+ return
155
+ }
156
+ } , [
157
+ room . hasMoreHistory , loadHistory ,
158
+ virtualizer . getVirtualItems ( ) ,
159
+ ] )
160
+
118
161
return < div className = "timeline-view" onScroll = { handleScroll } ref = { timelineViewRef } >
119
162
< div className = "timeline-beginning" >
120
163
{ room . hasMoreHistory ? < button onClick = { loadHistory } disabled = { isLoadingHistory } >
@@ -123,24 +166,47 @@ const TimelineView = () => {
123
166
: "Load more history" }
124
167
</ button > : "No more history available in this room" }
125
168
</ div >
126
- < div className = "timeline-list" >
127
- < div className = "timeline-top-ref" ref = { topRef } />
128
- { timeline . map ( entry => {
129
- if ( ! entry ) {
130
- return null
131
- }
132
- const thisEvt = < TimelineEvent
133
- key = { entry . rowid }
134
- evt = { entry }
135
- prevEvt = { prevEvt }
136
- smallReplies = { smallReplies }
137
- isFocused = { focusedEventRowID === entry . rowid }
138
- />
139
- prevEvt = entry
140
- return thisEvt
141
- } ) }
142
- < div className = "timeline-bottom-ref" ref = { bottomRef } />
169
+ < div
170
+ style = { {
171
+ height : virtualizer . getTotalSize ( ) ,
172
+ width : "100%" ,
173
+ position : "relative" ,
174
+ } }
175
+ className = "timeline-list"
176
+ ref = { virtualListRef }
177
+ >
178
+ < div
179
+ style = { {
180
+ position : "absolute" ,
181
+ top : 0 ,
182
+ left : 0 ,
183
+ width : "100%" ,
184
+ transform : `translateY(${ items [ 0 ] ?. start ?? 0 } px)` ,
185
+ } }
186
+ className = "timeline-virtual-items"
187
+ >
188
+
189
+ { items . map ( ( virtualRow ) => {
190
+ const entry = timeline [ virtualRow . index ]
191
+ if ( ! entry ) {
192
+ return null
193
+ }
194
+ const thisEvt = < TimelineEvent
195
+ evt = { entry }
196
+ prevEvt = { timeline [ virtualRow . index - 1 ] ?? null }
197
+ smallReplies = { smallReplies }
198
+ isFocused = { focusedEventRowID === entry . rowid }
199
+
200
+ key = { virtualRow . key }
201
+ virtualIndex = { virtualRow . index }
202
+ ref = { virtualizer . measureElement }
203
+ />
204
+
205
+ return thisEvt
206
+ } ) }
207
+ </ div >
143
208
</ div >
209
+ < div className = "timeline-bottom-ref" ref = { bottomRef } />
144
210
</ div >
145
211
}
146
212
0 commit comments