Merge pull request #4823 from urbit/lf/more-virt-perf

VirtualScroller: performance enhancements
This commit is contained in:
matildepark 2021-04-27 16:46:19 -04:00 committed by GitHub
commit 6bf0bf82cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 453 additions and 445 deletions

View File

@ -5,7 +5,7 @@
/- glob /- glob
/+ default-agent, verb, dbug /+ default-agent, verb, dbug
|% |%
++ hash 0v3.g6u13.haedt.jt4hd.61ek5.6t30q ++ hash 0v3.hls3k.gsbae.rm6pr.p6qve.46dh8
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))] +$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states +$ all-states
$% state-0 $% state-0

View File

@ -24,6 +24,6 @@
<div id="portal-root"></div> <div id="portal-root"></div>
<script src="/~landscape/js/channel.js"></script> <script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.js"></script> <script src="/~landscape/js/session.js"></script>
<script src="/~landscape/js/bundle/index.59e682153138f604d358.js"></script> <script src="/~landscape/js/bundle/index.b253f1f3f824fdeb29d3.js"></script>
</body> </body>
</html> </html>

View File

@ -43,8 +43,8 @@ export function useVirtualResizeState(s: boolean) {
[_setState, save] [_setState, save]
); );
useLayoutEffect(() => { useEffect(() => {
restore(); requestAnimationFrame(restore);
}, [state]); }, [state]);
return [state, setState] as const; return [state, setState] as const;
@ -58,7 +58,7 @@ export function useVirtualResizeProp(prop: Primitive) {
save(); save();
} }
useLayoutEffect(() => { useEffect(() => {
requestAnimationFrame(restore); requestAnimationFrame(restore);
}, [prop]); }, [prop]);

View File

@ -1,5 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap"; import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap";
import produce from 'immer';
import bigInt, { BigInteger } from "big-integer"; import bigInt, { BigInteger } from "big-integer";
import useGraphState, { GraphState } from '../state/graph'; import useGraphState, { GraphState } from '../state/graph';
import { reduceState } from '../state/base'; import { reduceState } from '../state/base';
@ -51,23 +52,18 @@ const keys = (json, state: GraphState): GraphState => {
const processNode = (node) => { const processNode = (node) => {
// is empty // is empty
if (!node.children) { if (!node.children) {
node.children = new BigIntOrderedMap(); return produce(node, draft => {
return node; draft.children = new BigIntOrderedMap();
});
} }
// is graph // is graph
let converted = new BigIntOrderedMap(); return produce(node, draft => {
for (let idx in node.children) { draft.children = new BigIntOrderedMap()
let item = node.children[idx]; .gas(_.map(draft.children, (item, idx) =>
let index = bigInt(idx); [bigInt(idx), processNode(item)] as [BigInteger, any]
));
converted.set( });
index,
processNode(item)
);
}
node.children = converted;
return node;
}; };
@ -85,17 +81,10 @@ const addGraph = (json, state: GraphState): GraphState => {
state.graphTimesentMap[resource] = {}; state.graphTimesentMap[resource] = {};
for (let idx in data.graph) { state.graphs[resource] = state.graphs[resource].gas(Object.keys(data.graph).map(idx => {
let item = data.graph[idx]; return [bigInt(idx), processNode(data.graph[idx])];
let index = bigInt(idx); }));
let node = processNode(item);
state.graphs[resource].set(
index,
node
);
}
state.graphKeys.add(resource); state.graphKeys.add(resource);
} }
return state; return state;
@ -116,7 +105,7 @@ const removeGraph = (json, state: GraphState): GraphState => {
}; };
const mapifyChildren = (children) => { const mapifyChildren = (children) => {
return new BigIntOrderedMap( return new BigIntOrderedMap().gas(
_.map(children, (node, idx) => { _.map(children, (node, idx) => {
idx = idx && idx.startsWith('/') ? idx.slice(1) : idx; idx = idx && idx.startsWith('/') ? idx.slice(1) : idx;
const nd = {...node, children: mapifyChildren(node.children || {}) }; const nd = {...node, children: mapifyChildren(node.children || {}) };
@ -128,8 +117,7 @@ const addNodes = (json, state) => {
const _addNode = (graph, index, node) => { const _addNode = (graph, index, node) => {
// set child of graph // set child of graph
if (index.length === 1) { if (index.length === 1) {
graph.set(index[0], node); return graph.set(index[0], node);
return graph;
} }
// set parent of graph // set parent of graph
@ -138,19 +126,20 @@ const addNodes = (json, state) => {
console.error('parent node does not exist, cannot add child'); console.error('parent node does not exist, cannot add child');
return graph; return graph;
} }
parNode.children = _addNode(parNode.children, index.slice(1), node); return graph.set(index[0], produce(parNode, draft => {
graph.set(index[0], parNode); draft.children = _addNode(draft.children, index.slice(1), node);
return graph; }));
}; };
const _remove = (graph, index) => { const _remove = (graph, index) => {
if (index.length === 1) { if (index.length === 1) {
graph.delete(index[0]); return graph.delete(index[0]);
} else { } else {
const child = graph.get(index[0]); const child = graph.get(index[0]);
if (child) { if (child) {
child.children = _remove(child.children, index.slice(1)); return graph.set(index[0], produce(child, draft => {
graph.set(index[0], child); draft.children = _remove(draft.children, index.slice(1));
}));
} }
} }
@ -166,10 +155,9 @@ const addNodes = (json, state) => {
return bigInt(ind); return bigInt(ind);
}); });
graph = _remove(graph, indexArr);
delete state.graphTimesentMap[resource][timestamp]; delete state.graphTimesentMap[resource][timestamp];
return _remove(graph, indexArr);
} }
return graph; return graph;
}; };
@ -208,11 +196,12 @@ const addNodes = (json, state) => {
return aArr.length - bArr.length; return aArr.length - bArr.length;
}); });
let graph = state.graphs[resource];
indices.forEach((index) => { indices.forEach((index) => {
let node = data.nodes[index]; let node = data.nodes[index];
graph = _removePending(graph, node.post, resource); const old = state.graphs[resource].size;
state.graphs[resource] = _removePending(state.graphs[resource], node.post, resource);
const newSize = state.graphs[resource].size;
if (index.split('/').length === 0) { return; } if (index.split('/').length === 0) { return; }
let indexArr = index.split('/').slice(1).map((ind) => { let indexArr = index.split('/').slice(1).map((ind) => {
@ -225,17 +214,21 @@ const addNodes = (json, state) => {
state.graphTimesentMap[resource][node.post['time-sent']] = index; state.graphTimesentMap[resource][node.post['time-sent']] = index;
} }
node.children = mapifyChildren(node?.children || {});
graph = _addNode( state.graphs[resource] = _addNode(
graph, state.graphs[resource],
indexArr, indexArr,
node produce(node, draft => {
draft.children = mapifyChildren(draft?.children || {});
})
); );
if(newSize !== old) {
console.log(`${resource}, (${old}, ${newSize}, ${state.graphs[resource].size})`);
}
}); });
state.graphs[resource] = graph;
} }
return state; return state;
}; };
@ -243,13 +236,15 @@ const addNodes = (json, state) => {
const removeNodes = (json, state: GraphState): GraphState => { const removeNodes = (json, state: GraphState): GraphState => {
const _remove = (graph, index) => { const _remove = (graph, index) => {
if (index.length === 1) { if (index.length === 1) {
graph.delete(index[0]); return graph.delete(index[0]);
} else { } else {
const child = graph.get(index[0]); const child = graph.get(index[0]);
if (child) { if (child) {
_remove(child.children, index.slice(1)); return graph.set(index[0], produce(draft => {
graph.set(index[0], child); draft.children = _remove(draft.children, index.slice(1))
}));
} }
return graph;
} }
}; };
@ -264,7 +259,7 @@ const removeNodes = (json, state: GraphState): GraphState => {
let indexArr = index.split('/').slice(1).map((ind) => { let indexArr = index.split('/').slice(1).map((ind) => {
return bigInt(ind); return bigInt(ind);
}); });
_remove(state.graphs[res], indexArr); state.graphs[res] = _remove(state.graphs[res], indexArr);
}); });
} }
return state; return state;

