chat-fe: convert virtualscroller to bigInt

This commit is contained in:
Liam Fitzgerald 2020-11-23 13:48:36 +10:00
parent 6bb62d802b
commit a1cf88faba
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
7 changed files with 162 additions and 85 deletions

View File

@ -170,6 +170,42 @@ export class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
};
return inner(nod);
}
peekLargest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>) => {
if(!node) {
return undefined;
}
if(node.l) {
return inner(node.l);
}
return node.n;
}
return inner(this.root);
}
peekSmallest(): [BigInteger, V] | undefined {
const inner = (node: MapNode<V>) => {
if(!node) {
return undefined;
}
if(node.r) {
return inner(node.r);
}
return node.n;
}
return inner(this.root);
}
keys(): BigInteger[] {
const list = Array.from(this);
return list.map(([key]) => key);
}
forEach(f: (value: V, key: BigInteger) => void) {
const list = Array.from(this);
return list.forEach(([k,v]) => f(v,k));
}
[Symbol.iterator](): IterableIterator<[BigInteger, V]> {
let result: [BigInteger, V][] = [];

View File

@ -0,0 +1,10 @@
import bigInt, { BigInteger } from "big-integer";
export function max(a: BigInteger, b: BigInteger) {
return a.gt(b) ? a : b;
}
export function min(a: BigInteger, b: BigInteger) {
return a.lt(b) ? a : b;
}

View File

@ -14,7 +14,7 @@ import RemoteContent from '~/views/components/RemoteContent';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
<Row ref={ref} color='blue' alignItems='center' fontSize='0' position='absolute' width='100%' py='2'>
<Row flexShrink={0} ref={ref} color='blue' alignItems='center' fontSize='0' position='absolute' width='100%' py='2'>
<Rule borderColor='blue' display={['none', 'block']} m='0' width='2rem' />
<Text flexShrink='0' display='block' zIndex='2' mx='4' color='blue'>New messages below</Text>
<Rule borderColor='blue' flexGrow='1' m='0'/>
@ -130,6 +130,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
return (
<Box
bg={highlighted ? 'washedBlue' : 'white'}
flexShrink={0}
width='100%'
display='flex'
flexWrap='wrap'
@ -146,7 +147,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
{renderSigil
? <MessageWithSigil {...messageProps} />
: <MessageWithoutSigil {...messageProps} />}
<Box fontSize={0} position='relative' width='100%' overflow='visible' style={unreadContainerStyle}>{isLastRead
<Box flexShrink={0} fontSize={0} position='relative' width='100%' overflow='visible' style={unreadContainerStyle}>{isLastRead
? <UnreadMarker dayBreak={dayBreak} when={msg.when} ref={unreadMarkerRef} />
: null}</Box>
</Box>
@ -222,10 +223,11 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
scrollWindow={scrollWindow}
history={history}
api={api}
className="fl pr3 v-top pt1"
className="fl pr3 v-top pt1 flex-shrink-0"
/>
<Box flexGrow='1' display='block' className="clamp-message">
<Box flexShrink={0} flexGrow='1' display='block' className="clamp-message">
<Box
flexShrink={0}
className="hide-child"
pt={1}
pb={1}
@ -235,6 +237,7 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
<Text
fontSize={0}
mr={3}
flexShrink={0}
mono={!showNickname}
fontWeight={(showNickname) ? '500' : '400'}
className={`mw5 db truncate pointer`}
@ -246,9 +249,9 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
title={`~${msg.author}`}
>{name}</Text>
<Text flexShrink='0' gray mono className="v-mid">{timestamp}</Text>
<Text gray mono ml={2} className="v-mid child dn-s">{datestamp}</Text>
<Text flexShrink={0} gray mono ml={2} className="v-mid child dn-s">{datestamp}</Text>
</Box>
<Box fontSize={fontSize ? fontSize : '14px'}><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} fontSize={fontSize} /></Box>
<Box flexShrink={0} fontSize={fontSize ? fontSize : '14px'}><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} fontSize={fontSize} /></Box>
</Box>
</>
);
@ -257,8 +260,8 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
export const MessageWithoutSigil = ({ timestamp, msg, remoteContentPolicy, measure }) => (
<>
<Text mono gray display='inline-block' pt='2px' lineHeight='tall' className="child">{timestamp}</Text>
<Box fontSize='14px' className="clamp-message" style={{ flexGrow: 1 }}>
<Text flexShrink={0} mono gray display='inline-block' pt='2px' lineHeight='tall' className="child">{timestamp}</Text>
<Box flexShrink={0} fontSize='14px' className="clamp-message" style={{ flexGrow: 1 }}>
<MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure}/>
</Box>
</>
@ -269,7 +272,7 @@ export const MessageContent = ({ content, remoteContentPolicy, measure, fontSize
return <CodeContent content={content} />;
} else if ('url' in content) {
return (
<Text fontSize={fontSize ? fontSize : '14px'} lineHeight="tall" color='black'>
<Text flexShrink={0} fontSize={fontSize ? fontSize : '14px'} lineHeight="tall" color='black'>
<RemoteContent
url={content.url}
remoteContentPolicy={remoteContentPolicy}
@ -285,7 +288,7 @@ export const MessageContent = ({ content, remoteContentPolicy, measure, fontSize
);
} else if ('me' in content) {
return (
<Text fontStyle='italic' fontSize={fontSize ? fontSize : '14px'} lineHeight='tall' color='black'>
<Text flexShrink={0} fontStyle='italic' fontSize={fontSize ? fontSize : '14px'} lineHeight='tall' color='black'>
{content.me}
</Text>
);

View File

@ -1,6 +1,7 @@
import React, { Component } from "react";
import { RouteComponentProps } from "react-router-dom";
import _ from "lodash";
import bigInt, { BigInteger } from 'big-integer';
import GlobalApi from "~/logic/api/global";
import { Patp, Path } from "~/types/noun";
@ -9,6 +10,7 @@ import { Association } from "~/types/metadata-update";
import { Group } from "~/types/group-update";
import { Envelope, IMessage } from "~/types/chat-update";
import { LocalUpdateRemoteContentPolicy } from "~/types";
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
import VirtualScroller from "~/views/components/VirtualScroller";
@ -66,7 +68,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
fetchPending: false,
idle: true,
initialized: false,
lastRead: props.unreadCount ? props.mailboxSize - props.unreadCount : Infinity,
lastRead: props.unreadCount ? props.mailboxSize - props.unreadCount : -1,
};
this.dismissUnread = this.dismissUnread.bind(this);
@ -143,7 +145,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
if (unreadCount > prevProps.unreadCount && this.state.idle) {
this.setState({
lastRead: unreadCount ? mailboxSize - unreadCount : Infinity,
lastRead: unreadCount ? mailboxSize - unreadCount : -1,
});
}
@ -159,7 +161,7 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
this.virtualList?.resetScroll();
this.scrollToUnread();
this.setState({
lastRead: unreadCount ? mailboxSize - unreadCount : Infinity,
lastRead: unreadCount ? mailboxSize - unreadCount : -1,
});
}
}
@ -254,21 +256,22 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
const unreadMarkerRef = this.unreadMarkerRef;
const messages = new Map();
const messages = new BigIntOrderedMap();
let lastMessage = 0;
[...envelopes]
.sort((a, b) => a.number - b.number)
.forEach(message => {
messages.set(message.number, message);
const num = bigInt(message.number);
messages.set(num, message);
lastMessage = message.number;
});
stationPendingMessages
.sort((a, b) => a.when - b.when)
.forEach((message, index) => {
index = index + 1; // To 1-index it
messages.set(mailboxSize + index, message);
const idx = bigInt(index + 1); // To 1-index it
messages.set(bigInt(mailboxSize).add(idx), message);
lastMessage = mailboxSize + index;
});
@ -299,24 +302,24 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
const msg: Envelope | IMessage = messages.get(index);
if (!msg) return null;
if (!this.state.initialized) {
return <MessagePlaceholder key={index} height="64px" index={index} />;
return <MessagePlaceholder key={index.toString()} height="64px" index={index} />;
}
const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
const isLastMessage: boolean = Boolean(index === lastMessage)
const isLastRead: boolean = Boolean(!isLastMessage && index === this.state.lastRead);
const highlighted = index === this.props.scrollTo;
const isLastMessage: boolean = Boolean(index.eq(bigInt(lastMessage)));
const isLastRead: boolean = Boolean(!isLastMessage && index.eq(bigInt(this.state.lastRead)));
const highlighted = bigInt(this.props.scrollTo || -1).eq(index);
const props = { measure, highlighted, scrollWindow, isPending, isLastRead, isLastMessage, msg, ...messageProps };
return (
<ChatMessage
key={index}
previousMsg={messages.get(index + 1)}
nextMsg={messages.get(index - 1)}
key={index.toString()}
previousMsg={messages.get(index.add(bigInt.one))}
nextMsg={messages.get(index.subtract(bigInt.one))}
{...props}
/>
);
}}
loadRows={(start, end) => {
this.fetchMessages(start, end);
this.fetchMessages(start.toJSNumber(), end.toJSNumber());
}}
/>
</>

