1
1
import React , { useCallback , useEffect , useLayoutEffect , useRef , useState , useMemo , useContext } from "react"
2
+ import { Virtuoso } from "react-virtuoso"
2
3
import _ from "lodash"
3
4
4
5
import { useEnsName } from "wagmi"
5
- import { useRoute } from "@canvas-js/hooks"
6
-
6
+ import { Client , useRoute } from "@canvas-js/hooks"
7
7
import { AppContext } from "./AppContext"
8
8
9
9
type Post = {
@@ -13,6 +13,135 @@ type Post = {
13
13
updated_at : number
14
14
}
15
15
16
+ export const MessagesInfiniteScroller : React . FC < { } > = ( { } ) => {
17
+ const [ posts , setPosts ] = useState < Post [ ] > ( [ ] )
18
+ const [ cursor , setCursor ] = useState < string | number > ( "" )
19
+
20
+ // Virtuoso uses firstItemIndex to maintain scroll position when
21
+ // items are added. It should always be set *relative to its
22
+ // original value* and cannot be negative, so we initialize it
23
+ // with a very large MAX_VALUE.
24
+ const MAX_VALUE = 999999999
25
+ const virtuoso = useRef ( null )
26
+ const [ firstItemIndex , setFirstItemIndex ] = useState < number > ( MAX_VALUE )
27
+
28
+ // Past posts are fetched declaratively, by updating `cursor`.
29
+ // Because of pagination, the route returns a window of posts ordered
30
+ // monotonically the same as displayed posts, but potentially overlapping
31
+ // or interleaved with them, so we use a Map to filter out duplicates.
32
+ const { data : pastPosts } = useRoute < Post > ( "/posts" , { before : cursor } , { subscribe : false } )
33
+
34
+ // Maintain a subscription to the most recent page of posts.
35
+ // We assume that posts are received in-order, an assumption which
36
+ // may be violated when generating data.
37
+ const { data : newPosts } = useRoute < Post > ( "/posts" , { before : "" } )
38
+
39
+ useEffect ( ( ) => {
40
+ if ( ! pastPosts || ! newPosts ) return
41
+
42
+ if ( posts . length === 0 ) {
43
+ const filteredNewPosts = [ ...newPosts ]
44
+ filteredNewPosts . reverse ( )
45
+ setPosts ( filteredNewPosts )
46
+ } else {
47
+ const postsM = new Map ( posts . map ( ( f ) => [ f . id , f ] ) )
48
+ const filteredPastPosts = pastPosts . filter ( ( item ) => ! postsM . has ( item . id ) )
49
+ const filteredNewPosts = newPosts . filter ( ( item ) => ! postsM . has ( item . id ) )
50
+ if ( filteredPastPosts . length === 0 && filteredNewPosts . length === 0 ) return
51
+ setFirstItemIndex ( firstItemIndex - filteredPastPosts . length )
52
+ filteredPastPosts . reverse ( )
53
+ filteredNewPosts . reverse ( )
54
+
55
+ // Interleave new posts according to updated_at, so if we
56
+ // receive new posts out-of-order (happens frequently on batch insert)
57
+ // they won't persist out-of-order
58
+ let result
59
+ if (
60
+ // check if all posts are ordered
61
+ ( filteredPastPosts . length === 0 && posts . length === 0 ) ||
62
+ ( posts . length === 0 && filteredNewPosts . length === 0 )
63
+ ) {
64
+ result = [ ...filteredPastPosts , ...posts , ...filteredNewPosts ]
65
+ } else {
66
+ // check if past + present posts are ordered
67
+ if (
68
+ filteredPastPosts . length === 0 ||
69
+ posts . length === 0 ||
70
+ filteredPastPosts [ filteredPastPosts . length - 1 ] . updated_at < posts [ 0 ] . updated_at
71
+ ) {
72
+ result = [ ...filteredPastPosts , ...posts ]
73
+ } else {
74
+ result = _ . sortedUniqBy ( _ . sortBy ( [ ...filteredPastPosts , ...posts ] , "updated_at" ) , "updated_at" )
75
+ }
76
+ // check if present + new posts are ordered
77
+ if (
78
+ result . length === 0 ||
79
+ filteredNewPosts . length === 0 ||
80
+ result [ result . length - 1 ] . updated_at < filteredNewPosts [ 0 ] . updated_at
81
+ ) {
82
+ result = [ ...result , ...filteredNewPosts ]
83
+ } else {
84
+ result = _ . sortedUniqBy ( _ . sortBy ( [ ...result , ...filteredNewPosts ] , "updated_at" ) , "updated_at" )
85
+ }
86
+ }
87
+ setPosts ( result )
88
+
89
+ // Scroll-to-bottom doesn't seem to work correctly, Virtuoso incorrectly
90
+ // caps the maximum scroll to `scroller.offsetHeight - scroller.scrollHeight`
91
+ // when there might be additional not-yet-rendered content at the bottom.
92
+ if ( filteredPastPosts . length === 0 ) {
93
+ const scroller = document . querySelector ( "[data-virtuoso-scroller=true]" ) as HTMLElement
94
+ if ( scroller === null ) return
95
+ // Only scroll-to-bottom if we're already near the bottom
96
+ if ( scroller . scrollTop + scroller . offsetHeight < scroller . scrollHeight - 40 ) return
97
+ setTimeout ( ( ) => {
98
+ scroller . scrollTop = 99999999
99
+ setTimeout ( ( ) => {
100
+ scroller . scrollTop = 99999999
101
+ } , 10 )
102
+ } , 10 )
103
+ }
104
+ }
105
+ } , [ newPosts , pastPosts , posts ] )
106
+
107
+ const startReached = useCallback (
108
+ ( index : number ) => {
109
+ if ( posts . length === 0 ) return
110
+
111
+ setTimeout ( ( ) => {
112
+ const earliestPost = posts [ 0 ]
113
+ const newCursor = earliestPost ?. updated_at ?. toString ( )
114
+ if ( ! earliestPost || cursor === earliestPost . updated_at ) return // Nothing more
115
+ setCursor ( earliestPost . updated_at )
116
+ console . log ( "cursor changed:" , earliestPost . updated_at )
117
+ } , 500 )
118
+ } ,
119
+ [ posts , cursor ]
120
+ )
121
+
122
+ const itemContent = useCallback ( ( index : number , post : Post ) => < Post key = { post . id } { ...post } /> , [ ] )
123
+ // const followOutput = useCallback((isAtBottom) => (isAtBottom ? "auto" : false), [])
124
+
125
+ return (
126
+ < ul className = "tree-view" >
127
+ { posts . length > 0 && (
128
+ < Virtuoso
129
+ atBottomThreshold = { 40 }
130
+ ref = { virtuoso }
131
+ firstItemIndex = { firstItemIndex }
132
+ initialTopMostItemIndex = { posts . length }
133
+ itemContent = { itemContent }
134
+ data = { posts }
135
+ startReached = { startReached }
136
+ // followOutput={followOutput}
137
+ style = { { flex : "1 1 auto" , overscrollBehavior : "contain" } }
138
+ increaseViewportBy = { { bottom : 40 , top : 40 } }
139
+ />
140
+ ) }
141
+ </ ul >
142
+ )
143
+ }
144
+
16
145
export const Messages : React . FC = ( { } ) => {
17
146
const inputRef = useRef < HTMLInputElement > ( null )
18
147
const { client } = useContext ( AppContext )
@@ -44,109 +173,25 @@ export const Messages: React.FC = ({}) => {
44
173
}
45
174
} , [ isReady ] )
46
175
47
- const [ cursor , setCursor ] = useState ( "" )
48
- const [ pages , setPages ] = useState < Record < string , Post [ ] > > ( { } )
49
- const [ messages , setMessages ] = useState < Post [ ] > ( [ ] )
50
- const [ latest , setLatest ] = useState < Post [ ] > ( [ ] ) // only used to trigger the LayoutEffect to scroll to bottom
51
- const [ trailing , setTrailing ] = useState < Post [ ] > ( [ ] ) // messages no longer in latest, but not in scrollback
52
-
53
- // Subscribe to both the latest posts, and scrollback
54
- const callbackLatest = useCallback (
55
- ( data : Post [ ] | null , error : Error | null ) => {
56
- if ( ! data ) return
57
- const hashes = new Set ( data . map ( ( d ) => d . id ) )
58
- setLatest ( data )
59
- setTrailing ( latest . filter ( ( d ) => ! hashes . has ( d . id ) ) . concat ( trailing ) )
60
- } ,
61
- [ trailing , setTrailing ]
62
- )
63
- const callbackPrev = useCallback (
64
- ( data : Post [ ] | null , error : Error | null ) => {
65
- if ( ! data || cursor === "" ) return
66
- setPages ( { ...pages , [ cursor ] : data } )
67
- } ,
68
- [ cursor ]
69
- )
70
-
71
- const { data : curr , error } = useRoute < Post > ( "/posts" , { before : "" } , undefined , callbackLatest )
72
- const { data : prev } = useRoute < Post > ( "/posts" , { before : cursor } , undefined , callbackPrev )
73
- useEffect ( ( ) => {
74
- const scrollback = Object . keys ( pages ) . reduce ( ( acc : Post [ ] , page ) => acc . concat ( pages [ page ] ) , [ ] )
75
- setMessages ( latest . concat ( trailing ) . concat ( scrollback ) )
76
- } , [ latest , pages ] )
77
-
78
- // Load more on scroll
79
- const handleScroll = useMemo (
80
- ( ) =>
81
- _ . throttle ( ( event : React . UIEvent < HTMLElement > ) => {
82
- if ( ( event ?. target as HTMLElement ) ?. scrollTop > 50 ) return
83
- if ( messages . length > 0 )
84
- setTimeout ( ( ) => {
85
- const earliestPost = messages [ messages . length - 1 ] ?. updated_at ?. toString ( )
86
- if ( cursor === earliestPost ) return // Nothing more to load
87
- setCursor ( earliestPost )
88
- } )
89
- } , 750 ) ,
90
- [ messages , cursor ]
91
- )
92
-
93
- // Jump to bottom on load, and when the current page updates, but not when messages updates
94
- const scrollContainer = useRef < HTMLDivElement > ( null )
95
- useLayoutEffect ( ( ) => {
96
- if ( ! curr ?. length || ! messages ?. length ) return
97
- setTimeout ( ( ) => {
98
- if ( scrollContainer . current !== null ) {
99
- scrollContainer . current . scrollTop = scrollContainer . current . scrollHeight
100
- }
101
- } )
102
- } , [ latest , curr ?. length !== 0 && prev ?. length !== 0 && messages . length !== 0 ] )
103
-
104
176
return (
105
177
< div id = "messages" className = "window" >
106
178
< div className = "title-bar" >
107
179
< div className = "title-bar-text" > Messages</ div >
108
180
</ div >
109
- { error ? (
110
- < div className = "window-body" >
111
- < ul className = "tree-view" >
112
- < li > { error . toString ( ) } </ li >
113
- </ ul >
114
- </ div >
115
- ) : (
116
- < div className = "window-body" >
117
- < div id = "scroll-container" ref = { scrollContainer } onScroll = { handleScroll } >
118
- < ul className = "tree-view" >
119
- < Posts posts = { messages } />
120
- </ ul >
121
- </ div >
122
- < input
123
- type = "text"
124
- disabled = { ! isReady }
125
- ref = { inputRef }
126
- onKeyDown = { handleKeyDown }
127
- placeholder = { isReady ? "" : "Start a session to chat" }
128
- />
129
- </ div >
130
- ) }
181
+ < div className = "window-body" >
182
+ < MessagesInfiniteScroller />
183
+ < input
184
+ type = "text"
185
+ disabled = { ! isReady }
186
+ ref = { inputRef }
187
+ onKeyDown = { handleKeyDown }
188
+ placeholder = { isReady ? "" : "Start a session to chat" }
189
+ />
190
+ </ div >
131
191
</ div >
132
192
)
133
193
}
134
194
135
- const Posts : React . FC < { posts : null | Post [ ] } > = ( props ) => {
136
- if ( props . posts === null ) {
137
- return null
138
- } else {
139
- return (
140
- < >
141
- { props . posts . map ( ( _ , i , posts ) => {
142
- const post = posts [ posts . length - i - 1 ]
143
- return < Post key = { post . id } { ...post } />
144
- } ) }
145
- </ >
146
- )
147
- }
148
- }
149
-
150
195
const Post : React . FC < Post > = ( { from_id, content, updated_at } ) => {
151
196
const address = `${ from_id . slice ( 0 , 5 ) } …${ from_id . slice ( - 4 ) } `
152
197
// use wagmi's internal cache for ens names
0 commit comments