View File

@ -329,9 +329,9 @@ function added(json: any, state: HarkState): HarkState {
); );
if (arrIdx !== -1) { if (arrIdx !== -1) {
timebox[arrIdx] = { index, notification }; timebox[arrIdx] = { index, notification };
state.notifications.set(time, timebox); state.notifications = state.notifications.set(time, timebox);
} else { } else {
state.notifications.set(time, [...timebox, { index, notification }]); state.notifications = state.notifications.set(time, [...timebox, { index, notification }]);
} }
} }
return state; return state;
@ -350,7 +350,7 @@ const timebox = (json: any, state: HarkState): HarkState => {
if (data) { if (data) {
const time = makePatDa(data.time); const time = makePatDa(data.time);
if (!data.archive) { if (!data.archive) {
state.notifications.set(time, data.notifications); state.notifications = state.notifications.set(time, data.notifications);
} }
} }
return state; return state;
@ -403,7 +403,7 @@ function setRead(
return state; return state;
} }
timebox[arrIdx].notification.read = read; timebox[arrIdx].notification.read = read;
state.notifications.set(patDa, timebox); state.notifications = state.notifications.set(patDa, timebox);
return state; return state;
} }

View File

@ -128,6 +128,8 @@ const useGraphState = createState<GraphState>('Graph', {
// }); // });
// graphReducer(node); // graphReducer(node);
// }, // },
}, ['graphs', 'graphKeys', 'looseNodes']); }, ['graphs', 'graphKeys', 'looseNodes', 'graphTimesentMap']);
window.useGraphState = useGraphState;
export default useGraphState; export default useGraphState;

View File

