From 56337c953b86ffcb5302fbf5ae4df526b770f7ba Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Tue, 23 Feb 2021 16:41:10 +1000 Subject: [PATCH] VirtualScroller: change virtualisation method We now virtualize 'treadmill' style i.e. by rendering a subset of the list into a window. When the scroll position gets close to an edge, we save our scroll position, adjust the subset and then restore our scroll position --- .../src/views/apps/chat/ChatResource.tsx | 2 +- .../apps/chat/components/ChatMessage.tsx | 31 +- .../views/apps/chat/components/ChatWindow.tsx | 36 +- .../src/views/components/RemoteContent.tsx | 31 +- .../src/views/components/VirtualScroller.tsx | 434 ++++++++++-------- 5 files changed, 308 insertions(+), 226 deletions(-) diff --git a/pkg/interface/src/views/apps/chat/ChatResource.tsx b/pkg/interface/src/views/apps/chat/ChatResource.tsx index f5e133e21..b49a04627 100644 --- a/pkg/interface/src/views/apps/chat/ChatResource.tsx +++ b/pkg/interface/src/views/apps/chat/ChatResource.tsx @@ -38,7 +38,7 @@ export function ChatResource(props: ChatResourceProps) { const canWrite = isWriter(group, station); useEffect(() => { - const count = Math.min(50, unreadCount + 15); + const count = 100; props.api.graph.getNewest(owner, name, count); }, [station]); diff --git a/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx b/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx index 5cf286f5a..80e8a76b9 100644 --- a/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx +++ b/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx @@ -78,7 +78,6 @@ export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => ( )); interface ChatMessageProps { - measure(element): void; msg: Post; previousMsg?: Post; nextMsg?: Post; @@ -96,9 +95,14 @@ interface ChatMessageProps { api: GlobalApi; highlighted?: boolean; renderSigil?: boolean; + innerRef: (el: HTMLDivElement | null) => void; + shiftLayout: { + save: () => void; + restore: () => void; + } } -export default class ChatMessage extends Component { +class ChatMessage extends Component { private divRef: React.RefObject; constructor(props) { @@ -107,9 +111,6 @@ export default class ChatMessage extends Component { } componentDidMount() { - if (this.divRef.current) { - this.props.measure(this.divRef.current); - } } render() { @@ -124,7 +125,6 @@ export default class ChatMessage extends Component { className = '', isPending, style, - measure, scrollWindow, isLastMessage, unreadMarkerRef, @@ -132,6 +132,7 @@ export default class ChatMessage extends Component { api, highlighted, fontSize, + shiftLayout, groups, associations } = this.props; @@ -157,9 +158,6 @@ export default class ChatMessage extends Component { .unix(msg['time-sent'] / 1000) .format(renderSigil ? 'h:mm A' : 'h:mm'); - const reboundMeasure = (event) => { - return measure(this.divRef.current); - }; const messageProps = { msg, @@ -167,7 +165,6 @@ export default class ChatMessage extends Component { contacts, association, group, - measure: reboundMeasure.bind(this), style, containerClass, isPending, @@ -177,7 +174,8 @@ export default class ChatMessage extends Component { highlighted, fontSize, associations, - groups + groups, + shiftLayout }; const unreadContainerStyle = { @@ -186,7 +184,7 @@ export default class ChatMessage extends Component { return ( { } } +export default React.forwardRef((props, ref) => ); + export const MessageAuthor = ({ timestamp, contacts, msg, - measure, group, api, associations, @@ -367,8 +366,8 @@ export const Message = ({ timestamp, contacts, msg, - measure, group, + shiftLayout, api, associations, groups, @@ -401,7 +400,7 @@ export const Message = ({ { @@ -145,7 +144,7 @@ export default class ChatWindow extends Component< this.scrollToUnread(); } this.prevSize = graph.size; - this.virtualList?.calculateVisibleItems(); + //this.virtualList?.calculateVisibleItems(); this.stayLockedIfActive(); } @@ -168,7 +167,7 @@ export default class ChatWindow extends Component< return; } - this.virtualList?.scrollToData(unreadIndex); + //this.virtualList?.scrollToData(unreadIndex); } dismissUnread() { @@ -179,18 +178,18 @@ export default class ChatWindow extends Component< this.props.api.hark.markCountAsRead(association, '/', 'mention'); } - async fetchMessages(newer: boolean, force = false): Promise { + async fetchMessages(newer: boolean): Promise { const { api, station, graph } = this.props; - - if (this.state.fetchPending && !force) { - return new Promise((resolve, reject) => {}); + if(this.state.fetchPending) { + return false; } + this.setState({ fetchPending: true }); const [, , ship, name] = station.split('/'); const currSize = graph.size; - if (newer && !this.loadedNewest) { + if (newer) { const [index] = graph.peekLargest()!; await api.graph.getYoungerSiblings( ship, @@ -198,20 +197,14 @@ export default class ChatWindow extends Component< 20, `/${index.toString()}` ); - if (currSize === graph.size) { - console.log('loaded all newest'); - this.loadedNewest = true; - } - } else if (!newer && !this.loadedOldest) { + } else { const [index] = graph.peekSmallest()!; await api.graph.getOlderSiblings(ship, name, 20, `/${index.toString()}`); this.calculateUnreadIndex(); - if (currSize === graph.size) { - console.log('loaded all oldest'); - this.loadedOldest = true; - } } this.setState({ fetchPending: false }); + console.log(currSize, graph.size); + return currSize === graph.size; } onScroll({ scrollTop, scrollHeight, windowHeight }) { @@ -293,7 +286,8 @@ export default class ChatWindow extends Component< onScroll={this.onScroll.bind(this)} data={graph} size={graph.size} - renderer={({ index, measure, scrollWindow }) => { + id={association.resource} + renderer={({ index, shiftLayout, measure, scrollWindow, ref }) => { const msg = graph.get(index)?.post; if (!msg) return null; if (!this.state.initialized) { @@ -316,12 +310,14 @@ export default class ChatWindow extends Component< const isLastRead: boolean = this.state.unreadIndex.eq(index); const props = { measure, + ref, highlighted, scrollWindow, isPending, isLastRead, isLastMessage, msg, + shiftLayout, ...messageProps }; return ( @@ -333,9 +329,7 @@ export default class ChatWindow extends Component< /> ); }} - loadRows={(newer) => { - this.fetchMessages(newer); - }} + loadRows={this.fetchMessages.bind(this)} /> ); diff --git a/pkg/interface/src/views/components/RemoteContent.tsx b/pkg/interface/src/views/components/RemoteContent.tsx index af26d032b..14835a1ae 100644 --- a/pkg/interface/src/views/components/RemoteContent.tsx +++ b/pkg/interface/src/views/components/RemoteContent.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent, Fragment } from 'react'; +import React, { Component, Fragment } from 'react'; import { BaseAnchor, BaseImage, Box, Button, Text } from '@tlon/indigo-react'; import { hasProvider } from 'oembed-parser'; import EmbedContainer from 'react-oembed-container'; @@ -18,6 +18,10 @@ interface RemoteContentProps { textProps?: any; style?: any; onLoad?(): void; + shiftLayout: { + save: () => void; + restore: () => void; + } } interface RemoteContentState { @@ -30,9 +34,10 @@ const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i); const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i); const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i); -class RemoteContent extends PureComponent { +class RemoteContent extends Component { private fetchController: AbortController | undefined; containerRef: HTMLDivElement | null = null; + private saving = true; constructor(props) { super(props); this.state = { @@ -56,8 +61,23 @@ class RemoteContent extends PureComponent { + window.requestAnimationFrame(() => { + this.props.shiftLayout.restore(); + }); + } loadOembed() { @@ -107,9 +127,9 @@ return; oembedProps = {}, textProps = {}, style = {}, - onLoad = () => {}, ...props } = this.props; + const { onLoad } = this; const { noCors } = this.state; const isImage = IMAGE_REGEX.test(url); const isAudio = AUDIO_REGEX.test(url); @@ -193,13 +213,14 @@ return; className='embed-container' style={style} flexShrink={0} - onLoad={onLoad} + onLoad={this.onLoad} {...oembedProps} {...props} > {this.state.embed && this.state.embed.html && this.state.unfold ?
{ + this.onLoad(); this.containerRef = el; }} dangerouslySetInnerHTML={{ __html: this.state.embed.html }} diff --git a/pkg/interface/src/views/components/VirtualScroller.tsx b/pkg/interface/src/views/components/VirtualScroller.tsx index 8b40a42cc..548a25afc 100644 --- a/pkg/interface/src/views/components/VirtualScroller.tsx +++ b/pkg/interface/src/views/components/VirtualScroller.tsx @@ -8,47 +8,83 @@ import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap'; interface RendererProps { index: BigInteger; - measure: (el: any) => void; - scrollWindow: any + shiftLayout: { + save: () => void; + restore: () => void; + } + scrollWindow: any; + ref: (el: HTMLElement | null) => void; } +const PAGE_DELTA = 20; +const PAGE_SIZE = 60; -interface VirtualScrollerProps { +interface VirtualScrollerProps { origin: 'top' | 'bottom'; - loadRows(newer: boolean): void; - data: BigIntOrderedMap; + loadRows(newer: boolean): Promise; + data: BigIntOrderedMap; + id: string; renderer: (props: RendererProps) => JSX.Element | null; onStartReached?(): void; onEndReached?(): void; size: number; - onCalculateVisibleItems?(visibleItems: BigIntOrderedMap): void; + totalSize: number; + + onCalculateVisibleItems?(visibleItems: BigIntOrderedMap): void; onScroll?({ scrollTop, scrollHeight, windowHeight }): void; style?: any; } -interface VirtualScrollerState { +interface VirtualScrollerState { startgap: number | undefined; - visibleItems: BigIntOrderedMap; + visibleItems: BigIntOrderedMap; endgap: number | undefined; totalHeight: number; averageHeight: number; - scrollTop: number; } -export default class VirtualScroller extends Component { - private scrollContainer: React.RefObject; - public window: HTMLDivElement | null; - private cache: BigIntOrderedMap; - private pendingLoad: { - start: BigInteger; - end: BigInteger - timeout: ReturnType; - } | undefined; +// nb: in this file, an index refers to a BigInteger and an offset refers to a +// number used to index a listified BigIntOrderedMap - overscan = 150; +export default class VirtualScroller extends Component, VirtualScrollerState> { + /** + * A reference to our scroll container + */ + private window: HTMLDivElement | null = null; + /** + * A map of child refs, used to calculate scroll position + */ + private childRefs = new BigIntOrderedMap(); + /** + * If saving, the bottommost visible element that we pin our scroll to + */ + private savedIndex: BigInteger | null = null; + /** + * If saving, the distance between the top of `this.savedEl` and the bottom + * of the screen + */ + private savedDistance = 0; - OVERSCAN_SIZE = 100; // Minimum number of messages on either side before loadRows is called + /** + * If saving, the number of requested saves. If several images are loading + * at once, we save the scroll pos the first time we see it and restore + * once the number of requested saves is zero + */ + private saveDepth = 0; - constructor(props: VirtualScrollerProps) { + private isUpdating = false; + + private scrollLocked = true; + + private dataEdges: [BigInteger, BigInteger] = [bigInt.zero, bigInt.zero]; + + private loaded = { + top: false, + bottom: false + }; + + private dirtied : 'top' | 'bottom' | null = null; + + constructor(props: VirtualScrollerProps) { super(props); this.state = { startgap: props.origin === 'top' ? 0 : undefined, @@ -56,137 +92,92 @@ export default class VirtualScroller extends Component this.restore(); + window.save = () => this.save(); } componentDidMount() { - this.calculateVisibleItems(); - - this.recalculateTotalHeight(); + this.updateDataEdges(); + this.updateVisible(0); } - componentDidUpdate(prevProps: VirtualScrollerProps, prevState: VirtualScrollerState) { - const { - scrollContainer, window, - props: { origin }, - state: { totalHeight, scrollTop } - } = this; - } - - scrollToData(targetIndex: BigInteger): Promise { - if (!this.window) { - return new Promise((resolve, reject) => { - reject(); -}); - } - const { offsetHeight } = this.window; - let scrollTop = 0; - let itemHeight = 0; - new BigIntOrderedMap([...this.props.data].reverse()).forEach((datum, index) => { - const height = this.heightOf(index); - if (index.geq(targetIndex)) { - scrollTop += height; - if (index.eq(targetIndex)) { - itemHeight = height; - } + componentDidUpdate(prevProps: VirtualScrollerProps, _prevState: VirtualScrollerState) { + const { id, size, data } = this.props; + const { visibleItems } = this.state; + if(id !== prevProps.id) { + this.resetScroll(); + this.updateVisible(0); + } else if(size !== prevProps.size) { + const index = visibleItems.peekSmallest()?.[0]!; + const newOffset = [...data].findIndex(([i]) => i.eq(index)); + + if(this.scrollLocked && this.dataEdges[1].neq(data.peekLargest()?.[0]!)) { + console.log('locking'); + this.updateVisible(0); + return; + } else if(this.scrollLocked || newOffset === -1) { + console.log('resetting'); + this.resetScroll(); } - }); - return this.setScrollTop(scrollTop - (offsetHeight / 2) + itemHeight); - } - - recalculateTotalHeight() { - let { averageHeight } = this.state; - let totalHeight = 0; - this.props.data.forEach((datum, index) => { - totalHeight += Math.max(this.heightOf(index), 0); - }); - averageHeight = Number((totalHeight / this.props.data.size).toFixed()); - totalHeight += (this.props.size - this.props.data.size) * averageHeight; - this.setState({ totalHeight, averageHeight }); - } - - estimateIndexFromScrollTop(targetScrollTop: number): BigInteger | undefined { - if (!this.window) -return undefined; - const index = bigInt(this.props.size); - const { averageHeight } = this.state; - let height = 0; - while (height < targetScrollTop) { - const itemHeight = this.cache.has(index) ? this.cache.get(index).height : averageHeight; - height += itemHeight; - index.subtract(bigInt.one); + this.updateDataEdges(); } - return index; } - heightOf(index: BigInteger): number { - return this.cache.has(index) ? this.cache.get(index).height : this.state.averageHeight; + componentWillUnmount() { + window.removeEventListener('keydown', this.invertedKeyHandler); } - calculateVisibleItems() { - if (!this.window) -return; - let startgap = 0, heightShown = 0, endgap = 0; - let startGapFilled = false; - const visibleItems = new BigIntOrderedMap(); - const { scrollTop, offsetHeight: windowHeight } = this.window; - const { averageHeight, totalHeight } = this.state; - const { data, size: totalSize, onCalculateVisibleItems } = this.props; + updateDataEdges() { + const { data } = this.props; + const small = data.peekSmallest()?.[0]!; + const large = data.peekLargest()?.[0]!; - [...data].forEach(([index, datum]) => { - const height = this.heightOf(index); - if (startgap < (scrollTop - this.overscan) && !startGapFilled) { - startgap += height; - } else if (heightShown < (windowHeight + this.overscan)) { - startGapFilled = true; - visibleItems.set(index, datum); - heightShown += height; - } - }); + this.dataEdges = [small, large]; + } - endgap = totalHeight - heightShown - startgap; - - const firstVisibleKey = visibleItems.peekSmallest()?.[0] ?? this.estimateIndexFromScrollTop(scrollTop)!; - const smallest = data.peekSmallest(); - if (smallest && smallest[0].eq(firstVisibleKey)) { - this.loadRows(false); + startOffset() { + const startIndex = this.state.visibleItems.peekLargest()?.[0]!; + const offset = [...this.props.data].findIndex(([i]) => i.eq(startIndex)) + if(offset === -1) { + throw new Error("a"); } - const lastVisibleKey = - visibleItems.peekLargest()?.[0] - ?? bigInt(this.estimateIndexFromScrollTop(scrollTop + windowHeight)!); + return offset; + } - const largest = data.peekLargest(); - - if (largest && largest[0].eq(lastVisibleKey)) { - this.loadRows(true); + /** + * Updates the `startOffset` and adjusts visible items accordingly. + * Saves the scroll positions before repainting and restores it afterwards + */ + updateVisible(newOffset: number) { + if (!this.window || this.isUpdating) { + return; } + this.isUpdating = true; + + const { data, onCalculateVisibleItems } = this.props; + const visibleItems = new BigIntOrderedMap( + [...data].slice(newOffset, newOffset + PAGE_SIZE) + ); + + this.save(); + onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null; this.setState({ - startgap: Number(startgap.toFixed()), visibleItems, - endgap: Number(endgap.toFixed()) + }, () => { + requestAnimationFrame(() => { + this.restore(); + this.isUpdating = false; + }); }); } - loadRows(newer: boolean) { - this.props.loadRows(newer); - } - scrollKeyMap(): Map { return new Map([ ['ArrowUp', this.state.averageHeight], @@ -213,13 +204,9 @@ return; } } - componentWillUnmount() { - window.removeEventListener('keydown', this.invertedKeyHandler); - } - setWindow(element) { if (!element) -return; + return; if (this.window) { if (this.window.isSameNode(element)) { return; @@ -228,8 +215,6 @@ return; } } - this.overscan = Math.max(element.offsetHeight * 3, 500); - this.window = element; if (this.props.origin === 'bottom') { element.addEventListener('wheel', (event) => { @@ -244,48 +229,145 @@ return; this.resetScroll(); } - resetScroll(): Promise { - if (!this.window) -return new Promise((resolve, reject) => { - reject(); -}); - return this.setScrollTop(0); + resetScroll() { + if (!this.window) { + return; + } + this.window.scrollTop = 0; + this.savedIndex = null; + this.savedDistance = 0; + this.saveDepth = 0; } - setScrollTop(distance: number, delay = 100): Promise { - return new Promise((resolve, reject) => { - setTimeout(() => { - if (!this.window) { - reject(); - return; - } - this.window.scrollTop = distance; - resolve(); - }, delay); - }); + async loadRows(newer: boolean) { + const dir = newer ? 'bottom' : 'top'; + if(this.loaded[dir]) { + return; + } + const done = await this.props.loadRows(newer); + if(done) { + this.dirtied = dir; + this.loaded[dir] = true; + } } - onScroll(event) { - if (!this.window) -return; - const { onStartReached, onEndReached, onScroll } = this.props; + onScroll(event: UIEvent) { + if(!this.window || this.savedIndex) { + // bail if we're going to adjust scroll anyway + return; + } + + const { onStartReached, onEndReached } = this.props; const windowHeight = this.window.offsetHeight; const { scrollTop, scrollHeight } = this.window; - if (scrollTop !== scrollHeight) { - this.setState({ scrollTop }); - } - this.calculateVisibleItems(); - onScroll ? onScroll({ scrollTop, scrollHeight, windowHeight }) : null; - if (scrollTop === 0) { - if (onStartReached) -onStartReached(); - } else if (scrollTop + windowHeight >= scrollHeight) { - if (onEndReached) -onEndReached(); + const startOffset = this.startOffset(); + if (scrollTop < 20) { + if (onStartReached) { + onStartReached(); + } + const newOffset = Math.max(0, startOffset - PAGE_DELTA); + if(newOffset < 10) { + setTimeout(() => this.loadRows(true)); + } + + if(newOffset === 0) { + this.scrollLocked = true; + } + if(newOffset !== startOffset) { + this.updateVisible(newOffset); + } + } + else if (scrollTop + windowHeight >= scrollHeight - 100) { + if (onEndReached) { + onEndReached(); + } + + const newOffset = Math.min(startOffset + PAGE_DELTA, this.props.data.size - PAGE_SIZE); + if((newOffset + 10 < this.props.data.size - PAGE_SIZE)) { + setTimeout(() => this.loadRows(false)); + } + + if(newOffset !== startOffset) { + this.updateVisible(newOffset); + } + } else { + this.scrollLocked = false; } } + restore() { + if(!this.window || !this.savedIndex) { + return; + } + this.saveDepth--; + if(this.saveDepth !== 0) { + //console.log('multiple restores'); + return; + } + + const { offsetTop } = this.childRefs.get(this.savedIndex)!; + const newScrollTop = this.window.scrollHeight - offsetTop - this.savedDistance; + + this.window.scrollTop = newScrollTop; + this.savedIndex = null; + this.savedDistance = 0; + } + + save() { + if(!this.window || this.savedIndex) { + return; + } + this.saveDepth++; + if(this.saveDepth !== 1) { + //console.log(new Error().stack); + //console.log('multiple saves'); + return; + } + + let bottomIndex: BigInteger | null = null; + const { scrollTop, scrollHeight } = this.window; + const topSpacing = scrollHeight - scrollTop; + [...Array.from(this.state.visibleItems)].reverse().forEach(([index, datum]) => { + const idxTop = this.childRefs.get(index)!.offsetTop; + if(idxTop < topSpacing) { + bottomIndex = index; + } + }); + + if(!bottomIndex) { + console.log('weird case'); + // weird, shouldn't really happen + return; + } + + this.savedIndex = bottomIndex; + const { offsetTop } = this.childRefs.get(bottomIndex)!; + this.savedDistance = topSpacing - offsetTop + } + + shiftLayout = { save: this.save.bind(this), restore: this.restore.bind(this) }; + + setRef = (index: BigInteger) => (element: HTMLElement | null) => { + if(element) { + this.childRefs.set(index, element); + } else { + this.childRefs.delete(index); + } + } + + renderItem = (index: BigInteger) => { + const ref = this.setRef(index); + return this.props.renderer({ + index, + ref, + shiftLayout: this.shiftLayout, + scrollWindow: this.window, + }); + }; + + + render() { const { startgap, @@ -295,34 +377,20 @@ onEndReached(); const { origin = 'top', - loadRows, renderer, style, - data } = this.props; const indexesToRender = origin === 'top' ? visibleItems.keys() : visibleItems.keys().reverse(); const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)'; - const render = (index: BigInteger) => { - const measure = (element: any) => { - if (element) { - this.cache.set(index, { - height: element.offsetHeight, - element - }); - this.recalculateTotalHeight(); - } - }; - return renderer({ index, measure, scrollWindow: this.window }); - }; - + return ( - - + + - {indexesToRender.map(render)} + {indexesToRender.map(this.renderItem)}