Merge pull request #4165 from urbit/lf/infinite-link-scroll

links-fe: infinite scroll for link displays
This commit is contained in:
matildepark 2021-01-20 18:18:20 -05:00 committed by GitHub
commit 1cdd3509b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 155 additions and 62 deletions

View File

@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useCallback } from "react";
import { Box, Row, Col, Center, LoadingSpinner, Text } from "@tlon/indigo-react";
import { Switch, Route, Link } from "react-router-dom";
import bigInt from 'big-integer';
@ -10,10 +10,14 @@ import { RouteComponentProps } from "react-router-dom";
import { LinkItem } from "./components/LinkItem";
import LinkSubmit from "./components/LinkSubmit";
import { LinkPreview } from "./components/link-preview";
import { LinkWindow } from "./LinkWindow";
import { Comments } from "~/views/components/Comments";
import "./css/custom.css";
const emptyMeasure = () => {};
type LinkResourceProps = StoreState & {
association: Association;
api: GlobalApi;
@ -57,30 +61,22 @@ export function LinkResource(props: LinkResourceProps) {
return <Center width='100%' height='100%'><LoadingSpinner/></Center>;
}
return (
<Col alignItems="center" height="100%" width="100%" overflowY="auto">
<Col alignItems="center" height="100%" width="100%" overflowY="hidden">
<Switch>
<Route
exact
path={relativePath("")}
render={(props) => {
return (
<Col width="100%" p={4} alignItems="center" maxWidth="768px">
<Col width="100%" flexShrink='0'>
<LinkSubmit s3={s3} name={name} ship={ship.slice(1)} api={api} />
</Col>
{Array.from(graph).map(([date, node]) => {
const contact = contactDetails[node.post.author];
return (
<LinkItem
<LinkWindow
s3={s3}
association={resource}
contacts={contacts}
key={date.toString()}
resource={resourcePath}
node={node}
contacts={contactDetails}
graph={graph}
unreads={unreads}
nickname={contact?.nickname}
baseUrl={resourceUrl}
group={group}
path={resource["group-path"]}
@ -88,9 +84,6 @@ export function LinkResource(props: LinkResourceProps) {
mb={3}
/>
);
})}
</Col>
);
}}
/>
<Route
@ -112,6 +105,7 @@ export function LinkResource(props: LinkResourceProps) {
const contact = contactDetails[node.post.author];
return (
<Col alignItems="center" overflowY="auto" width="100%">
<Col width="100%" p={3} maxWidth="768px">
<Link to={resourceUrl}><Text bold>{"<- Back"}</Text></Link>
<LinkItem
@ -125,6 +119,7 @@ export function LinkResource(props: LinkResourceProps) {
path={resource["group-path"]}
api={api}
mt={3}
measure={emptyMeasure}
/>
<Comments
ship={ship}
@ -141,6 +136,7 @@ export function LinkResource(props: LinkResourceProps) {
group={group}
/>
</Col>
</Col>
);
}}
/>

View File

@ -0,0 +1,95 @@
import React, { useRef, useCallback, useEffect, useMemo } from "react";
import { Col } from "@tlon/indigo-react";
import bigInt from 'big-integer';
import {
Association,
Graph,
Contacts,
Unreads,
LocalUpdateRemoteContentPolicy,
Group,
Rolodex,
S3State,
} from "~/types";
import GlobalApi from "~/logic/api/global";
import VirtualScroller from "~/views/components/VirtualScroller";
import { LinkItem } from "./components/LinkItem";
import LinkSubmit from "./components/LinkSubmit";
interface LinkWindowProps {
association: Association;
contacts: Rolodex;
resource: string;
graph: Graph;
unreads: Unreads;
hideNicknames: boolean;
hideAvatars: boolean;
baseUrl: string;
group: Group;
path: string;
api: GlobalApi;
s3: S3State;
}
export function LinkWindow(props: LinkWindowProps) {
const { graph, api, association } = props;
const loadedNewest = useRef(true);
const loadedOldest = useRef(false);
const virtualList = useRef<VirtualScroller>();
const fetchLinks = useCallback(
async (newer: boolean) => {
/* stubbed, should we generalize the display of graphs in virtualscroller? */
}, []
);
useEffect(() => {
const list = virtualList?.current;
if(!list) return;
list.calculateVisibleItems();
}, [graph.size]);
const first = graph.peekLargest()?.[0];
const [,,ship, name] = association['app-path'].split('/');
const style = useMemo(() =>
({
height: "100%",
width: "100%",
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}), []);
return (
<VirtualScroller
ref={(l) => (virtualList.current = l ?? undefined)}
origin="top"
style={style}
onStartReached={() => {}}
onScroll={() => {}}
data={graph}
size={graph.size}
renderer={({ index, measure, scrollWindow }) => {
const node = graph.get(index);
const post = node?.post;
if (!node || !post) return null;
const linkProps = {
...props,
node,
measure,
key: index.toString()
};
if(index.eq(first ?? bigInt.zero)) {
return (
<Col key={index.toString()} mx="auto" mt="4" maxWidth="768px" width="100%" flexShrink='0'>
<LinkSubmit s3={props.s3} name={name} ship={ship.slice(1)} api={api} />
<LinkItem {...linkProps} />
</Col>
)
}
return <LinkItem {...linkProps} />;
}}
loadRows={fetchLinks}
/>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Row, Col, Anchor, Box, Text, Icon, Action } from '@tlon/indigo-react';
@ -17,8 +17,9 @@ interface LinkItemProps {
api: GlobalApi;
group: Group;
path: string;
contacts: Rolodex[];
contacts: Rolodex;
unreads: Unreads;
measure: (el: any) => void;
}
export const LinkItem = (props: LinkItemProps) => {
@ -29,9 +30,12 @@ export const LinkItem = (props: LinkItemProps) => {
group,
path,
contacts,
measure,
...rest
} = props;
const ref = useRef<HTMLDivElement | null>(null);
const URLparser = new RegExp(
/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/
);
@ -70,9 +74,18 @@ export const LinkItem = (props: LinkItemProps) => {
const markRead = () => {
api.hark.markEachAsRead(props.association, '/', `/${index}`, 'link', 'link');
}
return (
<Box width="100%" {...rest}>
const onMeasure = useCallback(() => {
ref.current && measure(ref.current);
}, [ref.current, measure])
useEffect(() => {
onMeasure();
}, [onMeasure]);
return (
<Box mx="auto" maxWidth="768px" ref={ref} width="100%" {...rest}>
<Box
lineHeight="tall"
display='flex'
@ -90,6 +103,7 @@ export const LinkItem = (props: LinkItemProps) => {
url={contents[1].url}
text={contents[0].text}
unfold={true}
onLoad={onMeasure}
style={{ alignSelf: 'center' }}
oembedProps={{
p: 2,

View File

@ -44,6 +44,8 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
timeout: ReturnType<typeof setTimeout>;
} | undefined;
overscan = 150;
OVERSCAN_SIZE = 100; // Minimum number of messages on either side before loadRows is called
constructor(props: VirtualScrollerProps) {
@ -53,7 +55,7 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
visibleItems: new BigIntOrderedMap(),
endgap: props.origin === 'bottom' ? 0 : undefined,
totalHeight: 0,
averageHeight: 64,
averageHeight: 130,
scrollTop: props.origin === 'top' ? 0 : undefined
};
@ -61,8 +63,8 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
this.window = null;
this.cache = new BigIntOrderedMap();
this.recalculateTotalHeight = this.recalculateTotalHeight.bind(this);
this.calculateVisibleItems = this.calculateVisibleItems.bind(this);
this.recalculateTotalHeight = _.throttle(this.recalculateTotalHeight.bind(this), 200);
this.calculateVisibleItems = _.throttle(this.calculateVisibleItems.bind(this), 200);
this.estimateIndexFromScrollTop = this.estimateIndexFromScrollTop.bind(this);
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
this.heightOf = this.heightOf.bind(this);
@ -74,6 +76,8 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
componentDidMount() {
this.calculateVisibleItems();
this.recalculateTotalHeight();
}
componentDidUpdate(prevProps: VirtualScrollerProps, prevState: VirtualScrollerState) {
@ -107,7 +111,7 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
let { averageHeight } = this.state;
let totalHeight = 0;
this.props.data.forEach((datum, index) => {
totalHeight += this.heightOf(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;
@ -136,41 +140,23 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
let startgap = 0, heightShown = 0, endgap = 0;
let startGapFilled = false;
let visibleItems = new BigIntOrderedMap<any>();
let startBuffer = new BigIntOrderedMap<any>();
let endBuffer = new BigIntOrderedMap<any>();
const { scrollTop, offsetHeight: windowHeight } = this.window;
const { averageHeight } = this.state;
const { averageHeight, totalHeight } = this.state;
const { data, size: totalSize, onCalculateVisibleItems } = this.props;
console.log(windowHeight);
const overscan = Math.max(windowHeight / 2, 200);
[...data].forEach(([index, datum]) => {
const height = this.heightOf(index);
if (startgap < (scrollTop - overscan) && !startGapFilled) {
startBuffer.set(index, datum);
if (startgap < (scrollTop - this.overscan) && !startGapFilled) {
startgap += height;
} else if (heightShown < (windowHeight + overscan)) {
} else if (heightShown < (windowHeight + this.overscan)) {
startGapFilled = true;
visibleItems.set(index, datum);
heightShown += height;
} else if (endBuffer.size < visibleItems.size) {
endBuffer.set(index, data.get(index));
} else {
endgap += height;
}
});
startBuffer = new BigIntOrderedMap(
[...startBuffer].reverse().slice(0, (visibleItems.size - visibleItems.size % 5))
);
startBuffer.forEach((_datum, index) => {
startgap -= this.heightOf(index);
});
endgap = totalHeight - heightShown - startgap;
const firstVisibleKey = visibleItems.peekSmallest()?.[0] ?? this.estimateIndexFromScrollTop(scrollTop)!;
const smallest = data.peekSmallest();
@ -189,7 +175,7 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null;
this.setState({
startgap: Number(startgap.toFixed()),
visibleItems: new BigIntOrderedMap([...startBuffer, ...visibleItems, ...endBuffer]),
visibleItems,
endgap: Number(endgap.toFixed()),
});
}
@ -238,6 +224,8 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
}
}
this.overscan = Math.max(element.offsetHeight * 3, 500);
this.window = element;
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
@ -303,7 +291,7 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
data
} = this.props;
const indexesToRender = visibleItems.keys().reverse();
const indexesToRender = origin === 'top' ? visibleItems.keys() : visibleItems.keys().reverse();
const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
@ -314,7 +302,7 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
height: element.offsetHeight,
element
});
_.debounce(this.recalculateTotalHeight, 500)();
this.recalculateTotalHeight();
}
};
return renderer({ index, measure, scrollWindow: this.window });
@ -322,7 +310,7 @@ export default class VirtualScroller extends Component<VirtualScrollerProps, Vir
return (
<Box overflowY='scroll' ref={this.setWindow.bind(this)} onScroll={this.onScroll.bind(this)} style={{ ...style, ...{ transform } }}>
<Box ref={this.scrollContainer} style={{ transform }}>
<Box ref={this.scrollContainer} style={{ transform, width: '100%' }}>
<Box style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></Box>
{indexesToRender.map(render)}
<Box style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></Box>