@ -28,9 +28,9 @@ type ChatResourceProps = StoreState & {
association: Association; association: Association;
api: GlobalApi; api: GlobalApi;
baseUrl: string; baseUrl: string;
} & RouteComponentProps; };
export function ChatResource(props: ChatResourceProps) { function ChatResource(props: ChatResourceProps) {
const station = props.association.resource; const station = props.association.resource;
const groupPath = props.association.group; const groupPath = props.association.group;
const groups = useGroupState(state => state.groups); const groups = useGroupState(state => state.groups);
@ -40,7 +40,7 @@ export function ChatResource(props: ChatResourceProps) {
const graphPath = station.slice(7); const graphPath = station.slice(7);
const graph = graphs[graphPath]; const graph = graphs[graphPath];
const unreads = useHarkState(state => state.unreads); const unreads = useHarkState(state => state.unreads);
const unreadCount = unreads.graph?.[station]?.['/']?.unreads || 0; const unreadCount = unreads.graph?.[station]?.['/']?.unreads as number || 0;
const graphTimesentMap = useGraphState(state => state.graphTimesentMap); const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
const [,, owner, name] = station.split('/'); const [,, owner, name] = station.split('/');
const ourContact = contacts?.[`~${window.ship}`]; const ourContact = contacts?.[`~${window.ship}`];
@ -48,7 +48,7 @@ export function ChatResource(props: ChatResourceProps) {
const canWrite = isWriter(group, station); const canWrite = isWriter(group, station);
useEffect(() => { useEffect(() => {
const count = 100 + unreadCount; const count = Math.min(400, 100 + unreadCount);
props.api.graph.getNewest(owner, name, count); props.api.graph.getNewest(owner, name, count);
}, [station]); }, [station]);
@ -165,10 +165,9 @@ export function ChatResource(props: ChatResourceProps) {
{dragging && <SubmitDragger />} {dragging && <SubmitDragger />}
<ChatWindow <ChatWindow
key={station} key={station}
history={props.history}
graph={graph} graph={graph}
graphSize={graph.size} graphSize={graph.size}
unreadCount={unreadCount} unreadCount={unreadCount as number}
showOurContact={ !showBanner && hasLoadedAllowed } showOurContact={ !showBanner && hasLoadedAllowed }
association={props.association} association={props.association}
pendingSize={Object.keys(graphTimesentMap[graphPath] || {}).length} pendingSize={Object.keys(graphTimesentMap[graphPath] || {}).length}
@ -196,3 +195,8 @@ export function ChatResource(props: ChatResourceProps) {
</Col> </Col>
); );
} }
ChatResource.whyDidYouRender = true;
export { ChatResource };

View File

@ -3,6 +3,7 @@ import bigInt from 'big-integer';
import React, { import React, {
useState, useState,
useEffect, useEffect,
useMemo,
useRef, useRef,
Component, Component,
PureComponent, PureComponent,
@ -40,11 +41,12 @@ import styled from 'styled-components';
import useLocalState from '~/logic/state/local'; import useLocalState from '~/logic/state/local';
import useSettingsState, { selectCalmState } from '~/logic/state/settings'; import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import Timestamp from '~/views/components/Timestamp'; import Timestamp from '~/views/components/Timestamp';
import useContactState from '~/logic/state/contact'; import useContactState, {useContact} from '~/logic/state/contact';
import { useIdlingState } from '~/logic/lib/idling'; import { useIdlingState } from '~/logic/lib/idling';
import ProfileOverlay from '~/views/components/ProfileOverlay'; import ProfileOverlay from '~/views/components/ProfileOverlay';
import {useCopy} from '~/logic/lib/useCopy'; import {useCopy} from '~/logic/lib/useCopy';
import {GraphContentWide} from '~/views/landscape/components/Graph/GraphContentWide'; import {GraphContentWide} from '~/views/landscape/components/Graph/GraphContentWide';
import {Contact} from '@urbit/api';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D'; export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
@ -80,7 +82,7 @@ export const DayBreak = ({ when, shimTop = false }: DayBreakProps) => (
); );
export const UnreadMarker = React.forwardRef( export const UnreadMarker = React.forwardRef(
({ dayBreak, when, api, association }, ref) => { ({ dayBreak, when, api, association }: any, ref) => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const idling = useIdlingState(); const idling = useIdlingState();
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
@ -141,7 +143,7 @@ const MessageActionItem = (props) => {
); );
}; };
const MessageActions = ({ api, onReply, association, history, msg, group }) => { const MessageActions = ({ api, onReply, association, msg, group }) => {
const isAdmin = () => group.tags.role.admin.has(window.ship); const isAdmin = () => group.tags.role.admin.has(window.ship);
const isOwn = () => msg.author === window.ship; const isOwn = () => msg.author === window.ship;
const { doCopy, copyDisplay } = useCopy(`web+urbitgraph://group${association.group.slice(5)}/graph${association.resource.slice(5)}${msg.index}`, 'Copy Message Link'); const { doCopy, copyDisplay } = useCopy(`web+urbitgraph://group${association.group.slice(5)}/graph${association.resource.slice(5)}${msg.index}`, 'Copy Message Link');
@ -241,49 +243,43 @@ interface ChatMessageProps {
className?: string; className?: string;
isPending: boolean; isPending: boolean;
style?: unknown; style?: unknown;
scrollWindow: HTMLDivElement;
isLastMessage?: boolean; isLastMessage?: boolean;
unreadMarkerRef: React.RefObject<HTMLDivElement>; unreadMarkerRef: React.RefObject<HTMLDivElement>;
history: unknown;
api: GlobalApi; api: GlobalApi;
highlighted?: boolean; highlighted?: boolean;
renderSigil?: boolean; renderSigil?: boolean;
hideHover?: boolean; hideHover?: boolean;
innerRef: (el: HTMLDivElement | null) => void; innerRef: (el: HTMLDivElement | null) => void;
onReply?: (msg: Post) => void; onReply?: (msg: Post) => void;
showOurContact: boolean;
} }
class ChatMessage extends Component<ChatMessageProps> { function ChatMessage(props: ChatMessageProps) {
private divRef: React.RefObject<HTMLDivElement>; let { highlighted } = this.props;
const {
msg,
previousMsg,
nextMsg,
isLastRead,
group,
association,
className = '',
isPending,
style,
isLastMessage,
unreadMarkerRef,
api,
showOurContact,
fontSize,
hideHover
} = props;
constructor(props) { let onReply = props?.onReply ?? (() => {});
super(props); const transcluded = props?.transcluded ?? 0;
this.divRef = React.createRef(); const renderSigil = props.renderSigil ?? (Boolean(nextMsg && msg.author !== nextMsg.author) ||
} !nextMsg ||
msg.number === 1
componentDidMount() {} );
render() {
let { highlighted } = this.props;
const {
msg,
previousMsg,
nextMsg,
isLastRead,
group,
association,
className = '',
isPending,
style,
scrollWindow,
isLastMessage,
unreadMarkerRef,
history,
api,
showOurContact,
fontSize,
hideHover
} = this.props;
const ourMention = msg?.contents?.some((e) => { const ourMention = msg?.contents?.some((e) => {
return e?.mention && e?.mention === window.ship; return e?.mention && e?.mention === window.ship;
@ -295,126 +291,120 @@ class ChatMessage extends Component<ChatMessageProps> {
} }
} }
let onReply = this.props?.onReply ?? (() => {}); const date = useMemo(() => daToUnix(bigInt(msg.index.split('/')[1])), [msg.index]);
const transcluded = this.props?.transcluded ?? 0; const nextDate = useMemo(() => nextMsg ? (
let { renderSigil } = this.props; daToUnix(bigInt(nextMsg.index.split('/')[1]))
) : null,
[nextMsg]
);
if (renderSigil === undefined) { const dayBreak = useMemo(() =>
renderSigil = Boolean( nextDate &&
(nextMsg && msg.author !== nextMsg.author) || new Date(date).getDate() !==
!nextMsg || new Date(nextDate).getDate()
msg.number === 1 , [nextDate, date])
);
}
const date = daToUnix(bigInt(msg.index.split('/')[1])); const containerClass = `${isPending ? 'o-40' : ''} ${className}`;
const nextDate = nextMsg ? (
daToUnix(bigInt(nextMsg.index.split('/')[1]))
) : null;
const dayBreak = const timestamp = useMemo(() => moment
nextMsg && .unix(date / 1000)
new Date(date).getDate() !== .format(renderSigil ? 'h:mm A' : 'h:mm'),
new Date(nextDate).getDate(); [date, renderSigil]
);
const containerClass = `${isPending ? 'o-40' : ''} ${className}`; const messageProps = {
msg,
timestamp,
association,
group,
isPending,
showOurContact,
api,
highlighted,
fontSize,
hideHover,
transcluded,
onReply
};
const timestamp = moment const message = useMemo(() => (
.unix(date / 1000) <Message
.format(renderSigil ? 'h:mm A' : 'h:mm'); msg={msg}
timestamp={timestamp}
timestampHover={!renderSigil}
api={api}
transcluded={transcluded}
showOurContact={showOurContact}
/>
), [renderSigil, msg, timestamp, api, transcluded, showOurContact]);
const messageProps = { const unreadContainerStyle = {
msg, height: isLastRead ? '2rem' : '0'
timestamp, };
association,
group,
style,
containerClass,
isPending,
showOurContact,
history,
api,
scrollWindow,
highlighted,
fontSize,
hideHover,
transcluded,
onReply
};
const unreadContainerStyle = { return (
height: isLastRead ? '2rem' : '0' <Box
}; ref={props.innerRef}
pt={renderSigil ? 2 : 0}
return ( width="100%"
<Box pb={isLastMessage ? '20px' : 0}
ref={this.props.innerRef} className={containerClass}
pt={renderSigil ? 2 : 0} style={style}
width="100%" >
pb={isLastMessage ? '20px' : 0} {dayBreak && !isLastRead ? (
className={containerClass} <DayBreak when={date} shimTop={renderSigil} />
style={style} ) : null}
> {renderSigil ? (
{dayBreak && !isLastRead ? ( <MessageWrapper {...messageProps}>
<DayBreak when={date} shimTop={renderSigil} /> <MessageAuthor pb={1} {...messageProps} />
{message}
</MessageWrapper>
) : (
<MessageWrapper {...messageProps}>
{message}
</MessageWrapper>
)}
<Box style={unreadContainerStyle}>
{isLastRead ? (
<UnreadMarker
association={association}
api={api}
dayBreak={dayBreak}
when={date}
ref={unreadMarkerRef}
/>
) : null} ) : null}
{renderSigil ? (
<MessageWrapper {...messageProps}>
<MessageAuthor pb={1} {...messageProps} />
<Message pl={'44px'} pr={4} {...messageProps} />
</MessageWrapper>
) : (
<MessageWrapper {...messageProps}>
<Message pl={'44px'} pr={4} timestampHover {...messageProps} />
</MessageWrapper>
)}
<Box style={unreadContainerStyle}>
{isLastRead ? (
<UnreadMarker
association={association}
api={api}
dayBreak={dayBreak}
when={date}
ref={unreadMarkerRef}
/>
) : null}
</Box>
</Box> </Box>
); </Box>
} );
} }
export default React.forwardRef((props, ref) => ( export default React.forwardRef((props: Omit<ChatMessageProps, 'innerRef'>, ref: any) => (
<ChatMessage {...props} innerRef={ref} /> <ChatMessage {...props} innerRef={ref} />
)); ));
export const MessageAuthor = ({ export const MessageAuthor = ({
timestamp, timestamp,
msg, msg,
group,
api, api,
history,
scrollWindow,
showOurContact, showOurContact,
...rest
}) => { }) => {
const osDark = useLocalState((state) => state.dark); const osDark = useLocalState((state) => state.dark);
const theme = useSettingsState((s) => s.display.theme); const theme = useSettingsState((s) => s.display.theme);
const dark = theme === 'dark' || (theme === 'auto' && osDark); const dark = theme === 'dark' || (theme === 'auto' && osDark);
const contacts = useContactState((state) => state.contacts); let contact: Contact | null = useContact(`~${msg.author}`);
const date = daToUnix(bigInt(msg.index.split('/')[1])); const date = daToUnix(bigInt(msg.index.split('/')[1]));
const datestamp = moment const datestamp = moment
.unix(date / 1000) .unix(date / 1000)
.format(DATESTAMP_FORMAT); .format(DATESTAMP_FORMAT);
const contact = contact =
((msg.author === window.ship && showOurContact) || ((msg.author === window.ship && showOurContact) ||
msg.author !== window.ship) && msg.author !== window.ship)
`~${msg.author}` in contacts ? contact
? contacts[`~${msg.author}`] : null;
: undefined;
const showNickname = useShowNickname(contact); const showNickname = useShowNickname(contact);
const { hideAvatars } = useSettingsState(selectCalmState); const { hideAvatars } = useSettingsState(selectCalmState);
@ -467,7 +457,7 @@ export const MessageAuthor = ({
</Box> </Box>
); );
return ( return (
<Box display='flex' alignItems='flex-start' {...rest}> <Box display='flex' alignItems='flex-start'>
<Box <Box
height={24} height={24}
pr={2} pr={2}
@ -519,20 +509,20 @@ export const MessageAuthor = ({
); );
}; };
export const Message = ({ type MessageProps = { timestamp: string; timestampHover: boolean; }
& Pick<ChatMessageProps, "msg" | "api" | "transcluded" | "showOurContact">
export const Message = React.memo(({
timestamp, timestamp,
msg, msg,
group,
api, api,
scrollWindow,
timestampHover, timestampHover,
transcluded, transcluded,
showOurContact, showOurContact
...rest }: MessageProps) => {
}) => {
const { hovering, bind } = useHovering(); const { hovering, bind } = useHovering();
return ( return (
<Box width="100%" position='relative' {...rest}> <Box pl="44px" width="100%" position='relative'>
{timestampHover ? ( {timestampHover ? (
<Text <Text
display={hovering ? 'block' : 'none'} display={hovering ? 'block' : 'none'}
@ -559,7 +549,9 @@ export const Message = ({
/> />
</Box> </Box>
); );
}; });
Message.displayName = 'Message';
export const MessagePlaceholder = ({ export const MessagePlaceholder = ({
height, height,

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React, { useEffect, Component, useRef, useState, useCallback } from 'react';
import { RouteComponentProps } from 'react-router-dom'; import { RouteComponentProps } from 'react-router-dom';
import _ from 'lodash'; import _ from 'lodash';
import bigInt, { BigInteger } from 'big-integer'; import bigInt, { BigInteger } from 'big-integer';
@ -30,10 +30,13 @@ const DEFAULT_BACKLOG_SIZE = 100;
const IDLE_THRESHOLD = 64; const IDLE_THRESHOLD = 64;
const MAX_BACKLOG_SIZE = 1000; const MAX_BACKLOG_SIZE = 1000;
type ChatWindowProps = RouteComponentProps<{ const getCurrGraphSize = (ship: string, name: string) => {
ship: Patp; const { graphs } = useGraphState.getState();
station: string; const graph = graphs[`${ship}/${name}`];
}> & { return graph.size;
};
type ChatWindowProps = {
unreadCount: number; unreadCount: number;
graph: Graph; graph: Graph;
graphSize: number; graphSize: number;
@ -44,6 +47,8 @@ type ChatWindowProps = RouteComponentProps<{
api: GlobalApi; api: GlobalApi;
scrollTo?: BigInteger; scrollTo?: BigInteger;
onReply: (msg: Post) => void; onReply: (msg: Post) => void;
pendingSize?: number;
showOurContact: boolean;
}; };
interface ChatWindowState { interface ChatWindowState {
@ -55,6 +60,7 @@ interface ChatWindowState {
const virtScrollerStyle = { height: '100%' }; const virtScrollerStyle = { height: '100%' };
class ChatWindow extends Component< class ChatWindow extends Component<
ChatWindowProps, ChatWindowProps,
ChatWindowState ChatWindowState
@ -81,6 +87,7 @@ class ChatWindow extends Component<
this.handleWindowBlur = this.handleWindowBlur.bind(this); this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this); this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.stayLockedIfActive = this.stayLockedIfActive.bind(this); this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
this.fetchMessages = this.fetchMessages.bind(this);
this.virtualList = null; this.virtualList = null;
this.unreadMarkerRef = React.createRef(); this.unreadMarkerRef = React.createRef();
@ -109,9 +116,11 @@ class ChatWindow extends Component<
} }
const unreadIndex = graph.keys()[unreadCount]; const unreadIndex = graph.keys()[unreadCount];
if (!unreadIndex || unreadCount === 0) { if (!unreadIndex || unreadCount === 0) {
this.setState({ if(state.unreadIndex.neq(bigInt.zero)) {
unreadIndex: bigInt.zero this.setState({
}); unreadIndex: bigInt.zero
});
}
return; return;
} }
this.setState({ this.setState({
@ -122,8 +131,8 @@ class ChatWindow extends Component<
dismissedInitialUnread() { dismissedInitialUnread() {
const { unreadCount, graph } = this.props; const { unreadCount, graph } = this.props;
return this.state.unreadIndex.neq(bigInt.zero) && return this.state.unreadIndex.eq(bigInt.zero) ? unreadCount > graph.size :
this.state.unreadIndex.neq(graph.keys()?.[unreadCount]?.[0] ?? bigInt.zero); this.state.unreadIndex.neq(graph.keys()?.[unreadCount]?.[0] ?? bigInt.zero);
} }
handleWindowBlur() { handleWindowBlur() {
@ -138,7 +147,7 @@ class ChatWindow extends Component<
} }
componentDidUpdate(prevProps: ChatWindowProps, prevState) { componentDidUpdate(prevProps: ChatWindowProps, prevState) {
const { history, graph, unreadCount, graphSize, station } = this.props; const { graph, unreadCount, graphSize, station } = this.props;
if(unreadCount === 0 && prevProps.unreadCount !== unreadCount) { if(unreadCount === 0 && prevProps.unreadCount !== unreadCount) {
this.unreadSet = true; this.unreadSet = true;
} }
@ -195,31 +204,35 @@ class ChatWindow extends Component<
this.props.api.hark.markCountAsRead(association, '/', 'message'); this.props.api.hark.markCountAsRead(association, '/', 'message');
} }
setActive = () => {
if(this.state.idle) {
this.setState({ idle: false });
}
}
fetchMessages = async (newer: boolean): Promise<boolean> => { async fetchMessages(newer: boolean): Promise<boolean> {
const { api, station, graph } = this.props; const { api, station, graph } = this.props;
const pageSize = 100; const pageSize = 100;
const [, , ship, name] = station.split('/'); const [, , ship, name] = station.split('/');
const expectedSize = graph.size + pageSize; const expectedSize = graph.size + pageSize;
if (newer) { if (newer) {
const [index] = graph.peekLargest()!; const index = graph.peekLargest()?.[0];
if(!index) {
console.log(`no index for: ${graph}`);
return true;
}
await api.graph.getYoungerSiblings( await api.graph.getYoungerSiblings(
ship, ship,
name, name,
pageSize, pageSize,
`/${index.toString()}` `/${index.toString()}`
); );
return expectedSize !== graph.size; return expectedSize !== getCurrGraphSize(ship.slice(1), name);
} else { } else {
const [index] = graph.peekSmallest()!; console.log('x');
const index = graph.peekSmallest()?.[0];
if(!index) {
console.log(`no index for: ${graph}`);
return true;
}
await api.graph.getOlderSiblings(ship, name, pageSize, `/${index.toString()}`); await api.graph.getOlderSiblings(ship, name, pageSize, `/${index.toString()}`);
const done = expectedSize !== graph.size; const done = expectedSize !== getCurrGraphSize(ship.slice(1), name);
if(done) { if(done) {
this.calculateUnreadIndex(); this.calculateUnreadIndex();
} }
@ -238,12 +251,9 @@ class ChatWindow extends Component<
const { const {
api, api,
association, association,
group,
showOurContact, showOurContact,
graph, graph,
history, group,
groups,
associations,
onReply onReply
} = this.props; } = this.props;
const { unreadMarkerRef } = this; const { unreadMarkerRef } = this;
@ -252,10 +262,8 @@ class ChatWindow extends Component<
group, group,
showOurContact, showOurContact,
unreadMarkerRef, unreadMarkerRef,
history,
api, api,
groups, group,
associations,
onReply onReply
}; };
@ -275,10 +283,10 @@ class ChatWindow extends Component<
graph.peekLargest()?.[0] ?? bigInt.zero graph.peekLargest()?.[0] ?? bigInt.zero
); );
const highlighted = index.eq(this.props.scrollTo ?? bigInt.zero); const highlighted = index.eq(this.props.scrollTo ?? bigInt.zero);
const keys = graph.keys().reverse(); const keys = graph.keys();
const graphIdx = keys.findIndex((idx) => idx.eq(index)); const graphIdx = keys.findIndex((idx) => idx.eq(index));
const prevIdx = keys[graphIdx + 1]; const prevIdx = keys[graphIdx - 1];
const nextIdx = keys[graphIdx - 1]; const nextIdx = keys[graphIdx + 1];
const isLastRead: boolean = this.state.unreadIndex.eq(index); const isLastRead: boolean = this.state.unreadIndex.eq(index);
const props = { const props = {
highlighted, highlighted,
@ -308,12 +316,8 @@ class ChatWindow extends Component<
association, association,
group, group,
graph, graph,
history,
groups,
associations,
showOurContact, showOurContact,
pendingSize, pendingSize = 0,
onReply,
} = this.props; } = this.props;
const unreadMarkerRef = this.unreadMarkerRef; const unreadMarkerRef = this.unreadMarkerRef;
@ -321,16 +325,10 @@ class ChatWindow extends Component<
association, association,
group, group,
unreadMarkerRef, unreadMarkerRef,
history,
api, api,
associations
}; };
const unreadMsg = graph.get(this.state.unreadIndex); const unreadMsg = graph.get(this.state.unreadIndex);
// hack to force a re-render when we toggle showing contact
const contactsModified =
showOurContact ? 0 : 100;
return ( return (
<Col height='100%' overflow='hidden' position='relative'> <Col height='100%' overflow='hidden' position='relative'>
{ this.dismissedInitialUnread() && { this.dismissedInitialUnread() &&
@ -353,12 +351,11 @@ class ChatWindow extends Component<
offset={unreadCount} offset={unreadCount}
origin='bottom' origin='bottom'
style={virtScrollerStyle} style={virtScrollerStyle}
onStartReached={this.setActive}
onBottomLoaded={this.onBottomLoaded} onBottomLoaded={this.onBottomLoaded}
onScroll={this.onScroll} onScroll={this.onScroll}
data={graph} data={graph}
size={graph.size} size={graph.size}
pendingSize={pendingSize + contactsModified} pendingSize={pendingSize}
id={association.resource} id={association.resource}
averageHeight={22} averageHeight={22}
renderer={this.renderer} renderer={this.renderer}
@ -369,8 +366,5 @@ class ChatWindow extends Component<
} }
} }
export default withState(ChatWindow, [
[useGroupState, ['groups']], export default ChatWindow
[useMetadataState, ['associations']],
[useGraphState, ['pendingSize']]
]);

View File

@ -8,22 +8,11 @@ import Timestamp from '~/views/components/Timestamp';
export const UnreadNotice = (props) => { export const UnreadNotice = (props) => {
const { unreadCount, unreadMsg, dismissUnread, onClick } = props; const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
if (!unreadMsg || unreadCount === 0) { if (unreadCount === 0) {
return null; return null;
} }
const stamp = moment.unix(unreadMsg.post['time-sent'] / 1000); const stamp = unreadMsg && moment.unix(unreadMsg.post['time-sent'] / 1000);
let datestamp = moment
.unix(unreadMsg.post['time-sent'] / 1000)
.format('YYYY.M.D');
const timestamp = moment
.unix(unreadMsg.post['time-sent'] / 1000)
.format('HH:mm');
if (datestamp === moment().format('YYYY.M.D')) {
datestamp = null;
}
return ( return (
<Box <Box
@ -52,15 +41,20 @@ export const UnreadNotice = (props) => {
whiteSpace='pre' whiteSpace='pre'
overflow='hidden' overflow='hidden'
display='flex' display='flex'
cursor='pointer' cursor={unreadMsg ? 'pointer' : null}
onClick={onClick} onClick={onClick}
> >
{unreadCount} new message{unreadCount > 1 ? 's' : ''} since{' '} {unreadCount} new message{unreadCount > 1 ? 's' : ''}
<Timestamp stamp={stamp} color='black' date={true} fontSize={1} /> {unreadMsg && (
<>
{' '}since{' '}
<Timestamp stamp={stamp} color='black' date={true} fontSize={1} />
</>
)}
</Text> </Text>
<Icon <Icon
icon='X' icon='X'
ml='4' ml={unreadMsg ? 4 : 1}
color='black' color='black'
cursor='pointer' cursor='pointer'
textAlign='right' textAlign='right'

View File

@ -23,7 +23,7 @@ type LinkResourceProps = StoreState & {
association: Association; association: Association;
api: GlobalApi; api: GlobalApi;
baseUrl: string; baseUrl: string;
} & RouteComponentProps; };
export function LinkResource(props: LinkResourceProps) { export function LinkResource(props: LinkResourceProps) {
const { const {

View File

@ -6,7 +6,7 @@ import React, {
Component, Component,
} from "react"; } from "react";
import { Col, Text } from "@tlon/indigo-react"; import { Box, Col, Text } from "@tlon/indigo-react";
import bigInt from "big-integer"; import bigInt from "big-integer";
import { Association, Graph, Unreads, Group, Rolodex } from "@urbit/api"; import { Association, Graph, Unreads, Group, Rolodex } from "@urbit/api";
@ -48,7 +48,7 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
return isWriter(group, association.resource); return isWriter(group, association.resource);
} }
renderItem = ({ index, scrollWindow }) => { renderItem = React.forwardRef(({ index, scrollWindow }, ref) => {
const { props } = this; const { props } = this;
const { association, graph, api } = props; const { association, graph, api } = props;
const [, , ship, name] = association.resource.split("/"); const [, , ship, name] = association.resource.split("/");
@ -80,12 +80,14 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
api={api} api={api}
/> />
</Col> </Col>
<LinkItem {...linkProps} /> <LinkItem ref={ref} {...linkProps} />
</React.Fragment> </React.Fragment>
); );
} }
return <LinkItem key={index.toString()} {...linkProps} />; return <Box ref={ref}>
}; <LinkItem ref={ref} key={index.toString()} {...linkProps} />;
</Box>
});
render() { render() {
const { graph, api, association } = this.props; const { graph, api, association } = this.props;
@ -136,4 +138,4 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
} }
} }
export default LinkWindow; export default LinkWindow;

View File

@ -19,7 +19,7 @@ interface LinkItemProps {
node: GraphNode; node: GraphNode;
association: Association; association: Association;
resource: string; api: GlobalApi; group: Group; path: string; } resource: string; api: GlobalApi; group: Group; path: string; }
export const LinkItem = (props: LinkItemProps): ReactElement => { export const LinkItem = React.forwardRef((props: LinkItemProps, ref): ReactElement => {
const { const {
association, association,
node, node,
@ -30,7 +30,6 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
...rest ...rest
} = props; } = props;
const ref = useRef<HTMLDivElement | null>(null);
const remoteRef = useRef<typeof RemoteContent | null>(null); const remoteRef = useRef<typeof RemoteContent | null>(null);
const index = node.post.index.split('/')[1]; const index = node.post.index.split('/')[1];
@ -210,5 +209,5 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
</Row> </Row>
</Box>); </Box>);
}; });

View File

@ -11,7 +11,7 @@ type PublishResourceProps = StoreState & {
association: Association; association: Association;
api: GlobalApi; api: GlobalApi;
baseUrl: string; baseUrl: string;
} & RouteComponentProps; };
export function PublishResource(props: PublishResourceProps) { export function PublishResource(props: PublishResourceProps) {
const { association, api, baseUrl, notebooks } = props; const { association, api, baseUrl, notebooks } = props;

View File

@ -1,4 +1,4 @@
import React, { Component, useCallback } from 'react'; import React, { Component, useCallback, SyntheticEvent } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import normalizeWheel from 'normalize-wheel'; import normalizeWheel from 'normalize-wheel';
import bigInt, { BigInteger } from 'big-integer'; import bigInt, { BigInteger } from 'big-integer';
@ -76,7 +76,7 @@ interface VirtualScrollerProps<T> {
} }
interface VirtualScrollerState<T> { interface VirtualScrollerState<T> {
visibleItems: BigIntOrderedMap<T>; visibleItems: BigInteger[];
scrollbar: number; scrollbar: number;
loaded: { loaded: {
top: boolean; top: boolean;
@ -91,10 +91,9 @@ const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) { if(logLevel.includes(level)) {
console.log(`[${level}]: ${message}`); console.log(`[${level}]: ${message}`);
} }
} }
const ZONE_SIZE = IS_IOS ? 10 : 40; const ZONE_SIZE = IS_IOS ? 10 : 80;
// nb: in this file, an index refers to a BigInteger and an offset refers to a // nb: in this file, an index refers to a BigInteger and an offset refers to a
@ -114,7 +113,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
/** /**
* A map of child refs, used to calculate scroll position * A map of child refs, used to calculate scroll position
*/ */
private childRefs = new BigIntOrderedMap<HTMLElement>(); private childRefs = new Map<string, HTMLElement>();
/** /**
* A set of child refs which have been unmounted * A set of child refs which have been unmounted
*/ */
@ -149,7 +148,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
constructor(props: VirtualScrollerProps<T>) { constructor(props: VirtualScrollerProps<T>) {
super(props); super(props);
this.state = { this.state = {
visibleItems: new BigIntOrderedMap(), visibleItems: [],
scrollbar: 0, scrollbar: 0,
loaded: { loaded: {
top: false, top: false,
@ -160,10 +159,11 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
this.updateVisible = this.updateVisible.bind(this); this.updateVisible = this.updateVisible.bind(this);
this.invertedKeyHandler = this.invertedKeyHandler.bind(this); this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 400) : this.onScroll.bind(this); this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 200) : this.onScroll.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this); this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.setWindow = this.setWindow.bind(this); this.setWindow = this.setWindow.bind(this);
this.restore = this.restore.bind(this); this.restore = this.restore.bind(this);
this.startOffset = this.startOffset.bind(this);
} }
componentDidMount() { componentDidMount() {
@ -181,7 +181,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
} }
[...this.orphans].forEach(o => { [...this.orphans].forEach(o => {
const index = bigInt(o); const index = bigInt(o);
this.childRefs.delete(index); this.childRefs.delete(index.toString());
}); });
this.orphans.clear(); this.orphans.clear();
}; };
@ -206,13 +206,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) { componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) {
const { id, size, data, offset, pendingSize } = this.props; const { id, size, data, offset, pendingSize } = this.props;
const { visibleItems } = this.state;
if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) { if(size !== prevProps.size || pendingSize !== prevProps.pendingSize) {
if(this.scrollLocked && visibleItems?.peekLargest() && data?.peekLargest()) { if(this.scrollLocked) {
if(!visibleItems.peekLargest()[0].eq(data.peekLargest()[0])) { this.updateVisible(0);
this.updateVisible(0);
}
this.resetScroll(); this.resetScroll();
} }
} }
@ -228,11 +225,13 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
} }
startOffset() { startOffset() {
const startIndex = this.state?.visibleItems?.peekLargest()?.[0]; const { data } = this.props;
const startIndex = this.state.visibleItems?.[0];
if(!startIndex) { if(!startIndex) {
return 0; return 0;
} }
const offset = [...this.props.data].findIndex(([i]) => i.eq(startIndex)) const dataList = Array.from(data);
const offset = dataList.findIndex(([i]) => i.eq(startIndex))
if(offset === -1) { if(offset === -1) {
// TODO: revisit when we remove nodes for any other reason than // TODO: revisit when we remove nodes for any other reason than
// pending indices being removed // pending indices being removed
@ -252,19 +251,17 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
log('reflow', `from: ${this.startOffset()} to: ${newOffset}`); log('reflow', `from: ${this.startOffset()} to: ${newOffset}`);
const { data, onCalculateVisibleItems } = this.props; const { data, onCalculateVisibleItems } = this.props;
const visibleItems = new BigIntOrderedMap<any>( const visibleItems = data.keys().slice(newOffset, newOffset + this.pageSize);
[...data].slice(newOffset, newOffset + this.pageSize)
);
this.save(); this.save();
this.setState({ this.setState({
visibleItems, visibleItems,
}, () => {
requestAnimationFrame(() => {
this.restore();
});
}); });
requestAnimationFrame(() => {
this.restore();
});
} }
scrollKeyMap(): Map<string, number> { scrollKeyMap(): Map<string, number> {
@ -296,7 +293,6 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
setWindow(element) { setWindow(element) {
if (!element) if (!element)
return; return;
console.log('resetting window');
this.save(); this.save();
if (this.window) { if (this.window) {
@ -309,8 +305,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const { averageHeight } = this.props; const { averageHeight } = this.props;
this.window = element; this.window = element;
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 5.5)); this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 2));
this.pageDelta = Math.floor(this.pageSize / 3); this.pageDelta = Math.floor(this.pageSize / 4);
if (this.props.origin === 'bottom') { if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => { element.addEventListener('wheel', (event) => {
event.preventDefault(); event.preventDefault();
@ -356,7 +352,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
} }
}; };
onScroll(event: UIEvent) { onScroll(event: SyntheticEvent<HTMLElement, ScrollEvent>) {
this.updateScroll(); this.updateScroll();
if(!this.window) { if(!this.window) {
// bail if we're going to adjust scroll anyway // bail if we're going to adjust scroll anyway
@ -371,6 +367,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const { scrollTop, scrollHeight } = this.window; const { scrollTop, scrollHeight } = this.window;
const startOffset = this.startOffset(); const startOffset = this.startOffset();
const scrollEnd = scrollTop + windowHeight;
if (scrollTop < ZONE_SIZE) { if (scrollTop < ZONE_SIZE) {
log('scroll', `Entered start zone ${scrollTop}`); log('scroll', `Entered start zone ${scrollTop}`);
if (startOffset === 0) { if (startOffset === 0) {
@ -415,30 +413,37 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
log('bail', 'Deep restore'); log('bail', 'Deep restore');
return; return;
} }
if(this.scrollLocked) { if(this.scrollLocked) {
this.resetScroll(); this.resetScroll();
this.savedIndex = null; requestAnimationFrame(() => {
this.savedDistance = 0; this.savedIndex = null;
this.saveDepth--; this.savedDistance = 0;
this.saveDepth--;
});
return; return;
} }
let ref = this.childRefs.get(this.savedIndex) let ref = this.childRefs.get(this.savedIndex.toString())
if(!ref) { if(!ref) {
return; return;
} }
const newScrollTop = this.window.scrollHeight - ref.offsetTop - this.savedDistance;
const newScrollTop = this.props.origin === 'top'
? this.savedDistance + ref.offsetTop
: this.window.scrollHeight - ref.offsetTop - this.savedDistance;
this.window.scrollTo(0, newScrollTop); this.window.scrollTo(0, newScrollTop);
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.savedIndex = null; this.savedIndex = null;
this.savedDistance = 0; this.savedDistance = 0;
this.saveDepth--; this.saveDepth--;
}); });
} }
scrollToIndex = (index: BigInteger) => { scrollToIndex = (index: BigInteger) => {
let ref = this.childRefs.get(index); let ref = this.childRefs.get(index.toString());
if(!ref) { if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index)); const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index));
if(offset === -1) { if(offset === -1) {
@ -446,7 +451,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
} }
this.updateVisible(Math.max(offset - this.pageDelta, 0)); this.updateVisible(Math.max(offset - this.pageDelta, 0));
requestAnimationFrame(() => { requestAnimationFrame(() => {
ref = this.childRefs.get(index); ref = this.childRefs.get(index.toString());
this.savedIndex = null; this.savedIndex = null;
this.savedDistance = 0; this.savedDistance = 0;
this.saveDepth = 0; this.saveDepth = 0;
@ -467,17 +472,20 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
return; return;
} }
if(this.saveDepth !== 0) { if(this.saveDepth !== 0) {
console.log('bail', 'deep save');
return; return;
} }
this.saveDepth++; log('scroll', 'saving...');
let bottomIndex: BigInteger | null = null; this.saveDepth++;
const { visibleItems } = this.state;
let bottomIndex = visibleItems[visibleItems.length - 1];
const { scrollTop, scrollHeight } = this.window; const { scrollTop, scrollHeight } = this.window;
const topSpacing = scrollHeight - scrollTop; const topSpacing = this.props.origin === 'top' ? scrollTop : scrollHeight - scrollTop;
[...Array.from(this.state.visibleItems)].reverse().forEach(([index, datum]) => { const items = this.props.origin === 'top' ? visibleItems : [...visibleItems].reverse();
const el = this.childRefs.get(index); items.forEach((index) => {
const el = this.childRefs.get(index.toString());
if(!el) { if(!el) {
return; return;
} }
@ -490,20 +498,22 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
if(!bottomIndex) { if(!bottomIndex) {
// weird, shouldn't really happen // weird, shouldn't really happen
this.saveDepth--; this.saveDepth--;
log('bail', 'no index found');
return; return;
} }
this.savedIndex = bottomIndex; this.savedIndex = bottomIndex;
const ref = this.childRefs.get(bottomIndex)!; const ref = this.childRefs.get(bottomIndex.toString())!;
const { offsetTop } = ref; const { offsetTop } = ref;
this.savedDistance = topSpacing - offsetTop this.savedDistance = topSpacing - offsetTop
} }
shiftLayout = { save: this.save.bind(this), restore: this.restore.bind(this) }; // disabled until we work out race conditions with loading new nodes
shiftLayout = { save: () => {}, restore: () => {} };
setRef = (element: HTMLElement | null, index: BigInteger) => { setRef = (element: HTMLElement | null, index: BigInteger) => {
if(element) { if(element) {
this.childRefs.set(index, element); this.childRefs.set(index.toString(), element);
this.orphans.delete(index.toString()); this.orphans.delete(index.toString());
} else { } else {
this.orphans.add(index.toString()); this.orphans.add(index.toString());
@ -525,12 +535,11 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const isTop = origin === 'top'; const isTop = origin === 'top';
const indexesToRender = isTop ? visibleItems.keys() : visibleItems.keys().reverse();
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)'; const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const children = isTop ? visibleItems : [...visibleItems].reverse();
const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems.peekLargest()?.[0] || bigInt.zero); const atStart = (this.props.data.peekLargest()?.[0] ?? bigInt.zero).eq(visibleItems?.[0] || bigInt.zero);
const atEnd = this.state.loaded.top; const atEnd = (this.props.data.peekSmallest()?.[0] ?? bigInt.zero).eq(visibleItems?.[visibleItems.length -1 ] || bigInt.zero);
return ( return (
<> <>
@ -542,7 +551,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
<LoadingSpinner /> <LoadingSpinner />
</Center>)} </Center>)}
<VirtualContext.Provider value={this.shiftLayout}> <VirtualContext.Provider value={this.shiftLayout}>
{indexesToRender.map(index => ( {children.map(index => (
<VirtualChild <VirtualChild
key={index.toString()} key={index.toString()}
setRef={this.setRef} setRef={this.setRef}
@ -575,8 +584,10 @@ function VirtualChild(props: VirtualChildProps) {
const ref = useCallback((el: HTMLElement | null) => { const ref = useCallback((el: HTMLElement | null) => {
setRef(el, props.index); setRef(el, props.index);
}, [setRef, 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} />); return <Renderer ref={ref} {...rest} />
}; };

View File

@ -124,7 +124,6 @@ export function GroupsPane(props: GroupsPaneProps) {
> >
<Resource <Resource
{...props} {...props}
{...routeProps}
association={association} association={association}
baseUrl={baseUrl} baseUrl={baseUrl}
/> />

View File

@ -2,7 +2,7 @@ import React from 'react';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import VirtualScroller from "~/views/components/VirtualScroller"; import VirtualScroller from "~/views/components/VirtualScroller";
import PostItem from './PostItem/PostItem'; import PostItem from './PostItem/PostItem';
import { Col } from '@tlon/indigo-react'; import { Col, Box } from '@tlon/indigo-react';
import { resourceFromPath } from '~/logic/lib/group'; import { resourceFromPath } from '~/logic/lib/group';
@ -15,102 +15,103 @@ export class PostFeed extends React.Component {
super(props); super(props);
this.isFetching = false; this.isFetching = false;
this.renderItem = React.forwardRef(({ index, scrollWindow }, ref) => {
const {
graph,
graphPath,
api,
history,
baseUrl,
parentNode,
grandparentNode,
association,
group,
vip
} = this.props;
const graphResource = resourceFromPath(graphPath);
const node = graph.get(index);
if (!node) { return null; }
const first = graph.peekLargest()?.[0];
const post = node?.post;
if (!node || !post) {
return null;
}
let nodeIndex = parentNode ? 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()}
mb="3"
width="100%"
flexShrink={0}
>
<PostItem
key={parentNode.post.index}
ref={ref}
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
ref={ref}
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>
);
}
return (
<PostItem
key={index.toString()}
ref={ref}
node={node}
graphPath={graphPath}
association={association}
api={api}
index={[...nodeIndex, index]}
baseUrl={baseUrl}
history={history}
parentPost={parentNode?.post}
isReply={!!parentNode}
isRelativeTime={true}
vip={vip}
group={group}
/>
);
});
this.fetchPosts = this.fetchPosts.bind(this); this.fetchPosts = this.fetchPosts.bind(this);
this.doNotFetch = this.doNotFetch.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 graphResource = resourceFromPath(graphPath);
const node = graph.get(index);
if (!node) { return null; }
const first = graph.peekLargest()?.[0];
const post = node?.post;
if (!node || !post) {
return null;
}
let nodeIndex = parentNode ? 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>
);
}
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={!!parentNode}
isRelativeTime={true}
vip={vip}
group={group}
/>
</Box>
);
});
async fetchPosts(newer) { async fetchPosts(newer) {
const { graph, graphPath, api } = this.props; const { graph, graphPath, api } = this.props;
const graphResource = resourceFromPath(graphPath); const graphResource = resourceFromPath(graphPath);

View File

@ -15,12 +15,14 @@ import useGroupState from '~/logic/state/group';
import useContactState from '~/logic/state/contact'; import useContactState from '~/logic/state/contact';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
import useMetadataState from '~/logic/state/metadata'; import useMetadataState from '~/logic/state/metadata';
import {Workspace} from '~/types';
type ResourceProps = StoreState & { type ResourceProps = StoreState & {
association: Association; association: Association;
api: GlobalApi; api: GlobalApi;
baseUrl: string; baseUrl: string;
} & RouteComponentProps; workspace: Workspace;
};
export function Resource(props: ResourceProps): ReactElement { export function Resource(props: ResourceProps): ReactElement {
const { association, api, notificationsGraphConfig } = props; const { association, api, notificationsGraphConfig } = props;

View File

@ -1,6 +1,10 @@
import { immerable } from 'immer'; import produce, { immerable, castImmutable, castDraft, setAutoFreeze, enablePatches } from 'immer';
import bigInt, { BigInteger } from "big-integer"; import bigInt, { BigInteger } from "big-integer";
setAutoFreeze(false);
enablePatches();
function sortBigInt(a: BigInteger, b: BigInteger) { function sortBigInt(a: BigInteger, b: BigInteger) {
if (a.lt(b)) { if (a.lt(b)) {
return 1; return 1;
@ -11,19 +15,18 @@ function sortBigInt(a: BigInteger, b: BigInteger) {
} }
} }
export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> { export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
private root: Record<string, V> = {} root: Record<string, V> = {}
private cachedIter: [BigInteger, V][] | null = null; cachedIter: [BigInteger, V][] = [];
[immerable] = true; [immerable] = true;
constructor(items: [BigInteger, V][] = []) { constructor(items: [BigInteger, V][] = []) {
items.forEach(([key, val]) => { items.forEach(([key, val]) => {
this.set(key, val); this.set(key, val);
}); });
this.generateCachedIter();
} }
get size() { get size() {
return this.cachedIter?.length ?? Object.keys(this.root).length; return Object.keys(this.root).length;
} }
@ -31,14 +34,30 @@ export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
return this.root[key.toString()] ?? null; return this.root[key.toString()] ?? null;
} }
gas(items: [BigInteger, V][]) {
return produce(this, draft => {
items.forEach(([key, value]) => {
draft.root[key.toString()] = castDraft(value);
});
draft.generateCachedIter();
},
(patches) => {
//console.log(`gassed with ${JSON.stringify(patches, null, 2)}`);
});
}
set(key: BigInteger, value: V) { set(key: BigInteger, value: V) {
this.root[key.toString()] = value; return produce(this, draft => {
this.cachedIter = null; draft.root[key.toString()] = castDraft(value);
draft.generateCachedIter();
});
} }
clear() { clear() {
this.cachedIter = null; return produce(this, draft => {
this.root = {} draft.cachedIter = [];
draft.root = {}
});
} }
has(key: BigInteger) { has(key: BigInteger) {
@ -46,17 +65,15 @@ export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
} }
delete(key: BigInteger) { delete(key: BigInteger) {
const had = this.has(key); return produce(this, draft => {
if(had) { delete draft.root[key.toString()];
delete this.root[key.toString()]; draft.cachedIter = draft.cachedIter.filter(([x]) => x.eq(key));
this.cachedIter = null; });
}
return had;
} }
[Symbol.iterator](): IterableIterator<[BigInteger, V]> { [Symbol.iterator](): IterableIterator<[BigInteger, V]> {
let idx = 0; let idx = 0;
const result = this.generateCachedIter(); let result = [...this.cachedIter];
return { return {
[Symbol.iterator]: this[Symbol.iterator], [Symbol.iterator]: this[Symbol.iterator],
next: (): IteratorResult<[BigInteger, V]> => { next: (): IteratorResult<[BigInteger, V]> => {
@ -79,19 +96,15 @@ export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
} }
keys() { keys() {
return Object.keys(this.root).map(k => bigInt(k)).sort(sortBigInt) return Array.from(this).map(([k,v]) => k);
} }
private generateCachedIter() { generateCachedIter() {
if(this.cachedIter) {
return this.cachedIter;
}
const result = Object.keys(this.root).map(key => { const result = Object.keys(this.root).map(key => {
const num = bigInt(key); const num = bigInt(key);
return [num, this.root[key]] as [BigInteger, V]; return [num, this.root[key]] as [BigInteger, V];
}).sort(([a], [b]) => sortBigInt(a,b)); }).sort(([a], [b]) => sortBigInt(a,b));
this.cachedIter = result; this.cachedIter = result;
return result;
} }
} }