Merge pull request #4331 from urbit/lf/hark-lazy-scroll

notifications: lazy load correctly
This commit is contained in:
matildepark 2021-01-26 16:19:39 -05:00 committed by GitHub
commit 8f3afbd0ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 163 additions and 79 deletions

Binary file not shown.

View File

@ -196,10 +196,11 @@ export class HarkApi extends BaseApi<StoreState> {
});
}
getMore() {
async getMore(): Promise<boolean> {
const offset = this.store.state['notifications']?.size || 0;
const count = 3;
return this.getSubset(offset, count, false);
await this.getSubset(offset, count, false);
return offset === (this.store.state.notifications?.size || 0);
}
async getSubset(offset:number, count:number, isArchive: boolean) {

View File

@ -0,0 +1,51 @@
import { useEffect, RefObject, useRef, useState } from "react";
import _ from "lodash";
export function distanceToBottom(el: HTMLElement) {
const { scrollTop, scrollHeight, clientHeight } = el;
const scrolledPercent =
(scrollHeight - scrollTop - clientHeight) / scrollHeight;
return _.isNaN(scrolledPercent) ? 0 : scrolledPercent;
}
export function useLazyScroll(
ref: RefObject<HTMLElement>,
margin: number,
loadMore: () => Promise<boolean>
) {
const [isDone, setIsDone] = useState(false);
useEffect(() => {
if (!ref.current) {
return;
}
setIsDone(false);
const scroll = ref.current;
const loadUntil = (el: HTMLElement) => {
if (!isDone && distanceToBottom(el) < margin) {
return loadMore().then((done) => {
if (done) {
setIsDone(true);
return Promise.resolve();
}
return loadUntil(el);
});
}
return Promise.resolve();
};
loadUntil(scroll);
const onScroll = (e: Event) => {
const el = e.currentTarget! as HTMLElement;
loadUntil(el);
};
ref.current.addEventListener("scroll", onScroll);
return () => {
ref.current?.removeEventListener("scroll", onScroll);
};
}, [ref?.current]);
return isDone;
}

View File

@ -1,4 +1,5 @@
import { Serial, PatpNoSig, Path } from './noun';
import {Resource} from './group-update';
export type InviteUpdate =
InviteUpdateInitial
@ -60,8 +61,8 @@ export type AppInvites = {
export interface Invite {
app: string;
path: Path;
recipeint: PatpNoSig;
recipient: PatpNoSig;
resource: Resource;
ship: PatpNoSig;
text: string;
}

View File

@ -1,18 +1,16 @@
import React, { useEffect, useCallback } from "react";
import React, { useEffect, useCallback, useRef, useState } from "react";
import f from "lodash/fp";
import _ from "lodash";
import { Icon, Col, Row, Box, Text, Anchor, Rule } from "@tlon/indigo-react";
import { Icon, Col, Row, Box, Text, Anchor, Rule, Center } from "@tlon/indigo-react";
import moment from "moment";
import { Notifications, Rolodex, Timebox, IndexedNotification, Groups } from "~/types";
import { Notifications, Rolodex, Timebox, IndexedNotification, Groups, GroupNotificationsConfig, NotificationGraphConfig } from "~/types";
import { MOMENT_CALENDAR_DATE, daToUnix, resourceAsPath } from "~/logic/lib/util";
import { BigInteger } from "big-integer";
import GlobalApi from "~/logic/api/global";
import { Notification } from "./notification";
import { Associations } from "~/types";
import { cite } from '~/logic/lib/util';
import { InviteItem } from '~/views/components/Invite';
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import { useHistory } from "react-router-dom";
import {Invites} from "./invites";
import {useLazyScroll} from "~/logic/lib/useLazyScroll";
type DatedTimebox = [BigInteger, Timebox];
@ -45,10 +43,10 @@ export default function Inbox(props: {
contacts: Rolodex;
filter: string[];
invites: any;
notificationsGroupConfig: GroupNotificationsConfig;
notificationsGraphConfig: NotificationGraphConfig;
}) {
const { api, associations, invites } = props;
const waiter = useWaitForProps(props)
const history = useHistory();
useEffect(() => {
let seen = false;
setTimeout(() => {
@ -75,12 +73,12 @@ export default function Inbox(props: {
};
let notificationsByDay = f.flow(
f.map<DatedTimebox>(([date, nots]) => [
f.map<DatedTimebox, DatedTimebox>(([date, nots]) => [
date,
nots.filter(filterNotification(associations, props.filter)),
]),
f.groupBy<DatedTimebox>(([date]) => {
date = moment(daToUnix(date));
f.groupBy<DatedTimebox>(([d]) => {
const date = moment(daToUnix(d));
if (moment().subtract(6, 'hours').isBefore(date)) {
return 'latest';
} else {
@ -88,69 +86,27 @@ export default function Inbox(props: {
}
}),
)(notifications);
notificationsByDay = new Map(Object.keys(notificationsByDay).sort().reverse().map(timebox => {
return [timebox, notificationsByDay[timebox]];
}));
useEffect(() => {
api.hark.getMore(props.showArchive);
}, [props.showArchive]);
const notificationsByDayMap = new Map<string, DatedTimebox[]>(
Object.keys(notificationsByDay).map(timebox => {
return [timebox, notificationsByDay[timebox]];
})
);
const onScroll = useCallback((e) => {
let container = e.target;
const { scrollHeight, scrollTop, clientHeight } = container;
if((scrollHeight - scrollTop) < 1.5 * clientHeight) {
api.hark.getMore(props.showArchive);
}
}, [props.showArchive]);
const scrollRef = useRef(null);
const acceptInvite = (app: string, uid: string) => async (invite) => {
const resource = {
ship: `~${invite.resource.ship}`,
name: invite.resource.name
};
const loadMore = useCallback(async () => {
return api.hark.getMore();
}, [api]);
const resourcePath = resourceAsPath(invite.resource);
if(app === 'contacts') {
await api.contacts.join(resource);
await waiter(p => resourcePath in p.associations?.contacts);
await api.invite.accept(app, uid);
history.push(`/~landscape${resourcePath}`);
} else if ( app === 'chat') {
await api.invite.accept(app, uid);
history.push(`/~landscape/home/resource/chat${resourcePath.slice(5)}`);
} else if ( app === 'graph') {
await api.invite.accept(app, uid);
history.push(`/~graph/join${resourcePath}`);
}
};
const loadedAll = useLazyScroll(scrollRef, 0.2, loadMore);
const inviteItems = (invites, api) => {
const returned = [];
Object.keys(invites).map((appKey) => {
const app = invites[appKey];
Object.keys(app).map((uid) => {
const invite = app[uid];
const inviteItem =
<InviteItem
key={uid}
invite={invite}
onAccept={acceptInvite(appKey, uid)}
onDecline={() => api.invite.decline(appKey, uid)}
/>;
returned.push(inviteItem);
});
});
return returned;
};
return (
<Col position="relative" height="100%" overflowY="auto" onScroll={onScroll} >
<Col zIndex={4} gapY={2} bg="white" top="0px" position="sticky" flexShrink={0}>
{inviteItems(invites, api)}
</Col>
{[...notificationsByDay.keys()].map((day, index) => {
const timeboxes = notificationsByDay.get(day);
<Col ref={scrollRef} position="relative" height="100%" overflowY="auto">
<Invites invites={invites} api={api} associations={associations} />
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
const timeboxes = notificationsByDayMap.get(day)!;
return timeboxes.length > 0 && (
<DaySection
key={day}
@ -163,10 +119,14 @@ export default function Inbox(props: {
groups={props.groups}
graphConfig={props.notificationsGraphConfig}
groupConfig={props.notificationsGroupConfig}
chatConfig={props.notificationsChatConfig}
/>
);
})}
{loadedAll && (
<Center mt="2" borderTop={notifications.length !== 0 ? 1 : 0} borderTopColor="washedGray" width="100%" height="96px">
<Text gray fontSize="1">No more notifications</Text>
</Center>
)}
</Col>
);
}
@ -192,7 +152,6 @@ function DaySection({
api,
groupConfig,
graphConfig,
chatConfig,
}) {
const lent = timeboxes.map(([,nots]) => nots.length).reduce(f.add, 0);
@ -202,23 +161,22 @@ function DaySection({
return (
<>
<Box position="sticky" zIndex="3" top="-1px" bg="white">
<Box position="sticky" zIndex={3} top="-1px" bg="white">
<Box p="2" bg="scales.black05">
<Text>
{label}
</Text>
</Box>
</Box>
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i) =>
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i: number) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<React.Fragment key={j}>
{(i !== 0 || j !== 0) && (
<Box flexShrink="0" height="4px" bg="scales.black05" />
<Box flexShrink={0} height="4px" bg="scales.black05" />
)}
<Notification
graphConfig={graphConfig}
groupConfig={groupConfig}
chatConfig={chatConfig}
api={api}
associations={associations}
notification={not}

View File

@ -0,0 +1,74 @@
import React, { useCallback } from "react";
import { Box, Row, Col } from "@tlon/indigo-react";
import GlobalApi from "~/logic/api/global";
import { Invites as IInvites, Associations, Invite } from "~/types";
import { resourceAsPath } from "~/logic/lib/util";
import { useHistory } from "react-router-dom";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import InviteItem from "~/views/components/Invite";
interface InvitesProps {
api: GlobalApi;
invites: IInvites;
associations: Associations;
}
export function Invites(props: InvitesProps) {
const { api, invites } = props;
const history = useHistory();
const waiter = useWaitForProps(props);
const acceptInvite = (
app: string,
uid: string,
invite: Invite
) => async () => {
const resource = {
ship: `~${invite.resource.ship}`,
name: invite.resource.name,
};
const resourcePath = resourceAsPath(invite.resource);
if (app === "contacts") {
await api.contacts.join(resource);
await waiter((p) => resourcePath in p.associations?.contacts);
await api.invite.accept(app, uid);
history.push(`/~landscape${resourcePath}`);
} else if (app === "graph") {
await api.invite.accept(app, uid);
history.push(`/~graph/join${resourcePath}`);
}
};
const declineInvite = useCallback(
(app: string, uid: string) => () => api.invite.decline(app, uid),
[api]
);
return (
<Col
zIndex={4}
gapY={2}
bg="white"
top="0px"
position="sticky"
flexShrink={0}
>
{Object.keys(invites).reduce((items, appKey) => {
const app = invites[appKey];
let appItems = Object.keys(app).map((uid) => {
const invite = app[uid];
return (
<InviteItem
key={uid}
invite={invite}
onAccept={acceptInvite(appKey, uid, invite)}
onDecline={declineInvite(appKey, uid)}
/>
);
});
return [...items, ...appItems];
}, [] as JSX.Element[])}
</Col>
);
}

View File

@ -29,7 +29,6 @@ interface NotificationProps {
contacts: Contacts;
graphConfig: NotificationGraphConfig;
groupConfig: GroupNotificationsConfig;
chatConfig: string[];
}
function getMuted(