mirror of
https://github.com/urbit/shrub.git
synced 2025-01-03 01:54:43 +03:00
landscape: update notification rendering for new hark
This commit is contained in:
parent
623303c893
commit
3945ec457e
31
pkg/interface/src/views/apps/notifications/Archive.tsx
Normal file
31
pkg/interface/src/views/apps/notifications/Archive.tsx
Normal 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>
|
||||
);
|
||||
}
|
58
pkg/interface/src/views/apps/notifications/NewBox.tsx
Normal file
58
pkg/interface/src/views/apps/notifications/NewBox.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
|
Loading…
Reference in New Issue
Block a user