View File

@ -83,7 +83,7 @@ export default class TextContent extends Component {
&& (urbitOb.isValidPatp(group[2]) // valid patp?
&& (group[0] === content.text))) { // entire message is room name?
return (
<Text fontSize={props.fontSize ? props.fontSize : '14px'} color='black' lineHeight="tall">
<Text flexShrink={0} fontSize={props.fontSize ? props.fontSize : '14px'} color='black' lineHeight="tall">
<Link
className="bb b--black b--white-d mono"
to={'/~landscape/join/' + group.input}>
@ -93,7 +93,7 @@ export default class TextContent extends Component {
);
} else {
return (
<Text color='black' fontSize={props.fontSize ? props.fontSize : '14px'} lineHeight="tall" style={{ overflowWrap: 'break-word' }}>
<Text flexShrink={0} color='black' fontSize={props.fontSize ? props.fontSize : '14px'} lineHeight="tall" style={{ overflowWrap: 'break-word' }}>
<MessageMarkdown source={content.text} />
</Text>
);

View File

@ -72,6 +72,7 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
wrapInLink(contents) {
return (<BaseAnchor
href={this.props.url}
flexShrink={0}
style={{ color: 'inherit', textDecoration: 'none' }}
className={`word-break-all ${(typeof contents === 'string') ? 'bb' : ''}`}
target="_blank"
@ -103,6 +104,7 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
if (isImage && remoteContentPolicy.imageShown) {
return this.wrapInLink(
<BaseImage
flexShrink={0}
src={url}
style={style}
onLoad={onLoad}
@ -153,6 +155,7 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
height={3}
ml={1}
onClick={this.unfoldEmbed}
flexShrink={0}
style={{ cursor: 'pointer' }}
>
{this.state.unfold ? 'collapse' : 'expand'}
@ -160,9 +163,11 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
<Box
mb='2'
width='100%'
flexShrink={0}
display={this.state.unfold ? 'block' : 'none'}
className='embed-container'
style={style}
flexShrink={0}
onLoad={onLoad}
{...oembedProps}
{...props}

View File

@ -1,24 +1,33 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
import normalizeWheel from 'normalize-wheel';
import { Box } from '@tlon/indigo-react';
import bigInt, { BigInteger } from 'big-integer';
import * as bigIntUtils from '~/logic/lib/bigInt';
interface RendererProps {
index: BigInteger;
measure: (el: any) => void;
scrollWindow: any
}
interface VirtualScrollerProps {
origin: 'top' | 'bottom';
loadRows(start: number, end: number): void;
data: Map<number, any>;
renderer(index): JSX.Element | null;
loadRows(start: BigInteger, end: BigInteger): void;
data: BigIntOrderedMap<BigInteger, any>;
renderer: (props: RendererProps) => JSX.Element | null;
onStartReached?(): void;
onEndReached?(): void;
size: number;
onCalculateVisibleItems?(visibleItems: Map<number, JSX.Element>): void;
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<BigInteger, JSX.Element>): void;
onScroll?({ scrollTop, scrollHeight, windowHeight }): void;
style?: any;
}
interface VirtualScrollerState {
startgap: number | undefined;
visibleItems: Map<number, Element>;
visibleItems: BigIntOrderedMap<BigInteger, Element>;
endgap: number | undefined;
totalHeight: number;
averageHeight: number;
@ -28,29 +37,29 @@ interface VirtualScrollerState {
export default class VirtualScroller extends PureComponent<VirtualScrollerProps, VirtualScrollerState> {
private scrollContainer: React.RefObject<HTMLDivElement>;
public window: HTMLDivElement | null;
private cache: Map<number, any>;
private cache: BigIntOrderedMap<BigInteger, any>;
private pendingLoad: {
start: number;
end: number
start: BigInteger;
end: BigInteger
timeout: ReturnType<typeof setTimeout>;
} | undefined;
OVERSCAN_SIZE = 100; // Minimum number of messages on either side before loadRows is called
constructor(props) {
constructor(props: VirtualScrollerProps) {
super(props);
this.state = {
startgap: props.origin === 'top' ? 0 : undefined,
visibleItems: new Map(),
visibleItems: new BigIntOrderedMap(),
endgap: props.origin === 'bottom' ? 0 : undefined,
totalHeight: 0,
averageHeight: 64,
scrollTop: props.origin === 'top' ? 0 : Infinity
scrollTop: props.origin === 'top' ? 0 : undefined
};
this.scrollContainer = React.createRef();
this.window = null;
this.cache = new Map();
this.cache = new BigIntOrderedMap();
this.recalculateTotalHeight = this.recalculateTotalHeight.bind(this);
this.calculateVisibleItems = this.calculateVisibleItems.bind(this);
@ -75,18 +84,18 @@ export default class VirtualScroller extends PureComponent<VirtualScrollerProps,
} = this;
}
scrollToData(targetIndex: number): Promise<void> {
scrollToData(targetIndex: BigInteger): Promise<void> {
if (!this.window) {
return new Promise((resolve, reject) => {reject()});
}
const { offsetHeight } = this.window;
let scrollTop = 0;
let itemHeight = 0;
new Map([...this.props.data].reverse()).forEach((datum, index) => {
new BigIntOrderedMap([...this.props.data].reverse()).forEach((datum, index) => {
const height = this.heightOf(index);
if (index >= targetIndex) {
if (index.geq(targetIndex)) {
scrollTop += height;
if (index === targetIndex) {
if (index.eq(targetIndex)) {
itemHeight = height;
}
}
@ -105,20 +114,20 @@ export default class VirtualScroller extends PureComponent<VirtualScrollerProps,
this.setState({ totalHeight, averageHeight });
}
estimateIndexFromScrollTop(targetScrollTop: number): number | void {
if (!this.window) return;
let index = this.props.size;
estimateIndexFromScrollTop(targetScrollTop: number): BigInteger | undefined {
if (!this.window) return undefined;
let index = bigInt(this.props.size);
const { averageHeight } = this.state;
let height = 0;
while (height < targetScrollTop) {
const itemHeight = this.cache.has(index) ? this.cache.get(index).height : averageHeight;
height += itemHeight;
index--;
index.subtract(bigInt.one);
}
return index;
}
heightOf(index: number): number {
heightOf(index: BigInteger): number {
return this.cache.has(index) ? this.cache.get(index).height : this.state.averageHeight;
}
@ -126,70 +135,80 @@ export default class VirtualScroller extends PureComponent<VirtualScrollerProps,
if (!this.window) return;
let startgap = 0, heightShown = 0, endgap = 0;
let startGapFilled = false;
let visibleItems = new Map();
let startBuffer = new Map();
let endBuffer = new Map();
let visibleItems = new BigIntOrderedMap<any>();
let startBuffer = new BigIntOrderedMap<any>();
let endBuffer = new BigIntOrderedMap<any>();
const { scrollTop, offsetHeight: windowHeight } = this.window;
const { averageHeight } = this.state;
const { data, size: totalSize, onCalculateVisibleItems } = this.props;
const items = new Map([...data].reverse());
items.forEach((datum, index) => {
//console.log([...items].map(([index]) => this.heightOf(index)));
const list = [...data];
console.log(list[0][0].toString());
// console.log(list[list.length - 1][0].toString());
[...data].forEach(([index, datum]) => {
const height = this.heightOf(index);
if (startgap < scrollTop && !startGapFilled) {
console.log(index.toString());
startBuffer.set(index, datum);
startgap += height;
} else if (heightShown < windowHeight) {
startGapFilled = true;
visibleItems.set(index, datum);
heightShown += height;
} else if (endBuffer.size < visibleItems.size) {
} else if (endBuffer.size < (visibleItems.size - visibleItems.size % 5)) {
endBuffer.set(index, data.get(index));
} else {
endgap += height;
}
});
// endgap += Math.abs(totalSize - data.size) * averageHeight; // Uncomment to make full height of backlog
startBuffer = new Map([...startBuffer].reverse().slice(0, visibleItems.size));
console.log(startgap);
startBuffer.forEach((datum, index) => {
startBuffer = new BigIntOrderedMap([...startBuffer].reverse().slice(0, (visibleItems.size - visibleItems.size % 5)));
startBuffer.forEach((_datum, index) => {
startgap -= this.heightOf(index);
});
visibleItems = new Map([...visibleItems].reverse());
endBuffer = new Map([...endBuffer].reverse());
const firstVisibleKey = Array.from(visibleItems.keys())[0] ?? this.estimateIndexFromScrollTop(scrollTop);
const firstNeededKey = Math.max(firstVisibleKey - this.OVERSCAN_SIZE, 0);
if (!data.has(firstNeededKey + 1)) {
this.loadRows(firstNeededKey, firstVisibleKey - 1);
console.log(startBuffer.size);
console.log(startgap);
const firstVisibleKey = visibleItems.peekLargest()?.[0] ?? this.estimateIndexFromScrollTop(scrollTop)!;
const firstNeededKey = bigIntUtils.max(firstVisibleKey.subtract(bigInt(this.OVERSCAN_SIZE)), bigInt.zero)
if (!data.has(firstNeededKey.add(bigInt.one))) {
this.loadRows(firstNeededKey, firstVisibleKey.subtract(bigInt.one));
}
const lastVisibleKey = Array.from(visibleItems.keys())[visibleItems.size - 1] ?? this.estimateIndexFromScrollTop(scrollTop + windowHeight);
const lastNeededKey = Math.min(lastVisibleKey + this.OVERSCAN_SIZE, totalSize);
if (!data.has(lastNeededKey - 1)) {
this.loadRows(lastVisibleKey + 1, lastNeededKey);
const lastVisibleKey =
visibleItems.peekSmallest()?.[0]
?? bigInt(this.estimateIndexFromScrollTop(scrollTop + windowHeight)!);
const lastNeededKey = bigIntUtils.min(lastVisibleKey.add(bigInt(this.OVERSCAN_SIZE)), bigInt(totalSize));
if (!data.has(lastNeededKey.subtract(bigInt.one))) {
this.loadRows(lastVisibleKey.add(bigInt.one), lastNeededKey);
}
onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null;
this.setState({
startgap: Number(startgap.toFixed()),
visibleItems: new Map([...endBuffer, ...visibleItems, ...startBuffer]),
visibleItems: new BigIntOrderedMap([...startBuffer, ...visibleItems, ...endBuffer]),
endgap: Number(endgap.toFixed()),
});
}
loadRows(start, end) {
if (isNaN(start) || isNaN(end)) return;
loadRows(start: BigInteger, end: BigInteger) {
if (this.pendingLoad?.timeout) {
clearTimeout(this.pendingLoad.timeout);
start = Math.min(start, this.pendingLoad.start);
end = Math.max(end, this.pendingLoad.end);
start = bigIntUtils.min(start, this.pendingLoad.start);
end = bigIntUtils.max(end, this.pendingLoad.end);
}
this.pendingLoad = {
timeout: setTimeout(() => {
if (!this.pendingLoad) return;
start = Math.max(this.pendingLoad.start, 0);
end = Math.min(Math.max(this.pendingLoad.end, 0), this.props.size);
start = bigIntUtils.max(this.pendingLoad.start, bigInt.zero);
end = bigIntUtils.min(bigIntUtils.max(this.pendingLoad.end, bigInt.zero), bigInt(this.props.size));
if (start < end) {
this.props.loadRows(start, end);
}
@ -204,11 +223,11 @@ export default class VirtualScroller extends PureComponent<VirtualScrollerProps,
return new Map([
['ArrowUp', this.state.averageHeight],
['ArrowDown', this.state.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]
['PageUp', this.window!.offsetHeight],
['PageDown', this.window!.offsetHeight * -1],
['Home', this.window!.scrollHeight],
['End', this.window!.scrollHeight * -1],
['Space', this.window!.offsetHeight * -1]
]);
}
@ -217,11 +236,11 @@ export default class VirtualScroller extends PureComponent<VirtualScrollerProps,
if (map.has(event.code) && document.body.isSameNode(document.activeElement)) {
event.preventDefault();
event.stopImmediatePropagation();
let distance = map.get(event.code);
let distance = map.get(event.code)!;
if (event.code === 'Space' && event.shiftKey) {
distance = distance * -1;
}
this.window.scrollBy(0, distance);
this.window!.scrollBy(0, distance);
return false;
}
}
@ -242,12 +261,13 @@ export default class VirtualScroller extends PureComponent<VirtualScrollerProps,
this.window = element;
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
/* 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.resetScroll();
@ -304,12 +324,12 @@ export default class VirtualScroller extends PureComponent<VirtualScrollerProps,
data
} = this.props;
const indexesToRender = Array.from(visibleItems.keys());
const indexesToRender = visibleItems.keys().reverse();
const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const render = (index) => {
const measure = (element) => {
const render = (index: BigInteger) => {
const measure = (element: any) => {
if (element) {
this.cache.set(index, {
height: element.offsetHeight,