interface: flat timeline first pass

This commit is contained in:
Logan Allen 2021-05-13 12:25:01 -05:00
parent dbb58bd00d
commit 1e20f52050
13 changed files with 1457 additions and 45 deletions

View File

@ -348,7 +348,7 @@ export default class GraphApi extends BaseApi<StoreState> {
}
async getDeepNewest(ship: string, resource: string, startTime = null, count: number) {
const start = startTime ? decToUd(startTime) : null;
const start = startTime ? decToUd(startTime) : 'null';
const data = await this.scry<any>('graph-store',
`/deep-nodes-up-to/${ship}/${resource}/${count}/${start}`
);

View File

@ -26,9 +26,8 @@ export const GraphReducer = (json) => {
const flat = _.get(json, 'graph-update-flat', false);
if (flat) {
reduceState<GraphState, any>(useGraphState, loose, [addNodesFlat]);
reduceState<GraphState, any>(useGraphState, flat, [addNodesFlat]);
}
};
const addNodesLoose = (json: any, state: GraphState): GraphState => {
@ -60,7 +59,7 @@ const addNodesFlat = (json: any, state: GraphState): GraphState => {
const indices = Array.from(Object.keys(data.nodes));
indices.forEach((index) => {
const node = data.nodes[index];
let node = data.nodes[index];
if (index.split('/').length === 0) {
return;
}
@ -73,14 +72,11 @@ const addNodesFlat = (json: any, state: GraphState): GraphState => {
return state;
}
node.children = mapifyChildren({});
state.flatGraphs[resource] =
state.flatGraphs[resource].set(
indexArr,
produce(node, (draft) => {
draft.children = mapifyChildren({});
})
);
state.flatGraphs[resource].set(indexArr, node);
});
}
return state;
};
@ -120,8 +116,13 @@ const addGraph = (json, state: GraphState): GraphState => {
state.graphs = {};
}
if (!('flatGraphs' in state)) {
state.flatGraphs = {};
}
const resource = data.resource.ship + '/' + data.resource.name;
state.graphs[resource] = new BigIntOrderedMap();
state.flatGraphs[resource] = new BigIntArrayOrderedMap();
state.graphTimesentMap[resource] = {};
state.graphs[resource] = state.graphs[resource].gas(Object.keys(data.graph).map((idx) => {
@ -139,6 +140,10 @@ const removeGraph = (json, state: GraphState): GraphState => {
if (!('graphs' in state)) {
state.graphs = {};
}
if (!('graphs' in state)) {
state.flatGraphs = {};
}
const resource = data.ship + '/' + data.name;
state.graphKeys.delete(resource);
delete state.graphs[resource];
@ -219,14 +224,18 @@ const addNodes = (json, state) => {
const data = _.get(json, 'add-nodes', false);
if (data) {
if (!('graphs' in state)) {
return state;
}
return state;
}
const resource = data.resource.ship + '/' + data.resource.name;
if (!(resource in state.graphs)) {
state.graphs[resource] = new BigIntOrderedMap();
}
if (!(resource in state.flatGraphs)) {
state.flatGraphs[resource] = new BigIntArrayOrderedMap();
}
if (!(resource in state.graphTimesentMap)) {
state.graphTimesentMap[resource] = {};
}

View File

@ -1,4 +1,4 @@
import { Association, deSig, GraphNode, Graphs, resourceFromPath } from '@urbit/api';
import { Association, deSig, GraphNode, Graphs, FlatGraphs, resourceFromPath } from '@urbit/api';
import { useCallback } from 'react';
import { BaseState, createState } from './base';
@ -27,6 +27,7 @@ export interface GraphState extends BaseState<GraphState> {
const useGraphState = createState<GraphState>('Graph', {
graphs: {},
flatGraphs: {},
graphKeys: new Set(),
looseNodes: {},
pendingIndices: {},
@ -130,7 +131,7 @@ const useGraphState = createState<GraphState>('Graph', {
// });
// graphReducer(node);
// },
}, ['graphs', 'graphKeys', 'looseNodes', 'graphTimesentMap']);
}, ['graphs', 'graphKeys', 'looseNodes', 'graphTimesentMap', 'flatGraphs']);
export function useGraph(ship: string, name: string) {
return useGraphState(

View File

@ -0,0 +1,635 @@
import { Box, Center, LoadingSpinner } from '@tlon/indigo-react';
import BigIntArrayOrderedMap, {
arrToString,
stringToArr
} from '@urbit/api/lib/BigIntArrayOrderedMap';
import bigInt, { BigInteger } from 'big-integer';
import _ from 'lodash';
import normalizeWheel from 'normalize-wheel';
import React, { Component, SyntheticEvent, useCallback } from 'react';
import styled from 'styled-components';
import { IS_IOS } from '~/logic/lib/platform';
import { VirtualContext } from '~/logic/lib/virtualContext';
const ScrollbarLessBox = styled(Box)`
scrollbar-width: none !important;
::-webkit-scrollbar {
display: none;
}
`;
interface RendererProps {
index: BigInteger[];
scrollWindow: any;
ref: (el: HTMLElement | null) => void;
}
export { arrToString, stringToArr };
export function indexEqual(a: BigInteger[], b: BigInteger[]) {
let aLen = a.length;
let bLen = b.length;
if (aLen === bLen) {
let i = 0;
while (i < aLen && i < bLen) {
if (a[i].eq(b[i])) {
if (i === aLen - 1) {
return true;
}
i++;
} else {
return false;
}
}
}
return false;
}
interface VirtualScrollerProps<T> {
/**
* Start scroll from
*/
origin: 'top' | 'bottom';
/**
* Load more of the graph
*
* @returns boolean whether or not the graph is now fully loaded
*/
loadRows(newer: boolean): Promise<boolean>;
/**
* The data to iterate over
*/
data: BigIntArrayOrderedMap<T>;
/**
* The component to render the items
*
* @remarks
*
* This component must be referentially stable, so either use `useCallback` or
* a instance method. It must also forward the DOM ref from its root DOM node
*/
renderer: (props: RendererProps) => JSX.Element | null;
onStartReached?(): void;
onEndReached?(): void;
size: number;
pendingSize: number;
totalSize: number;
/**
* Average height of a single rendered item
*
* @remarks
* This is used primarily to calculate how many items should be onscreen. If
* size is variable, err on the lower side.
*/
averageHeight: number;
/**
* The offset to begin rendering at, on load.
*
* @remarks
* This is only looked up once, on component creation. Subsequent changes to
* this prop will have no effect
*/
offset: number;
style?: any;
/**
* Callback to execute when finished loading from start
*/
onBottomLoaded?: () => void;
}
interface VirtualScrollerState<T> {
visibleItems: BigInteger[][];
scrollbar: number;
loaded: {
top: boolean;
bottom: boolean;
}
}
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
const logLevel = ['network', 'bail', 'scroll', 'reflow'] as LogLevel[];
const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) {
console.log(`[${level}]: ${message}`);
}
};
const ZONE_SIZE = IS_IOS ? 20 : 80;
// nb: in this file, an index refers to a BigInteger[] and an offset refers to a
// number used to index a listified BigIntArrayOrderedMap
/**
* A virtualscroller for a `BigIntArrayOrderedMap`.
*
* VirtualScroller does not clean up or reset itself, so please use `key`
* to ensure a new instance is created for each BigIntArrayOrderedMap
*/
export default class ArrayVirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState<T>> {
/**
* A reference to our scroll container
*/
window: HTMLDivElement | null = null;
/**
* A map of child refs, used to calculate scroll position
*/
private childRefs = new Map<string, HTMLElement>();
/**
* A set of child refs which have been unmounted
*/
private orphans = new Set<string>();
/**
* 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;
/**
* 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;
scrollLocked = true;
private pageSize = 50;
private pageDelta = 15;
private scrollRef: HTMLElement | null = null;
private cleanupRefInterval: NodeJS.Timeout | null = null;
constructor(props: VirtualScrollerProps<T>) {
super(props);
this.state = {
visibleItems: [],
scrollbar: 0,
loaded: {
top: false,
bottom: false
}
};
this.updateVisible = this.updateVisible.bind(this);
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 200) : this.onScroll.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.setWindow = this.setWindow.bind(this);
this.restore = this.restore.bind(this);
this.startOffset = this.startOffset.bind(this);
}
componentDidMount() {
this.updateVisible(0);
this.loadTop();
this.loadBottom();
this.cleanupRefInterval = setInterval(this.cleanupRefs, 5000);
}
cleanupRefs = () => {
if(this.saveDepth > 0) {
return;
}
[...this.orphans].forEach((o) => {
this.childRefs.delete(o);
});
this.orphans.clear();
};
// manipulate scrollbar manually, to dodge change detection
updateScroll = IS_IOS ? () => {} : _.throttle(() => {
if(!this.window || !this.scrollRef) {
return;
}
const { scrollTop, scrollHeight, offsetHeight } = this.window;
const unloaded = (this.startOffset() / this.pageSize);
const totalpages = this.props.size / this.pageSize;
const loaded = (scrollTop / scrollHeight);
const total = unloaded + loaded;
const result = ((unloaded + loaded) / totalpages) * this.window.offsetHeight;
this.scrollRef.style[this.props.origin] = `${result}px`;
}, 50);
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) {
const { size, data, offset, pendingSize } = this.props;
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
if((this.window?.scrollTop ?? 0) < ZONE_SIZE) {
this.scrollLocked = true;
this.updateVisible(0);
this.resetScroll();
}
}
}
componentWillUnmount() {
window.removeEventListener('keydown', this.invertedKeyHandler);
if(this.cleanupRefInterval) {
clearInterval(this.cleanupRefInterval);
}
this.cleanupRefs();
this.childRefs.clear();
}
startOffset() {
const { data } = this.props;
const startIndex = this.state.visibleItems?.[0];
if(!startIndex) {
return 0;
}
const dataList = Array.from(data);
const offset = dataList.findIndex(([i]) => indexEqual(i, startIndex));
if(offset === -1) {
// TODO: revisit when we remove nodes for any other reason than
// pending indices being removed
return 0;
}
return offset;
}
/**
* Updates the `startOffset` and adjusts visible items accordingly.
* Saves the scroll positions before repainting and restores it afterwards
*/
updateVisible(newOffset: number) {
if (!this.window) {
return;
}
log('reflow', `from: ${this.startOffset()} to: ${newOffset}`);
const { data } = this.props;
const visibleItems = data.keys().slice(newOffset, newOffset + this.pageSize);
this.save();
this.setState({
visibleItems
});
requestAnimationFrame(() => {
this.restore();
});
}
scrollKeyMap(): Map<string, number> {
return new Map([
['ArrowUp', this.props.averageHeight],
['ArrowDown', this.props.averageHeight * -1],
['PageUp', this.window!.offsetHeight],
['PageDown', this.window!.offsetHeight * -1],
['Home', this.window!.scrollHeight],
['End', this.window!.scrollHeight * -1],
['Space', this.window!.offsetHeight * -1]
]);
}
invertedKeyHandler(event): void | false {
const map = this.scrollKeyMap();
if (map.has(event.code) && document.body.isSameNode(document.activeElement)) {
event.preventDefault();
event.stopImmediatePropagation();
let distance = map.get(event.code)!;
if (event.code === 'Space' && event.shiftKey) {
distance = distance * -1;
}
this.window!.scrollBy(0, distance);
return false;
}
}
setWindow(element) {
if (!element)
return;
this.save();
if (this.window) {
if (this.window.isSameNode(element)) {
return;
} else {
window.removeEventListener('keydown', this.invertedKeyHandler);
}
}
const { averageHeight } = this.props;
this.window = element;
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 2));
this.pageDelta = Math.floor(this.pageSize / 4);
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
event.preventDefault();
const normalized = normalizeWheel(event);
element.scrollBy(0, normalized.pixelY * -1);
return false;
}, { passive: false });
window.addEventListener('keydown', this.invertedKeyHandler, { passive: false });
}
this.restore();
}
resetScroll() {
if (!this.window) {
return;
}
this.window.scrollTop = 0;
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
}
loadTop = _.throttle(() => this.loadRows(false), 100);
loadBottom = _.throttle(() => this.loadRows(true), 100);
loadRows = async (newer: boolean) => {
const dir = newer ? 'bottom' : 'top';
if(this.state.loaded[dir]) {
return;
}
log('network', `loading more at ${dir}`);
const done = await this.props.loadRows(newer);
if(done) {
this.setState({
loaded: {
...this.state.loaded,
[dir]: done
}
});
if(newer && this.props.onBottomLoaded) {
this.props.onBottomLoaded();
}
}
};
onScroll(event: SyntheticEvent<HTMLElement>) {
this.updateScroll();
if(!this.window) {
// bail if we're going to adjust scroll anyway
return;
}
if(this.saveDepth > 0) {
log('bail', 'deep scroll queue');
return;
}
const { onStartReached, onEndReached } = this.props;
const windowHeight = this.window.offsetHeight;
const { scrollTop, scrollHeight } = this.window;
const startOffset = this.startOffset();
const scrollEnd = scrollTop + windowHeight;
if (scrollTop < ZONE_SIZE) {
log('scroll', `Entered start zone ${scrollTop}`);
if (startOffset === 0) {
onStartReached && onStartReached();
this.scrollLocked = true;
}
const newOffset = Math.max(0, startOffset - this.pageDelta);
if(newOffset < 10) {
this.loadBottom();
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
} else if (scrollTop + windowHeight >= scrollHeight - ZONE_SIZE) {
this.scrollLocked = false;
log('scroll', `Entered end zone ${scrollTop}`);
const newOffset = Math.min(startOffset + this.pageDelta, this.props.data.size - this.pageSize);
if (onEndReached && startOffset === 0) {
onEndReached();
}
if((newOffset + (3 * this.pageSize) > this.props.data.size)) {
this.loadTop();
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
} else {
this.scrollLocked = false;
}
}
restore() {
if(!this.window || !this.savedIndex) {
return;
}
if(this.saveDepth !== 1) {
log('bail', 'Deep restore');
return;
}
if(this.scrollLocked) {
this.resetScroll();
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
return;
}
const ref = this.childRefs.get(arrToString(this.savedIndex));
if(!ref) {
return;
}
const newScrollTop = this.props.origin === 'top'
? this.savedDistance + ref.offsetTop
: this.window.scrollHeight - ref.offsetTop - this.savedDistance;
this.window.scrollTo(0, newScrollTop);
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
}
scrollToIndex = (index: BigInteger[]) => {
let ref = this.childRefs.get(arrToString(index));
if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => indexEqual(idx, index));
if(offset === -1) {
return;
}
this.scrollLocked = false;
this.updateVisible(Math.max(offset - this.pageDelta, 0));
requestAnimationFrame(() => {
ref = this.childRefs.get(arrToString(index));
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
});
ref?.scrollIntoView({ block: 'center' });
});
} else {
ref?.scrollIntoView({ block: 'center' });
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
});
}
};
save() {
if(!this.window || this.savedIndex) {
return;
}
if(this.saveDepth !== 0) {
return;
}
log('scroll', 'saving...');
this.saveDepth++;
const { visibleItems } = this.state;
let bottomIndex = visibleItems[visibleItems.length - 1];
const { scrollTop, scrollHeight } = this.window;
const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop;
const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse();
items.forEach((index) => {
const el = this.childRefs.get(arrToString(index));
if(!el) {
return;
}
const { offsetTop } = el;
if(offsetTop < topSpacing) {
bottomIndex = index;
}
});
if(!bottomIndex) {
// weird, shouldn't really happen
this.saveDepth--;
log('bail', 'no index found');
return;
}
this.savedIndex = bottomIndex;
const ref = this.childRefs.get(arrToString(bottomIndex))!;
if(!ref) {
this.saveDepth--;
log('bail', 'missing ref');
return;
}
const { offsetTop } = ref;
this.savedDistance = topSpacing - offsetTop;
}
// disabled until we work out race conditions with loading new nodes
shiftLayout = { save: () => {}, restore: () => {} };
setRef = (element: HTMLElement | null, index: BigInteger[]) => {
if(element) {
this.childRefs.set(arrToString(index), element);
this.orphans.delete(arrToString(index));
} else {
this.orphans.add(arrToString(index));
}
}
render() {
const {
visibleItems
} = this.state;
const {
origin = 'top',
renderer,
style
} = this.props;
const isTop = origin === 'top';
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const children = isTop ? visibleItems : [...visibleItems].reverse();
const atStart =
indexEqual(
(this.props.data.peekLargest()?.[0] ?? [bigInt.zero]),
(visibleItems?.[0] || [bigInt.zero])
);
const atEnd =
indexEqual(
(this.props.data.peekSmallest()?.[0] ?? [bigInt.zero]),
(visibleItems?.[visibleItems.length - 1] || [bigInt.zero])
);
return (
<>
{!IS_IOS && (<Box borderRadius={3} top ={isTop ? '0' : undefined}
bottom={!isTop ? '0' : undefined} ref={(el) => {
this.scrollRef = el;
}}
right={0} height="50px"
position="absolute" width="4px"
backgroundColor="lightGray"
/>)}
<ScrollbarLessBox overflowY='scroll' ref={this.setWindow} onScroll={this.onScroll} style={{ ...style, ...{ transform }, 'WebkitOverflowScrolling': 'auto' }}>
<Box style={{ transform, width: 'calc(100% - 4px)' }}>
{(isTop ? !atStart : !atEnd) && (
<Center height={5}>
<LoadingSpinner />
</Center>
)}
<VirtualContext.Provider value={this.shiftLayout}>
{children.map(index => (
<VirtualChild
key={arrToString(index)}
setRef={this.setRef}
index={index}
scrollWindow={this.window}
renderer={renderer}
/>
))}
</VirtualContext.Provider>
{(!isTop ? !atStart : !atEnd) &&
(<Center height={5}>
<LoadingSpinner />
</Center>)}
</Box>
</ScrollbarLessBox>
</>
);
}
}
interface VirtualChildProps {
index: BigInteger[];
scrollWindow: any;
setRef: (el: HTMLElement | null, index: BigInteger[]) => void;
renderer: (p: RendererProps) => JSX.Element | null;
}
function VirtualChild(props: VirtualChildProps) {
const { setRef, renderer: Renderer, ...rest } = props;
const ref = useCallback((el: HTMLElement | null) => {
setRef(el, props.index);
// VirtualChild should always be keyed on the index, so the index should be
// valid for the entire lifecycle of the component, hence no dependencies
}, []);
return <Renderer ref={ref} {...rest} />;
}

