diff --git a/pkg/interface/src/logic/api/graph.ts b/pkg/interface/src/logic/api/graph.ts index 57a4591e3..e7224ef91 100644 --- a/pkg/interface/src/logic/api/graph.ts +++ b/pkg/interface/src/logic/api/graph.ts @@ -348,7 +348,7 @@ export default class GraphApi extends BaseApi { } 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('graph-store', `/deep-nodes-up-to/${ship}/${resource}/${count}/${start}` ); diff --git a/pkg/interface/src/logic/reducers/graph-update.ts b/pkg/interface/src/logic/reducers/graph-update.ts index a32ae6a5c..fd25974c9 100644 --- a/pkg/interface/src/logic/reducers/graph-update.ts +++ b/pkg/interface/src/logic/reducers/graph-update.ts @@ -26,9 +26,8 @@ export const GraphReducer = (json) => { const flat = _.get(json, 'graph-update-flat', false); if (flat) { - reduceState(useGraphState, loose, [addNodesFlat]); + reduceState(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] = {}; } diff --git a/pkg/interface/src/logic/state/graph.ts b/pkg/interface/src/logic/state/graph.ts index 5f1f45627..2e479cf61 100644 --- a/pkg/interface/src/logic/state/graph.ts +++ b/pkg/interface/src/logic/state/graph.ts @@ -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 { const useGraphState = createState('Graph', { graphs: {}, + flatGraphs: {}, graphKeys: new Set(), looseNodes: {}, pendingIndices: {}, @@ -130,7 +131,7 @@ const useGraphState = createState('Graph', { // }); // graphReducer(node); // }, -}, ['graphs', 'graphKeys', 'looseNodes', 'graphTimesentMap']); +}, ['graphs', 'graphKeys', 'looseNodes', 'graphTimesentMap', 'flatGraphs']); export function useGraph(ship: string, name: string) { return useGraphState( diff --git a/pkg/interface/src/views/components/ArrayVirtualScroller.tsx b/pkg/interface/src/views/components/ArrayVirtualScroller.tsx new file mode 100644 index 000000000..646ac1400 --- /dev/null +++ b/pkg/interface/src/views/components/ArrayVirtualScroller.tsx @@ -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 { + /** + * 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; + /** + * The data to iterate over + */ + data: BigIntArrayOrderedMap; + /** + * 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 { + 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 extends Component, VirtualScrollerState> { + /** + * A reference to our scroll container + */ + window: HTMLDivElement | null = null; + /** + * A map of child refs, used to calculate scroll position + */ + private childRefs = new Map(); + /** + * A set of child refs which have been unmounted + */ + private orphans = new Set(); + /** + * 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) { + 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, _prevState: VirtualScrollerState) { + 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 { + 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) { + 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 && ( { + this.scrollRef = el; +}} +right={0} height="50px" +position="absolute" width="4px" +backgroundColor="lightGray" + />)} + + + + {(isTop ? !atStart : !atEnd) && ( +
+ +
+ )} + + {children.map(index => ( + + ))} + + {(!isTop ? !atStart : !atEnd) && + (
+ +
)} +
+
+ + ); + } +} + +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 ; +} + diff --git a/pkg/interface/src/views/landscape/components/Graph/GraphContent.tsx b/pkg/interface/src/views/landscape/components/Graph/GraphContent.tsx index b16677bea..5afbe03b5 100644 --- a/pkg/interface/src/views/landscape/components/Graph/GraphContent.tsx +++ b/pkg/interface/src/views/landscape/components/Graph/GraphContent.tsx @@ -414,8 +414,9 @@ export function Graphdown( {...nodeRest} tall={tall} > - {children.map((c) => ( + {children.map((c, idx) => ( 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 ; + } + + return ( + + + + { + return ( + + ); + }} + /> + { + return ( + + ); + }} + /> + + + ); +} + +export { GroupFlatFeed }; diff --git a/pkg/interface/src/views/landscape/components/Home/GroupHome.tsx b/pkg/interface/src/views/landscape/components/Home/GroupHome.tsx index 138ac229a..4e083ae63 100644 --- a/pkg/interface/src/views/landscape/components/Home/GroupHome.tsx +++ b/pkg/interface/src/views/landscape/components/Home/GroupHome.tsx @@ -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 } - + { (graphMetadata?.vip === 'admin-feed') ? ( + + ) : ( + + ) + } { + 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 ( + + + + + + + ); + } else if (index.eq(first ?? bigInt.zero)) { + return ( + + + + + + + ); + } + + return ( + + + + ); + }); + + 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 ( + + + + ); + } +} + +export default withRouter(PostFeed); diff --git a/pkg/interface/src/views/landscape/components/Home/Post/PostFeed.tsx b/pkg/interface/src/views/landscape/components/Home/Post/PostFeed.tsx index 4e046472c..8e5b0e97c 100644 --- a/pkg/interface/src/views/landscape/components/Home/Post/PostFeed.tsx +++ b/pkg/interface/src/views/landscape/components/Home/Post/PostFeed.tsx @@ -119,8 +119,8 @@ class PostFeed extends React.Component { width="100%" maxWidth="616px" pt={3} - pl={2} - pr={2} + pl={1} + pr={1} mb={3}> { 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 { history } = this.props; - // TODO: memoize flattening the graph, - // take in a prop on whether to flatten the graph or not - return ( { + 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 ( + + + + + 1} + isRelativeTime={true} + vip={vip} + group={group} + /> + + ); + } + + return ( + + 1} + isRelativeTime={true} + vip={vip} + group={group} + /> + + ); + }); + + 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 ( + + + + ); + } +} + +export default withRouter(PostFlatFeed); diff --git a/pkg/interface/src/views/landscape/components/Home/Post/PostFlatTimeline.tsx b/pkg/interface/src/views/landscape/components/Home/Post/PostFlatTimeline.tsx new file mode 100644 index 000000000..6838ee560 --- /dev/null +++ b/pkg/interface/src/views/landscape/components/Home/Post/PostFlatTimeline.tsx @@ -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 ( + + + + ); + } + + const first = flatGraph.peekLargest()?.[0]; + if (!first) { + return ( + + + + + + + + No one has posted anything here yet. + + + + + ); + } + + return ( + + + + ); +} + +export default PostFlatTimeline; diff --git a/pkg/interface/src/views/landscape/components/Home/Post/PostThread.tsx b/pkg/interface/src/views/landscape/components/Home/Post/PostThread.tsx new file mode 100644 index 000000000..2ea509774 --- /dev/null +++ b/pkg/interface/src/views/landscape/components/Home/Post/PostThread.tsx @@ -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 ( + + ); + + const graphResource = resourceFromPath(graphPath); + + let graph = props.graph; + const shouldRenderFeed = Boolean(graph); + + if (!shouldRenderFeed) { + return ( + + + + ); + } + + 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 ( + + + + + + + + No one has posted any replies yet. + + + + + ); + } + + return ( + + + + ); +} + diff --git a/pkg/npm/api/lib/BigIntArrayOrderedMap.ts b/pkg/npm/api/lib/BigIntArrayOrderedMap.ts index 856996a4a..1402680df 100644 --- a/pkg/npm/api/lib/BigIntArrayOrderedMap.ts +++ b/pkg/npm/api/lib/BigIntArrayOrderedMap.ts @@ -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 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 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]; }