virtualscroller: ios viable

This commit is contained in:
Liam Fitzgerald 2021-03-01 12:56:40 +10:00
parent a960234e2c
commit 89f63ac443
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
6 changed files with 129 additions and 62 deletions

View File

@ -0,0 +1,6 @@
const ua = window.navigator.userAgent;
export const IS_IOS = ua.includes('iPhone');
console.log(IS_IOS);

View File

@ -38,7 +38,7 @@ export function ChatResource(props: ChatResourceProps) {
const canWrite = isWriter(group, station);
useEffect(() => {
const count = 100;
const count = 100 + unreadCount;
props.api.graph.getNewest(owner, name, count);
}, [station]);
@ -149,6 +149,7 @@ export function ChatResource(props: ChatResourceProps) {
/>
{dragging && <SubmitDragger />}
<ChatWindow
key={station}
history={props.history}
graph={graph}
unreadCount={unreadCount}

View File

@ -182,6 +182,7 @@ class ChatMessage extends Component<ChatMessageProps> {
pt={renderSigil ? 2 : 0}
pb={isLastMessage ? 4 : 2}
className={containerClass}
backgroundColor={highlighted ? 'blue' : 'white'}
style={style}
>
{dayBreak && !isLastRead ? (
@ -409,6 +410,7 @@ export const Message = ({
color='black'
>
<RemoteContent
key={content.url}
url={content.url}
imageProps={{
style: {

View File

@ -77,7 +77,6 @@ export default class ChatWindow extends Component<
this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
this.dismissIfLineVisible = this.dismissIfLineVisible.bind(this);
this.virtualList = null;
this.unreadMarkerRef = React.createRef();
@ -111,6 +110,7 @@ export default class ChatWindow extends Component<
});
return;
}
console.log(`found unread: ${unreadIndex}`);
this.setState({
unreadIndex
});
@ -157,15 +157,15 @@ export default class ChatWindow extends Component<
return;
}
//this.virtualList?.scrollToData(unreadIndex);
this.virtualList?.scrollToIndex(this.state.unreadIndex);
}
dismissUnread() {
const { association } = this.props;
if (this.state.fetchPending) return;
if (this.props.unreadCount === 0) return;
console.log('dismissing unreads');
this.props.api.hark.markCountAsRead(association, '/', 'message');
this.props.api.hark.markCountAsRead(association, '/', 'mention');
}
async fetchMessages(newer: boolean): Promise<boolean> {
@ -184,12 +184,12 @@ export default class ChatWindow extends Component<
await api.graph.getYoungerSiblings(
ship,
name,
20,
100,
`/${index.toString()}`
);
} else {
const [index] = graph.peekSmallest()!;
await api.graph.getOlderSiblings(ship, name, 20, `/${index.toString()}`);
await api.graph.getOlderSiblings(ship, name, 100, `/${index.toString()}`);
this.calculateUnreadIndex();
}
this.setState({ fetchPending: false });
@ -202,22 +202,8 @@ export default class ChatWindow extends Component<
this.setState({ idle: true });
}
this.dismissIfLineVisible();
}
dismissIfLineVisible() {
if (this.props.unreadCount === 0) return;
if (!this.unreadMarkerRef.current || !this.virtualList?.window) return;
const parent = this.unreadMarkerRef.current.parentElement?.parentElement;
if (!parent) return;
const { scrollTop, scrollHeight, offsetHeight } = this.virtualList.window;
if (
scrollHeight - parent.offsetTop > scrollTop &&
scrollHeight - parent.offsetTop < scrollTop + offsetHeight
) {
this.dismissUnread();
}
}
renderer = React.forwardRef(({ index, scrollWindow }, ref) => {
const {
@ -256,8 +242,7 @@ export default class ChatWindow extends Component<
const isLastMessage = index.eq(
graph.peekLargest()?.[0] ?? bigInt.zero
);
const highlighted = bigInt(this.props.scrollTo || -1).eq(index);
const highlighted = false; // this.state.unreadIndex.eq(index);
const keys = graph.keys().reverse();
const graphIdx = keys.findIndex((idx) => idx.eq(index));
const prevIdx = keys[graphIdx + 1];
@ -330,16 +315,18 @@ export default class ChatWindow extends Component<
ref={(list) => {
this.virtualList = list;
}}
offset={unreadCount}
origin='bottom'
style={{ height: '100%' }}
onStartReached={() => {
this.setState({ idle: false });
this.dismissUnread();
//this.dismissUnread();
}}
onScroll={this.onScroll.bind(this)}
data={graph}
size={graph.size}
id={association.resource}
averageHeight={22}
renderer={this.renderer}
loadRows={this.fetchMessages.bind(this)}
/>

View File

@ -1,11 +1,18 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import moment from 'moment';
import { Box, Text } from '@tlon/indigo-react';
import VisibilitySensor from 'react-visibility-sensor';
import Timestamp from '~/views/components/Timestamp';
export const UnreadNotice = (props) => {
const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
const [visible, setVisible] = useState(false);
useEffect(() => {
if(visible && unreadCount) {
dismissUnread();
}
}, [visible])
if (!unreadMsg || (unreadCount === 0)) {
return null;
@ -21,6 +28,7 @@ export const UnreadNotice = (props) => {
}
return (
<VisibilitySensor onChange={setVisible}>
<Box style={{ left: '0px', top: '0px' }}
p='4'
width='100%'
@ -53,5 +61,6 @@ export const UnreadNotice = (props) => {
</Text>
</Box>
</Box>
</VisibilitySensor>
);
}

View File

@ -3,9 +3,10 @@ import _ from 'lodash';
import normalizeWheel from 'normalize-wheel';
import bigInt, { BigInteger } from 'big-integer';
import { Box } from '@tlon/indigo-react';
import { Box, LoadingSpinner, Row, Center } from '@tlon/indigo-react';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import {VirtualContext} from '~/logic/lib/virtualContext';
import { IS_IOS } from '~/logic/lib/platform';
interface RendererProps {
index: BigInteger;
@ -23,20 +24,28 @@ interface VirtualScrollerProps<T> {
onEndReached?(): void;
size: number;
totalSize: number;
averageHeight: number;
offset: number;
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<T>): void;
onScroll?({ scrollTop, scrollHeight, windowHeight }): void;
style?: any;
}
interface VirtualScrollerState<T> {
startgap: number | undefined;
visibleItems: BigIntOrderedMap<T>;
endgap: number | undefined;
totalHeight: number;
averageHeight: number;
}
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
let logLevel = ['bail', 'scroll', 'reflow'] as LogLevel[];
const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) {
console.log(`[${level}]: ${message}`);
}
}
// nb: in this file, an index refers to a BigInteger and an offset refers to a
// number used to index a listified BigIntOrderedMap
@ -85,37 +94,43 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
constructor(props: VirtualScrollerProps<T>) {
super(props);
this.state = {
startgap: props.origin === 'top' ? 0 : undefined,
visibleItems: new BigIntOrderedMap(),
endgap: props.origin === 'bottom' ? 0 : undefined,
totalHeight: 0,
averageHeight: 130,
};
this.updateVisible = this.updateVisible.bind(this);
this.updateVisible = IS_IOS
? _.debounce(this.updateVisible.bind(this), 100)
: this.updateVisible.bind(this);
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
this.onScroll = this.onScroll.bind(this)
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 100) : this.onScroll.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.setWindow = this.setWindow.bind(this);
window.restore = () => this.restore();
window.save = () => this.save();
}
componentDidMount() {
if(true) {
this.updateVisible(0);
this.resetScroll();
return;
}
}
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) {
const { id, size, data } = this.props;
const { id, size, data, offset } = this.props;
const { visibleItems } = this.state;
if(id !== prevProps.id) {
console.log('changed id');
this.resetScroll();
this.updateVisible(0);
//this.resetScroll();
//this.updateVisible(offset ?? 0);
} else if(size !== prevProps.size) {
if(this.scrollLocked) {
console.log('locked');
this.updateVisible(0)
this.updateVisible(0);
if(IS_IOS) {
(this.updateVisible as any).flush();
}
this.resetScroll();
}
}
@ -126,7 +141,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
startOffset() {
const startIndex = this.state.visibleItems.peekLargest()?.[0]!;
const startIndex = this.state.visibleItems.peekLargest()?.[0];
if(!startIndex) {
return 0;
}
const offset = [...this.props.data].findIndex(([i]) => i.eq(startIndex))
if(offset === -1) {
throw new Error("a");
@ -167,8 +185,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
scrollKeyMap(): Map<string, number> {
return new Map([
['ArrowUp', this.state.averageHeight],
['ArrowDown', this.state.averageHeight * -1],
['ArrowUp', this.props.averageHeight],
['ArrowDown', this.props.averageHeight * -1],
['PageUp', this.window!.offsetHeight],
['PageDown', this.window!.offsetHeight * -1],
['Home', this.window!.scrollHeight],
@ -204,9 +222,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
window.removeEventListener('keydown', this.invertedKeyHandler);
}
}
const { averageHeight } = this.props;
this.window = element;
this.pageSize = Math.floor(element.offsetHeight / 22);
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 5));
this.pageDelta = Math.floor(this.pageSize / 3);
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
@ -248,6 +267,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
return;
}
if(this.saveDepth > 0) {
log('bail', 'deep scroll queue');
return;
}
const { onStartReached, onEndReached } = this.props;
@ -256,9 +276,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
const startOffset = this.startOffset();
if (scrollTop < 30) {
console.log(scrollTop);
console.log('start');
if (onStartReached) {
log('scroll', `Entered start zone ${scrollTop}`);
if (startOffset === 0 && onStartReached) {
onStartReached();
}
const newOffset = Math.max(0, startOffset - this.pageDelta);
@ -274,12 +293,15 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
}
}
else if (scrollTop + windowHeight >= scrollHeight - 20) {
if (onEndReached) {
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();
}
const newOffset = Math.min(startOffset + this.pageDelta, this.props.data.size - this.pageSize);
if((newOffset + 2 * this.pageSize > this.props.data.size)) {
if((newOffset + (3 * this.pageSize) > this.props.data.size)) {
setTimeout(() => this.loadRows(false));
}
@ -296,6 +318,7 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
return;
}
if(this.saveDepth !== 1) {
console.log('bail', 'Deep restore');
return;
}
console.log(this.childRefs.size);
@ -304,9 +327,8 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
//ref.scrollIntoView();
const newScrollTop = this.window.scrollHeight - ref.offsetTop - this.savedDistance;
this.window.style['-webkit-overflow-scrolling'] = 'auto';
this.window.scrollTop = newScrollTop;
this.window.style['-webkit-overflow-scrolling'] = 'touch';
//this.window.scrollTop = newScrollTop;
this.window.scrollTo(0, newScrollTop);
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
@ -314,12 +336,41 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
});
}
scrollToIndex = (index: BigInteger) => {
let ref = this.childRefs.get(index);
if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index));
if(offset === -1) {
return;
}
this.updateVisible(offset - this.pageDelta);
if(IS_IOS) {
(this.updateVisible as any).flush();
}
requestAnimationFrame(() => {
ref = this.childRefs.get(index);
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
ref?.scrollIntoView({ block: 'center' });
});
} else {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
ref?.scrollIntoView({ block: 'center' });
}
};
save() {
if(!this.window || this.savedIndex) {
return;
}
this.saveDepth++;
if(this.saveDepth !== 1) {
console.log('bail', 'deep save');
return;
}
@ -355,7 +406,9 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
if(element) {
this.childRefs.set(index, element);
} else {
setTimeout(() => {
this.childRefs.delete(index);
});
}
}
@ -372,15 +425,21 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
style,
} = this.props;
const indexesToRender = origin === 'top' ? visibleItems.keys() : visibleItems.keys().reverse();
const isTop = origin === 'top';
const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const indexesToRender = isTop ? visibleItems.keys() : visibleItems.keys().reverse();
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const atStart = this.props.data.peekLargest()![0].eq(visibleItems.peekLargest()?.[0] || bigInt.zero)
const atEnd = false;
return (
<Box overflowY='scroll' ref={this.setWindow} onScroll={this.onScroll} style={{ ...style, ...{ transform } }}>
<Box overflowY='scroll' ref={this.setWindow} onScroll={this.onScroll} style={{ ...style, ...{ transform }, "-webkit-overflow-scrolling": "auto" }}>
<Box style={{ transform, width: '100%' }}>
<Box style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></Box>
{(isTop ? !atStart : !atEnd) && (<Center height="5">
<LoadingSpinner />
</Center>)}
<VirtualContext.Provider value={this.shiftLayout}>
{indexesToRender.map(index => (
<VirtualChild
@ -392,7 +451,10 @@ export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T
/>
))}
</VirtualContext.Provider>
<Box style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></Box>
{(!isTop ? !atStart : !atEnd) &&
(<Center height="5">
<LoadingSpinner />
</Center>)}
</Box>
</Box>
);