View File

@ -414,8 +414,9 @@ export function Graphdown<T extends {} = {}>(
{...nodeRest}
tall={tall}
>
{children.map((c) => (
{children.map((c, idx) => (
<Graphdown
key={idx}
transcluded={transcluded}
depth={depth + 1}
{...rest}

View File

@ -0,0 +1,120 @@
import { Col } from '@tlon/indigo-react';
import React, {
useEffect
} from 'react';
import { Route, Switch, useHistory } from 'react-router-dom';
import { resourceFromPath } from '~/logic/lib/group';
import useGraphState from '~/logic/state/graph';
import useGroupState from '~/logic/state/group';
import useMetadataState from '~/logic/state/metadata';
import { Loading } from '~/views/components/Loading';
import { GroupFeedHeader } from './GroupFeedHeader';
import PostThread from './Post/PostThread';
import PostFlatTimeline from './Post/PostFlatTimeline';
function GroupFlatFeed(props) {
const {
baseUrl,
api,
graphPath,
groupPath,
vip
} = props;
const groups = useGroupState(state => state.groups);
const group = groups[groupPath];
const associations = useMetadataState(state => state.associations);
const flatGraphs = useGraphState(state => state.flatGraphs);
const graphResource =
graphPath ? resourceFromPath(graphPath) : resourceFromPath('/ship/~zod/null');
const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
const pendingSize = Object.keys(
graphTimesentMap[`${graphResource.ship.slice(1)}/${graphResource.name}`] ||
{}
).length;
const relativePath = path => baseUrl + path;
const association = associations.graph[graphPath];
const history = useHistory();
const locationUrl = history.location.pathname;
const graphId = `${graphResource.ship.slice(1)}/${graphResource.name}`;
const flatGraph = flatGraphs[graphId];
useEffect(() => {
// TODO: VirtualScroller should support lower starting values than 100
if (graphResource.ship === '~zod' && graphResource.name === 'null') {
return;
}
api.graph.getDeepNewest(graphResource.ship, graphResource.name, null, 100);
api.hark.markCountAsRead(association, '/', 'post');
}, [graphPath]);
if (!graphPath) {
return <Loading />;
}
return (
<Col
width="100%"
height="100%"
display="flex"
overflow="hidden"
position="relative"
alignItems="center"
>
<GroupFeedHeader
baseUrl={baseUrl}
history={history}
graphs={flatGraphs}
vip={vip}
graphResource={graphResource}
/>
<Switch>
<Route
exact
path={[relativePath('/'), relativePath('/feed')]}
render={(routeProps) => {
return (
<PostFlatTimeline
baseUrl={baseUrl}
api={api}
history={history}
graphPath={graphPath}
group={group}
association={association}
vip={vip}
flatGraph={flatGraph}
pendingSize={pendingSize}
/>
);
}}
/>
<Route
path={relativePath('/feed/:index+')}
render={(routeProps) => {
return (
<PostThread
locationUrl={locationUrl}
baseUrl={baseUrl}
api={api}
history={history}
graphPath={graphPath}
group={group}
association={association}
vip={vip}
pendingSize={pendingSize}
/>
);
}}
/>
</Switch>
</Col>
);
}
export { GroupFlatFeed };

View File

@ -8,6 +8,8 @@ import { AddFeedBanner } from './AddFeedBanner';
import { EmptyGroupHome } from './EmptyGroupHome';
import { EnableGroupFeed } from './EnableGroupFeed';
import { GroupFeed } from './GroupFeed';
import { GroupFlatFeed } from './GroupFlatFeed';
function GroupHome(props) {
const {
@ -61,13 +63,24 @@ function GroupHome(props) {
/>
) : null }
<Route path={`${baseUrl}/feed`}>
<GroupFeed
graphPath={graphPath}
groupPath={groupPath}
vip={graphMetadata?.vip || ''}
api={api}
baseUrl={baseUrl}
/>
{ (graphMetadata?.vip === 'admin-feed') ? (
<GroupFeed
graphPath={graphPath}
groupPath={groupPath}
vip={graphMetadata?.vip || ''}
api={api}
baseUrl={baseUrl}
/>
) : (
<GroupFlatFeed
graphPath={graphPath}
groupPath={groupPath}
vip={graphMetadata?.vip || ''}
api={api}
baseUrl={baseUrl}
/>
)
}
</Route>
<Route path={baseUrl} exact>
<EmptyGroupHome

View File

@ -0,0 +1,235 @@
import { Box, Col } from '@tlon/indigo-react';
import { Association, Graph, GraphNode, Group } from '@urbit/api';
import bigInt from 'big-integer';
import React from 'react';
import { withRouter } from 'react-router';
import GlobalApi from '~/logic/api/global';
import { resourceFromPath } from '~/logic/lib/group';
import ArrayVirtualScroller from '~/views/components/ArrayVirtualScroller';
import PostItem from './PostItem/PostItem';
import PostInput from './PostInput';
const virtualScrollerStyle = {
height: '100%'
};
interface PostFeedProps {
graph: Graph;
graphPath: string;
api: GlobalApi;
history: History;
baseUrl: string;
parentNode?: GraphNode;
grandparentNode?: GraphNode;
association: Association;
group: Group;
vip: string;
pendingSize: number;
}
class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
isFetching: boolean;
constructor(props) {
super(props);
this.isFetching = false;
this.fetchPosts = this.fetchPosts.bind(this);
this.doNotFetch = this.doNotFetch.bind(this);
}
renderItem = React.forwardRef(({ index, scrollWindow }, ref) => {
const {
graph,
graphPath,
api,
history,
baseUrl,
parentNode,
grandparentNode,
association,
group,
vip
} = this.props;
const node = graph.get(index);
if (!node) {
return null;
}
const first = graph.peekLargest()?.[0];
const post = node?.post;
const nodeIndex =
( parentNode &&
typeof parentNode.post !== 'string'
) ? parentNode.post.index.split('/').slice(1).map((ind) => {
return bigInt(ind);
}) : [];
if (parentNode && index.eq(first ?? bigInt.zero)) {
return (
<React.Fragment key={index.toString()}>
<Col
key={index.toString()}
ref={ref}
mb={3}
width="100%"
flexShrink={0}
>
<PostItem
key={parentNode.post.index}
parentPost={grandparentNode?.post}
node={parentNode}
parentNode={grandparentNode}
graphPath={graphPath}
association={association}
api={api}
index={nodeIndex}
baseUrl={baseUrl}
history={history}
isParent={true}
isRelativeTime={false}
vip={vip}
group={group}
/>
</Col>
<PostItem
node={node}
graphPath={graphPath}
association={association}
api={api}
index={[...nodeIndex, index]}
baseUrl={baseUrl}
history={history}
isReply={true}
parentPost={parentNode.post}
isRelativeTime={true}
vip={vip}
group={group}
/>
</React.Fragment>
);
} else if (index.eq(first ?? bigInt.zero)) {
return (
<Col
width="100%"
alignItems="center"
key={index.toString()}
ref={ref}>
<Col
width="100%"
maxWidth="616px"
pt={3}
pl={2}
pr={2}
mb={3}>
<PostInput
api={api}
group={group}
association={association}
vip={vip}
graphPath={graphPath}
/>
</Col>
<PostItem
node={node}
graphPath={graphPath}
association={association}
api={api}
index={[...nodeIndex, index]}
baseUrl={baseUrl}
history={history}
parentPost={parentNode?.post}
isReply={Boolean(parentNode)}
isRelativeTime={true}
vip={vip}
group={group}
/>
</Col>
);
}
return (
<Box key={index.toString()} ref={ref}>
<PostItem
node={node}
graphPath={graphPath}
association={association}
api={api}
index={[...nodeIndex, index]}
baseUrl={baseUrl}
history={history}
parentPost={parentNode?.post}
isReply={Boolean(parentNode)}
isRelativeTime={true}
vip={vip}
group={group}
/>
</Box>
);
});
async fetchPosts(newer) {
const { graph, graphPath, api } = this.props;
const graphResource = resourceFromPath(graphPath);
if (this.isFetching) {
return false;
}
this.isFetching = true;
const { ship, name } = graphResource;
const currSize = graph.size;
if (newer) {
const [index] = graph.peekLargest();
// TODO: replace this with deep something
await api.graph.getYoungerSiblings(
ship,
name,
100,
`/${index.toString()}`
);
} else {
const [index] = graph.peekSmallest();
await api.graph.getOlderSiblings(ship, name, 100, `/${index.toString()}`);
}
this.isFetching = false;
return currSize === graph.size;
}
async doNotFetch(newer) {
return true;
}
render() {
const {
flatGraph,
pendingSize,
parentNode,
history
} = this.props;
// TODO: memoize flattening the graph,
// take in a prop on whether to flatten the graph or not
return (
<Col width="100%" height="100%" position="relative">
<ArrayVirtualScroller
key={history.location.pathname}
origin="top"
offset={0}
data={flatGraph}
averageHeight={106}
size={flatGraph.size}
style={virtualScrollerStyle}
pendingSize={pendingSize}
renderer={this.renderItem}
loadRows={parentNode ? this.doNotFetch : this.fetchPosts}
/>
</Col>
);
}
}
export default withRouter(PostFeed);

View File

@ -119,8 +119,8 @@ class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
width="100%"
maxWidth="616px"
pt={3}
pl={2}
pr={2}
pl={1}
pr={1}
mb={3}>
<PostInput
api={api}
@ -182,7 +182,6 @@ class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
if (newer) {
const [index] = graph.peekLargest();
// TODO: replace this with deep something
await api.graph.getYoungerSiblings(
ship,
name,
@ -210,9 +209,6 @@ class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
history
} = this.props;
// TODO: memoize flattening the graph,
// take in a prop on whether to flatten the graph or not
return (
<Col width="100%" height="100%" position="relative">
<VirtualScroller

View File

@ -0,0 +1,183 @@
import { Box, Col } from '@tlon/indigo-react';
import { Association, FlatGraph, FlatGraphNode, Group } from '@urbit/api';
import bigInt from 'big-integer';
import React from 'react';
import { withRouter } from 'react-router';
import GlobalApi from '~/logic/api/global';
import { resourceFromPath } from '~/logic/lib/group';
import ArrayVirtualScroller, {
indexEqual,
arrToString
} from '~/views/components/ArrayVirtualScroller';
import PostItem from './PostItem/PostItem';
import PostInput from './PostInput';
const virtualScrollerStyle = {
height: '100%'
};
interface PostFeedProps {
flatGraph: FlatGraph;
graphPath: string;
api: GlobalApi;
history: History;
baseUrl: string;
parentNode?: FlatGraphNode;
association: Association;
group: Group;
vip: string;
pendingSize: number;
}
class PostFlatFeed extends React.Component<PostFeedProps, PostFeedState> {
isFetching: boolean;
constructor(props) {
super(props);
this.isFetching = false;
this.fetchPosts = this.fetchPosts.bind(this);
this.doNotFetch = this.doNotFetch.bind(this);
}
renderItem = React.forwardRef(({ index, scrollWindow }, ref) => {
const {
flatGraph,
graphPath,
api,
history,
baseUrl,
association,
group,
vip
} = this.props;
const node = flatGraph.get(index);
const parentNode = index.length > 1 ?
flatGraph.get(index.slice(0, index.length - 1)) : null;
if (!node) {
return null;
}
const first = flatGraph.peekLargest()?.[0];
const post = node?.post;
if (indexEqual(index, (first ?? [bigInt.zero]))) {
return (
<Col
width="100%"
alignItems="center"
key={arrToString(index)}
ref={ref}>
<Col
width="100%"
maxWidth="608px"
pt={3}
pl={1}
pr={1}
mb={3}>
<PostInput
api={api}
group={group}
association={association}
vip={vip}
graphPath={graphPath}
/>
</Col>
<PostItem
node={node}
graphPath={graphPath}
association={association}
api={api}
index={index}
baseUrl={baseUrl}
history={history}
parentPost={parentNode?.post}
isReply={index.length > 1}
isRelativeTime={true}
vip={vip}
group={group}
/>
</Col>
);
}
return (
<Box key={arrToString(index)} ref={ref}>
<PostItem
node={node}
graphPath={graphPath}
association={association}
api={api}
index={index}
baseUrl={baseUrl}
history={history}
parentPost={parentNode?.post}
isReply={index.length > 1}
isRelativeTime={true}
vip={vip}
group={group}
/>
</Box>
);
});
async fetchPosts(newer) {
const { flatGraph, graphPath, api } = this.props;
const graphResource = resourceFromPath(graphPath);
if (this.isFetching) {
return false;
}
this.isFetching = true;
const { ship, name } = graphResource;
const currSize = flatGraph.size;
if (newer) {
return true;
} else {
const [index] = flatGraph.peekSmallest();
if (index && index.length > 0) {
await api.graph.getDeepNewest(ship, name, index[0].toString(), 100);
} else {
await api.graph.getDeepNewest(ship, name, null, 100);
}
}
this.isFetching = false;
return currSize === flatGraph.size;
}
async doNotFetch(newer) {
return true;
}
render() {
const {
flatGraph,
pendingSize,
parentNode,
history
} = this.props;
return (
<Col width="100%" height="100%" position="relative">
<ArrayVirtualScroller
key={history.location.pathname}
origin="top"
offset={0}
data={flatGraph}
averageHeight={106}
size={flatGraph.size}
style={virtualScrollerStyle}
pendingSize={pendingSize}
renderer={this.renderItem}
loadRows={this.fetchPosts}
/>
</Col>
);
}
}
export default withRouter(PostFlatFeed);

View File

@ -0,0 +1,103 @@
import { Box, Col, Text } from '@tlon/indigo-react';
import { Association, FlatGraph, Group } from '@urbit/api';
import React, { ReactElement } from 'react';
import GlobalApi from '~/logic/api/global';
import { Loading } from '~/views/components/Loading';
import PostFlatFeed from './PostFlatFeed';
import PostInput from './PostInput';
interface PostTimelineProps {
api: GlobalApi;
association: Association;
baseUrl: string;
flatGraph: FlatGraph;
graphPath: string;
group: Group;
pendingSize: number;
vip: string;
}
const PostFlatTimeline = (props: PostTimelineProps): ReactElement => {
const {
baseUrl,
api,
association,
graphPath,
group,
flatGraph,
pendingSize,
vip
} = props;
//console.log(flatGraph);
const shouldRenderFeed = Boolean(flatGraph);
if (!shouldRenderFeed) {
return (
<Box height="100%" pt={3} pb={3} width="100%" alignItems="center" pl={1}>
<Loading />
</Box>
);
}
const first = flatGraph.peekLargest()?.[0];
if (!first) {
return (
<Col
key={0}
width="100%"
height="100%"
alignItems="center"
>
<Col
width="100%"
maxWidth="616px"
pt={3}
pl={2}
pr={2}
mb={3}
alignItems="center"
>
<PostInput
api={api}
graphPath={graphPath}
group={group}
association={association}
vip={vip}
/>
</Col>
<Box
pl={2}
pr={2}
width="100%"
maxWidth="616px"
alignItems="center"
>
<Col bg="washedGray" width="100%" alignItems="center" p={3}>
<Text textAlign="center" width="100%">
No one has posted anything here yet.
</Text>
</Col>
</Box>
</Col>
);
}
return (
<Box height="calc(100% - 48px)" width="100%" alignItems="center" pl={1}>
<PostFlatFeed
key={graphPath}
graphPath={graphPath}
flatGraph={flatGraph}
pendingSize={pendingSize}
association={association}
group={group}
vip={vip}
api={api}
baseUrl={baseUrl}
/>
</Box>
);
}
export default PostFlatTimeline;

View File

@ -0,0 +1,126 @@
import { Box, Col, Text } from '@tlon/indigo-react';
import bigInt from 'big-integer';
import React from 'react';
import { resourceFromPath } from '~/logic/lib/group';
import { Loading } from '~/views/components/Loading';
import PostFeed from './PostFeed';
import PostItem from './PostItem/PostItem';
export default function PostThread(props) {
const {
baseUrl,
api,
history,
association,
graphPath,
group,
vip,
pendingSize
} = props;
// TODO: make thread
return (
<Box></Box>
);
const graphResource = resourceFromPath(graphPath);
let graph = props.graph;
const shouldRenderFeed = Boolean(graph);
if (!shouldRenderFeed) {
return (
<Box height="100%" width="100%" alignItems="center" pl={1} pt={3}>
<Loading />
</Box>
);
}
const locationUrl =
props.locationUrl.replace(`${baseUrl}/feed`, '');
const nodeIndex = locationUrl.split('/').slice(1).map((ind) => {
return bigInt(ind);
});
let node;
let parentNode;
nodeIndex.forEach((i, idx) => {
if (!graph) {
return null;
}
node = graph.get(i);
if(idx < nodeIndex.length - 1) {
parentNode = node;
}
if (!node) {
return null;
}
graph = node.children;
});
if (!node || !graph) {
return null;
}
const first = graph.peekLargest()?.[0];
if (!first) {
return (
<Col
key={locationUrl}
width="100%"
height="100%"
alignItems="center" overflowY="scroll"
>
<Box mt={3} width="100%" alignItems="center">
<PostItem
key={node.post.index}
node={node}
graphPath={graphPath}
association={association}
api={api}
index={nodeIndex}
baseUrl={baseUrl}
history={history}
isParent={true}
parentPost={parentNode?.post}
vip={vip}
group={group}
/>
</Box>
<Box
pl={2}
pr={2}
width="100%"
maxWidth="616px"
alignItems="center"
>
<Col bg="washedGray" width="100%" alignItems="center" p={3}>
<Text textAlign="center" width="100%">
No one has posted any replies yet.
</Text>
</Col>
</Box>
</Col>
);
}
return (
<Box height="calc(100% - 48px)" width="100%" alignItems="center" pl={1} pt={3}>
<PostFeed
key={locationUrl}
graphPath={graphPath}
graph={graph}
grandparentNode={parentNode}
parentNode={node}
pendingSize={pendingSize}
association={association}
group={group}
vip={vip}
api={api}
history={history}
baseUrl={baseUrl}
/>
</Box>
);
}

View File

@ -5,30 +5,21 @@ setAutoFreeze(false);
enablePatches();
function sortBigInt(a: BigInteger[], b: BigInteger[]) {
if (a.lt(b)) {
return 1;
} else if (a.eq(b)) {
return 0;
} else {
return -1;
}
}
function stringToBigIntArr(str: string) {
export function stringToArr(str: string) {
return str.split('/').slice(1).map((ind) => {
return bigInt(ind);
});
}
function arrToString(arr: BigInteger[]) {
export function arrToString(arr: BigInteger[]) {
let string = '';
arr.forEach((key) => {
string = string + `/${key.toString()}`;
});
return string;
}
function sortBigIntArr(a: BigInteger[], b: BigInteger[]) {
export function sortBigIntArr(a: BigInteger[], b: BigInteger[]) {
let aLen = a.length;
let bLen = b.length;
@ -81,7 +72,7 @@ export default class BigIntArrayOrderedMap<V> implements Iterable<[BigInteger[],
set(key: BigInteger[], value: V) {
return produce(this, draft => {
draft.root[key.toString()] = castDraft(value);
draft.root[arrToString(key)] = castDraft(value);
draft.cachedIter = null;
});
}
@ -138,9 +129,8 @@ export default class BigIntArrayOrderedMap<V> implements Iterable<[BigInteger[],
return [...this.cachedIter];
}
const result = Object.keys(this.root).map(key => {
const num = stringtoBigIntArr(key);
return [num, this.root[key]] as [BigInteger[], V];
}).sort(([a], [b]) => sortBigIntArr(a,b));
return [stringToArr(key), this.root[key]] as [BigInteger[], V];
}).sort(([a], [b]) => sortBigIntArr(a, b));
this.cachedIter = result;
return [...result];
}