Skip to content

Commit af860ce

Browse files
authored
Infinite scroll: Interleave posts that arrive out-of-order (#145)
* port infinite scroll to chat-webpack * interleave posts that arrive out-of-order
1 parent 2f3022c commit af860ce

File tree

6 files changed

+279
-108
lines changed

6 files changed

+279
-108
lines changed

examples/chat-next/.eslintrc.cjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
module.exports = {
2+
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@next/next/recommended"],
3+
plugins: ["@typescript-eslint"],
4+
parser: "@typescript-eslint/parser",
5+
ignorePatterns: [
6+
"*.d.ts",
7+
"*.cts",
8+
],
9+
settings: {
10+
"import/parsers": {
11+
"@typescript-eslint/parser": [".ts", ".tsx"],
12+
},
13+
},
14+
rules: {
15+
"@typescript-eslint/no-explicit-any": "off",
16+
"@typescript-eslint/no-empty-function": "off",
17+
"@typescript-eslint/no-unused-vars": "off",
18+
"@typescript-eslint/no-non-null-assertion": "off",
19+
"@typescript-eslint/no-empty-interface": "off",
20+
"no-empty-pattern": "off",
21+
"@typescript-eslint/ban-types": [
22+
"error",
23+
{
24+
"extendDefaults": true,
25+
"types": { "{}": false },
26+
},
27+
],
28+
},
29+
};

examples/chat-next/components/Messages.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import _ from "lodash"
21
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
32
import { Virtuoso } from "react-virtuoso"
3+
import _ from "lodash"
44

55
import { useEnsName } from "wagmi"
66
import { Client, useRoute } from "@canvas-js/hooks"
@@ -51,10 +51,39 @@ export const MessagesInfiniteScroller: React.FC<{}> = ({}) => {
5151
filteredPastPosts.reverse()
5252
filteredNewPosts.reverse()
5353

54-
// TODO: Interleave new posts according to updated_at, so if we
55-
// receive new posts out-of-order (happens frequently on first insert)
54+
// Interleave new posts according to updated_at, so if we
55+
// receive new posts out-of-order (happens frequently on batch insert)
5656
// they won't persist out-of-order
57-
setPosts([...filteredPastPosts, ...posts, ...filteredNewPosts])
57+
let result
58+
if (
59+
// check if all posts are ordered
60+
(filteredPastPosts.length === 0 && posts.length === 0) ||
61+
(posts.length === 0 && filteredNewPosts.length === 0)
62+
) {
63+
result = [...filteredPastPosts, ...posts, ...filteredNewPosts]
64+
} else {
65+
// check if past + present posts are ordered
66+
if (
67+
filteredPastPosts.length === 0 ||
68+
posts.length === 0 ||
69+
filteredPastPosts[filteredPastPosts.length - 1].updated_at < posts[0].updated_at
70+
) {
71+
result = [...filteredPastPosts, ...posts]
72+
} else {
73+
result = _.sortedUniqBy(_.sortBy([...filteredPastPosts, ...posts], "updated_at"), "updated_at")
74+
}
75+
// check if present + new posts are ordered
76+
if (
77+
result.length === 0 ||
78+
filteredNewPosts.length === 0 ||
79+
result[result.length - 1].updated_at < filteredNewPosts[0].updated_at
80+
) {
81+
result = [...result, ...filteredNewPosts]
82+
} else {
83+
result = _.sortedUniqBy(_.sortBy([...result, ...filteredNewPosts], "updated_at"), "updated_at")
84+
}
85+
}
86+
setPosts(result)
5887

5988
// Scroll-to-bottom doesn't seem to work correctly, Virtuoso incorrectly
6089
// caps the maximum scroll to `scroller.offsetHeight - scroller.scrollHeight`

examples/chat-next/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"98.css": "^0.1.18",
2222
"ethers": "^5.7.1 <6",
2323
"http-status-codes": "^2.2.0",
24+
"lodash": "^4.17.21",
2425
"next": "^13.1.1",
2526
"react": "^18.2.0",
2627
"react-dom": "^18.2.0",
@@ -29,6 +30,8 @@
2930
"wagmi": "^0.11.5"
3031
},
3132
"devDependencies": {
33+
"@next/eslint-plugin-next": "^13.2.1",
34+
"@types/lodash": "^4.14.191",
3235
"@types/use-sync-external-store": "^0.0.3"
3336
}
3437
}

examples/chat-webpack/src/Messages.tsx

Lines changed: 141 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, useMemo, useContext } from "react"
2+
import { Virtuoso } from "react-virtuoso"
23
import _ from "lodash"
34

45
import { useEnsName } from "wagmi"
5-
import { useRoute } from "@canvas-js/hooks"
6-
6+
import { Client, useRoute } from "@canvas-js/hooks"
77
import { AppContext } from "./AppContext"
88

99
type Post = {
@@ -13,6 +13,135 @@ type Post = {
1313
updated_at: number
1414
}
1515

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+
16145
export const Messages: React.FC = ({}) => {
17146
const inputRef = useRef<HTMLInputElement>(null)
18147
const { client } = useContext(AppContext)
@@ -44,109 +173,25 @@ export const Messages: React.FC = ({}) => {
44173
}
45174
}, [isReady])
46175

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-
104176
return (
105177
<div id="messages" className="window">
106178
<div className="title-bar">
107179
<div className="title-bar-text">Messages</div>
108180
</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>
131191
</div>
132192
)
133193
}
134194

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-
150195
const Post: React.FC<Post> = ({ from_id, content, updated_at }) => {
151196
const address = `${from_id.slice(0, 5)}${from_id.slice(-4)}`
152197
// use wagmi's internal cache for ens names

examples/chat-webpack/src/styles.css

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,25 +36,27 @@ body {
3636
gap: 4px;
3737
}
3838

39-
#scroll-container {
40-
overflow-y: scroll;
39+
#messages ul.tree-view {
4140
flex-grow: 1;
4241
height: 0px;
42+
padding: 2px;
4343

4444
display: flex;
4545
flex-direction: column;
4646
}
4747

48-
#scroll-container ul.tree-view {
49-
flex-grow: 1;
48+
#messages ul.tree-view li {
49+
margin: 0;
50+
padding-top: 3px;
51+
padding-left: 6px;
5052
}
5153

5254
#sidebar {
5355
margin: 12px 0px;
5456
display: flex;
5557
flex-direction: column;
5658
gap: 12px;
57-
width: 420px;
59+
flex-grow: 0;
5860
}
5961

6062
span.address {

0 commit comments

Comments
 (0)