landscape: update notification rendering for new hark

This commit is contained in:
Liam Fitzgerald 2021-10-05 14:14:30 +10:00
parent 623303c893
commit 3945ec457e
5 changed files with 251 additions and 247 deletions

View File

@ -0,0 +1,31 @@
import { Box } from '@tlon/indigo-react';
import React, { useEffect } from 'react';
import useHarkState, { HarkState } from '~/logic/state/hark';
import { Notification } from './notification';
const selArchive = (s: HarkState) => s.archive;
export function Archive() {
const archive = useHarkState(selArchive);
const keys = archive.keys();
useEffect(() => {
useHarkState.getState().getMore();
}, []);
return (
<Box pt="2" overflowY="auto" overflowX="hidden">
{keys.map(key =>
Object.entries(archive.get(key)!)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n]) => (
<Notification
key={`${key.toString()}-${binId}`}
lid={{ time: key.toString() }}
notification={n}
/>
))
)}
</Box>
);
}

View File

@ -0,0 +1,58 @@
import { Box, Center, Col, Text } from '@tlon/indigo-react';
import React from 'react';
import useHarkState, { HarkState } from '~/logic/state/hark';
import { harkBinToId, HarkLid, Timebox } from '../../../../../npm/api/dist';
import { Notification } from './notification';
const unseenLid = { unseen: null };
const seenLid = { seen: null };
const selUnseen = (s: HarkState) => s.unseen;
const selSeen = (s: HarkState) => s.seen;
export function NewBox() {
const seen = useHarkState(selSeen);
const unseen = useHarkState(selUnseen);
const empty = Object.keys(seen).length + Object.keys(unseen).length === 0;
return (
<Box pt="2" overflowY="auto" overflowX="hidden">
{empty ? (
<Center p="3">
<Text>All clear!</Text>
</Center>
) : (
<>
<Lid lid={unseenLid} timebox={unseen} title="Unseen" />
<Lid lid={seenLid} timebox={seen} title="Seen" />
</>
)}
</Box>
);
}
function Lid({
lid,
timebox,
title
}: {
lid: HarkLid;
timebox: Timebox;
title: string;
}) {
if(Object.keys(timebox).length === 0) {
return null;
}
return (
<>
<Text gray p="2">
{title}
</Text>
<Col>
{Object.entries(timebox)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n]) => (
<Notification key={harkBinToId(n.bin)} lid={lid} notification={n} />
))}
</Col>
</>
);
}

View File

@ -1,135 +0,0 @@
import { Col } from '@tlon/indigo-react';
import {
IndexedNotification,
JoinRequests,
Notifications,
seen,
Timebox,
unixToDa
} from '@urbit/api';
import { BigInteger } from 'big-integer';
import _ from 'lodash';
import f from 'lodash/fp';
import moment from 'moment';
import React, { useEffect } from 'react';
import { getNotificationKey } from '~/logic/lib/hark';
import { daToUnix } from '~/logic/lib/util';
import useHarkState from '~/logic/state/hark';
import { Invites } from './invites';
import { Notification } from './notification';
import airlock from '~/logic/api';
type DatedTimebox = [BigInteger, Timebox];
function filterNotification(groups: string[]) {
if (groups.length === 0) {
return () => true;
}
return (n: IndexedNotification) => {
if ('graph' in n.index) {
const { group } = n.index.graph;
return groups.findIndex(g => group === g) !== -1;
} else if ('group' in n.index) {
const { group } = n.index.group;
return groups.findIndex(g => group === g) !== -1;
}
return true;
};
}
export default function Inbox(props: {
archive: Notifications;
showArchive?: boolean;
filter: string[];
pendingJoin: JoinRequests;
}) {
useEffect(() => {
let hasSeen = false;
setTimeout(() => {
hasSeen = true;
}, 3000);
return () => {
if (hasSeen) {
airlock.poke(seen());
}
};
}, []);
const ready = useHarkState(
s => Object.keys(s.unreads.graph).length > 0
);
const getMore = useHarkState(s => s.getMore);
const notificationState = useHarkState(state => state.notifications);
const unreadNotes = useHarkState(s => s.unreadNotes);
const archivedNotifications = useHarkState(state => state.archivedNotifications);
const notifications =
Array.from(props.showArchive ? archivedNotifications : notificationState) || [];
const notificationsByDay = f.flow(
f.map<DatedTimebox, DatedTimebox>(([date, nots]) => [
date,
nots.filter(filterNotification(props.filter))
]),
f.groupBy<DatedTimebox>(([d]) => {
const date = moment(daToUnix(d));
if (moment().subtract(6, 'hours').isBefore(date)) {
return 'latest';
} else {
return date.format('YYYYMMDD');
}
})
)(notifications);
const notificationsByDayMap = new Map<string, DatedTimebox[]>(
Object.keys(notificationsByDay).map((timebox) => {
return [timebox, notificationsByDay[timebox]];
})
);
const date = unixToDa(Date.now());
return (
<Col p={1} position="relative" height="100%" overflowY="auto" overflowX="hidden">
<Invites pendingJoin={props.pendingJoin} />
</Col>
);
}
function sortTimeboxes([a]: DatedTimebox, [b]: DatedTimebox) {
return b.subtract(a);
}
function sortIndexedNotification(
{ notification: a }: IndexedNotification,
{ notification: b }: IndexedNotification
) {
return b.time - a.time;
}
function DaySection({
timeboxes,
unread = false
}) {
const lent = timeboxes.map(([,nots]) => nots.length).reduce(f.add, 0);
if (lent === 0 || timeboxes.length === 0) {
return null;
}
return (
<>
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i: number) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<Notification
key={getNotificationKey(date, not)}
notification={not}
unread={unread}
time={!unread ? date : undefined}
/>
))
)}
</>
);
}

View File

@ -1,64 +1,111 @@
import { Box, Button, Icon, Row } from '@tlon/indigo-react';
import { Box, Col, Text, Button, Icon, Row } from '@tlon/indigo-react';
import {
GraphNotificationContents,
GroupNotificationContents,
IndexedNotification
HarkLid,
harkLidToId,
harkBinToId,
Notification as INotification,
HarkContent
} from '@urbit/api';
import { BigInteger } from 'big-integer';
import React, { ReactNode, useCallback } from 'react';
import { getNotificationKey } from '~/logic/lib/hark';
import React, { useCallback } from 'react';
import { useHovering } from '~/logic/lib/util';
import useLocalState from '~/logic/state/local';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import { SwipeMenu } from '~/views/components/SwipeMenu';
import { GraphNotification } from './graph';
import { GroupNotification } from './group';
import useHarkState from '~/logic/state/hark';
import shallow from 'zustand/shallow';
import { map, take } from 'lodash';
import { Mention } from '~/views/components/MentionText';
import { PropFunc } from '~/types';
import { useHistory } from 'react-router-dom';
import { getNotificationRedirect } from '~/logic/lib/notificationRedirects';
export interface NotificationProps {
notification: IndexedNotification;
notification: INotification;
time: BigInteger;
unread: boolean;
}
export function NotificationWrapper(props: {
time?: BigInteger;
read?: boolean;
notification?: IndexedNotification;
children: ReactNode;
const MAX_CONTENTS = 5;
interface NotificationTextProps extends PropFunc<typeof Box> {
contents: HarkContent[];
}
const NotificationText = ({ contents, ...rest }: NotificationTextProps) => {
return (
<>
{contents.map((content, idx) => {
if ('ship' in content) {
return (
<Mention
key={idx}
ship={content.ship}
first={idx === 0}
{...rest}
/>
);
}
return <Text key={idx} {...rest}>{content.text}</Text>;
})}
</>
);
};
export function Notification(props: {
lid: HarkLid;
notification: INotification;
}) {
const { time, notification, children, read = false } = props;
const { notification, lid } = props;
const read = !('unseen' in lid);
const key = `${harkLidToId(lid)}-${harkBinToId(notification.bin)}`;
const history = useHistory();
const isMobile = useLocalState(s => s.mobile);
const [archive, readNote] = useHarkState(s => [s.archive, s.readNote], shallow);
const onArchive = useCallback(async (e) => {
e.stopPropagation();
if (!notification) {
return;
}
await archive(notification.index, time);
}, [time, notification]);
const onClick = (e: any) => {
if (!notification || read) {
return;
}
return readNote(notification.index);
};
const onArchive = useCallback(
async (e) => {
e.stopPropagation();
if (!notification) {
return;
}
useHarkState.getState().archiveNote(notification.bin, lid);
},
[notification, lid]
);
const { hovering, bind } = useHovering();
const contents = map(notification.body, 'content').filter(
c => c.length > 0
);
const first = notification.body[0];
if (!first) {
// should be unreachable
return null;
}
const onClick = (e: any) => {
const redirect = getNotificationRedirect(first.link);
if(redirect) {
history.push(redirect);
} else {
console.log('no redirect');
}
};
return (
<SwipeMenu
key={(time && notification && getNotificationKey(time, notification)) ?? 'unknown'}
key={key}
m={2}
menuWidth={100}
disabled={!isMobile}
menu={
<Button onClick={onArchive} ml={2} height="100%" width="92px" primary destructive>
<Button
onClick={onArchive}
ml={2}
height="100%"
width="92px"
primary
destructive
>
Remove
</Button>
}
@ -74,7 +121,24 @@ export function NotificationWrapper(props: {
p={2}
{...bind}
>
{children}
<Col gapY={contents.length === 0 ? 0 : 2}>
<Row>
<NotificationText contents={first.title} fontWeight="medium" />
</Row>
<Col gapY="3">
{take(contents, MAX_CONTENTS).map((content, i) => (
<Box key={i}>
<NotificationText lineHeight="tall" contents={content} />
</Box>
))}
</Col>
{contents.length > MAX_CONTENTS ? (
<Text mt="2" gray display="block">
and {contents.length - MAX_CONTENTS} more
</Text>
) : null}
</Col>
<Row
alignItems="flex-start"
gapX={2}
@ -82,7 +146,7 @@ export function NotificationWrapper(props: {
justifyContent="flex-end"
opacity={[0, hovering ? 1 : 0]}
>
{notification && (
{!('time' in lid) && (
<StatelessAsyncAction
name=""
borderRadius={1}
@ -97,46 +161,3 @@ export function NotificationWrapper(props: {
</SwipeMenu>
);
}
export function Notification(props: NotificationProps) {
const { notification, unread } = props;
const { contents, time } = notification.notification;
const wrapperProps = {
notification,
read: !unread,
time: props.time
};
if ('graph' in notification.index) {
const index = notification.index.graph;
const c: GraphNotificationContents = (contents as any).graph;
return (
<NotificationWrapper {...wrapperProps}>
<GraphNotification
index={index}
contents={c}
read={!unread}
timebox={props.time}
time={time}
/>
</NotificationWrapper>
);
}
if ('group' in notification.index) {
const index = notification.index.group;
const c: GroupNotificationContents = (contents as any).group;
return (
<NotificationWrapper {...wrapperProps}>
<GroupNotification
index={index}
contents={c}
time={time}
/>
</NotificationWrapper>
);
}
return null;
}

View File

@ -1,29 +1,50 @@
import { Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
import React, { ReactElement, useCallback, useRef } from 'react';
import { Action, Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
import React, { ReactElement, ReactNode, useRef } from 'react';
import Helmet from 'react-helmet';
import { Link, Route, Switch } from 'react-router-dom';
import useGroupState from '~/logic/state/group';
import { Link, Route, Switch, useHistory, useLocation } from 'react-router-dom';
import useHarkState from '~/logic/state/hark';
import { Body } from '~/views/components/Body';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import { useTutorialModal } from '~/views/components/useTutorialModal';
import Inbox from './inbox';
import airlock from '~/logic/api';
import { readAll } from '@urbit/api';
import { Archive } from './Archive';
import { NewBox } from './NewBox';
const baseUrl = '/~notifications';
export function NavLink({
href,
children
}: {
href: string;
children: ReactNode;
}) {
const location = useLocation();
const { push } = useHistory();
const isActive = href === location.pathname;
const onClick = () => {
push(href);
};
return (
<Action
backgroundColor="transparent"
onClick={onClick}
color={isActive ? 'black' : 'gray'}
>
{children}
</Action>
);
}
export default function NotificationsScreen(props: any): ReactElement {
const relativePath = (p: string) => baseUrl + p;
const pendingJoin = useGroupState(s => s.pendingJoin);
const onReadAll = useCallback(async () => {
await airlock.poke(readAll());
}, []);
const anchorRef = useRef<HTMLElement | null>(null);
useTutorialModal('notifications', true, anchorRef);
const notificationsCount = useHarkState(state => state.notificationsCount);
const onReadAll = async () => {};
return (
<Switch>
<Route
@ -33,7 +54,10 @@ export default function NotificationsScreen(props: any): ReactElement {
return (
<>
<Helmet defer={false}>
<title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape - Notifications</title>
<title>
{notificationsCount ? `(${String(notificationsCount)}) ` : ''}
Groups - Notifications
</title>
</Helmet>
<Body>
<Col overflowY="hidden" height="100%">
@ -46,22 +70,29 @@ export default function NotificationsScreen(props: any): ReactElement {
borderBottom={1}
borderBottomColor="lightGray"
>
<Text fontWeight="bold" fontSize={2} lineHeight={1} ref={anchorRef}>
Notifications
</Text>
<Row
justifyContent="space-between"
gapX={3}
<Text
fontWeight="bold"
fontSize={2}
lineHeight={1}
ref={anchorRef}
>
<StatelessAsyncAction
overflow="hidden"
color="black"
backgroundColor="white"
onClick={onReadAll}
>
Mark All Read
</StatelessAsyncAction>
Notifications
</Text>
<Row gapX="2">
<NavLink href="/~notifications">New</NavLink>
<NavLink href="/~notifications/archive">Archive</NavLink>
</Row>
<Row justifyContent="space-between" gapX={3}>
{ (false as boolean) ? (
<StatelessAsyncAction
overflow="hidden"
color="black"
backgroundColor="white"
onClick={onReadAll}
>
Mark All Read
</StatelessAsyncAction>
) : null}
<Link to="/~settings#notifications">
<Box>
<Icon lineHeight={1} icon="Adjust" />
@ -69,11 +100,9 @@ export default function NotificationsScreen(props: any): ReactElement {
</Link>
</Row>
</Row>
{!view && <Inbox
pendingJoin={pendingJoin}
{...props}
filter={[]}
/>}
{ view === 'archive' ? (
<Archive />
) : <NewBox /> }
</Col>
</Body>
</>