hark-fe: add inbox view

This commit is contained in:
Liam Fitzgerald 2020-10-28 15:58:29 +10:00
parent ec5804bb3c
commit 0a69c6fd5b
10 changed files with 877 additions and 0 deletions

View File

@ -124,6 +124,9 @@ class App extends React.Component {
const theme = state.dark ? dark : light; const theme = state.dark ? dark : light;
const { background } = state; const { background } = state;
const notificationsCount = state.notificationsCount || 0;
const doNotDisturb = state.doNotDisturb || false;
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Helmet> <Helmet>
@ -142,6 +145,8 @@ class App extends React.Component {
connection={this.state.connection} connection={this.state.connection}
subscription={this.subscription} subscription={this.subscription}
ship={this.ship} ship={this.ship}
doNotDisturb={doNotDisturb}
notificationsCount={notificationsCount}
/> />
</ErrorBoundary> </ErrorBoundary>
<ErrorBoundary> <ErrorBoundary>

View File

@ -0,0 +1,162 @@
import React, { ReactNode, useCallback } from "react";
import moment from "moment";
import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import _ from "lodash";
import {
Post,
GraphNotifIndex,
GraphNotificationContents,
Associations,
Content,
Rolodex,
} from "~/types";
import { Header } from "./header";
import { cite, deSig } from "~/logic/lib/util";
import { Sigil } from "~/logic/lib/sigil";
import RichText from "~/views/components/RichText";
import GlobalApi from "~/logic/api/global";
function getGraphModuleIcon(module: string) {
if (module === "link") {
return "Links";
}
return _.capitalize(module);
}
function describeNotification(description: string, plural: boolean) {
switch (description) {
case "link":
return `added ${plural ? "new links" : "a new link"} to`;
case "comment":
return `left ${plural ? "comments" : "a comment"} on`;
case "mention":
return "mentioned you on";
default:
return description;
}
}
const GraphUrl = ({ url, title }) => (
<Box borderRadius="1" p="2" bg="washedGray">
<Anchor target="_blank" color="gray" href={url}>
<Icon verticalAlign="bottom" mr="2" icon="ArrowExternal" />
{title}
</Anchor>
</Box>
);
const GraphNodeContent = ({ contents, module, description, index }) => {
if (module === "link") {
const lent = index.slice(1).split("/").length;
if (lent === 1) {
const [{ text }, { url }] = contents;
return <GraphUrl title={text} url={url} />;
} else if (lent === 2) {
const [{ text }] = contents;
return <RichText>{text}</RichText>;
}
return null;
}
return null;
};
const GraphNode = ({ contents, author, module, description, time, index }) => {
author = deSig(author);
const img = (
<Sigil
ship={`~${author}`}
size={16}
icon
color={`#000000`}
classes="mix-blend-diff"
/>
);
return (
<Row gapX="2" py="2">
<Col>{img}</Col>
<Col alignItems="flex-start">
<Row
mb="2"
height="16px"
alignItems="center"
p="1"
backgroundColor="white"
>
<Text mono title={author}>
{cite(author)}
</Text>
<Text ml="2" gray>
{moment(time).format("HH:mm")}
</Text>
</Row>
<Row p="1">
<GraphNodeContent
contents={contents}
module={module}
description={description}
index={index}
/>
</Row>
</Col>
</Row>
);
};
export function GraphNotification(props: {
index: GraphNotifIndex;
contents: GraphNotificationContents;
archived: boolean;
read: boolean;
time: number;
timebox: BigInteger;
associations: Associations;
contacts: Rolodex;
api: GlobalApi;
}) {
const { contents, index, read, time, api, timebox } = props;
const authors = _.map(contents, "author");
const { graph, group } = index;
const icon = getGraphModuleIcon(index.module);
const desc = describeNotification(index.description, contents.length !== 1);
const onClick = useCallback(() => {
if (props.archived) {
return;
}
const func = read ? "unread" : "read";
return api.hark[func](timebox, { graph: index });
}, [api, timebox, index, read]);
return (
<Col onClick={onClick} p="2">
<Header
archived={props.archived}
time={time}
read={read}
authors={authors}
moduleIcon={icon}
channel={graph}
contacts={props.contacts}
group={group}
description={desc}
associations={props.associations}
/>
<Col pl="5">
{_.map(contents, (content, idx) => (
<GraphNode
author={content.author}
contents={content.contents}
module={index.module}
time={content["time-sent"]}
description={index.description}
index={content.index}
/>
))}
</Col>
</Col>
);
}

View File

@ -0,0 +1,90 @@
import React, { ReactNode, useCallback } from "react";
import moment from "moment";
import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import _ from "lodash";
import { NotificationProps } from "./types";
import {
Post,
GraphNotifIndex,
GraphNotificationContents,
Associations,
Content,
IndexedNotification,
GroupNotificationContents,
GroupNotifIndex,
GroupUpdate,
Rolodex,
} from "~/types";
import { Header } from "./header";
import { cite, deSig } from "~/logic/lib/util";
import { Sigil } from "~/logic/lib/sigil";
import RichText from "~/views/components/RichText";
import GlobalApi from "~/logic/api/global";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
function describeNotification(description: string, plural: boolean) {
switch (description) {
case "add-members":
return "joined";
case "remove-members":
return "left";
default:
return description;
}
}
function getGroupUpdateParticipants(update: GroupUpdate) {
if ("addMembers" in update) {
return update.addMembers.ships;
}
if ("removeMembers" in update) {
return update.removeMembers.ships;
}
return [];
}
interface GroupNotificationProps {
index: GroupNotifIndex;
contents: GroupNotificationContents;
archived: boolean;
read: boolean;
time: number;
timebox: BigInteger;
associations: Associations;
contacts: Rolodex;
api: GlobalApi;
}
export function GroupNotification(props: GroupNotificationProps) {
const { contents, index, read, time, api, timebox, associations } = props;
const authors = _.flatten(_.map(contents, getGroupUpdateParticipants));
const { group } = index;
const desc = describeNotification(index.description, contents.length !== 1);
const onClick = useCallback(() => {
if (props.archived) {
return;
}
const func = read ? "unread" : "read";
return api.hark[func](timebox, { group: index });
}, [api, timebox, index, read]);
return (
<Col onClick={onClick} p="2">
<Header
archived={props.archived}
time={time}
read={read}
group={group}
contacts={props.contacts}
authors={authors}
description={desc}
associations={associations}
/>
</Col>
);
}

View File

@ -0,0 +1,104 @@
import React from "react";
import { Text as NormalText, Row, Icon } from "@tlon/indigo-react";
import f from "lodash/fp";
import moment from "moment";
import { PropFunc } from "~/types/util";
import { getContactDetails } from "~/logic/lib/util";
import { Associations, Contact, Contacts, Rolodex } from "~/types";
const Text = (props: PropFunc<typeof Text>) => (
<NormalText fontWeight="500" {...props} />
);
const Divider = (props: PropFunc<typeof Text>) => (
<Text lineHeight="tall" mx="1" fontWeight="bold" color="lightGray">
|
</Text>
);
function Author(props: { patp: string; contacts: Contacts; last?: boolean }) {
const contact: Contact | undefined = props.contacts?.[props.patp];
const showNickname = !!contact?.nickname;
const name = contact?.nickname || `~${props.patp}`;
return (
<Text mono={!showNickname}>
{name}
{!props.last && ", "}
</Text>
);
}
export function Header(props: {
authors: string[];
archived?: boolean;
channel?: string;
group: string;
contacts: Rolodex;
description: string;
moduleIcon?: string;
time: number;
read: boolean;
associations: Associations;
}) {
const { description, channel, group, moduleIcon, read } = props;
const contacts = props.contacts[group] || {};
const authors = _.uniq(props.authors);
const authorDesc = f.flow(
f.take(3),
f.entries,
f.map(([idx, p]: [string, string]) => {
const lent = Math.min(3, authors.length);
const last = lent - 1 === parseInt(idx, 10);
return <Author key={idx} contacts={contacts} patp={p} last={last} />;
}),
(auths) => (
<React.Fragment>
{auths}
{authors.length > 3 &&
` and ${authors.length - 3} other${authors.length === 4 ? "" : "s"}`}
</React.Fragment>
)
)(authors);
const time = moment(props.time).format("HH:mm");
const groupTitle =
props.associations.contacts?.[props.group]?.metadata?.title || props.group;
const channelTitle =
(channel && props.associations.graph?.[channel]?.metadata?.title) ||
channel;
return (
<Row p="2" flexWrap="wrap" gapX="1" alignItems="center">
{!props.archived && (
<Icon
display="block"
mr="1"
icon={read ? "Circle" : "Bullet"}
color="blue"
/>
)}
<Text mr="1" mono>
{authorDesc}
</Text>
<Text mr="1">{description}</Text>
{!!moduleIcon && <Icon icon={moduleIcon as any} />}
{!!channel && <Text fontWeight="500">{channelTitle}</Text>}
<Text mx="1" fontWeight="bold" color="lightGray">
|
</Text>
<Text fontWeight="500">{groupTitle}</Text>
<Text lineHeight="tall" mx="1" fontWeight="bold" color="lightGray">
|
</Text>
<Text fontWeight="regular" color="lightGray">
{time}
</Text>
</Row>
);
}

View File

@ -0,0 +1,143 @@
import React, { useEffect } from "react";
import f from "lodash/fp";
import _ from "lodash";
import { Icon, Col, Row, Box, Text, Anchor } from "@tlon/indigo-react";
import moment from "moment";
import { Notifications, Rolodex, Timebox, IndexedNotification } from "~/types";
import { MOMENT_CALENDAR_DATE, daToUnix } from "~/logic/lib/util";
import { BigInteger } from "big-integer";
import GlobalApi from "~/logic/api/global";
import { Notification } from "./notification";
import { Associations } from "~/types";
export default function Inbox(props: {
notifications: Notifications;
archive: Notifications;
showArchive?: boolean;
api: GlobalApi;
associations: Associations;
contacts: Rolodex;
gr;
}) {
const { api, associations } = props;
useEffect(() => {
let seen = false;
setTimeout(() => {
seen = true;
}, 3000);
return () => {
if (seen) {
//api.hark.seen();
}
};
}, []);
const [newNotifications, ...notifications] =
Array.from(props.showArchive ? props.archive : props.notifications) || [];
const inspect = f.tap((x: any) => {
console.log(x);
});
const notificationsByDay = f.flow(
f.groupBy<[BigInteger, Timebox]>(([date]) =>
moment(daToUnix(date)).format("DDMMYYYY")
),
f.values,
inspect
)(notifications);
return (
<Col overflowY="auto" flexGrow="1">
{newNotifications && (
<DaySection
latest
timeboxes={[newNotifications]}
contacts={props.contacts}
archive={!!props.showArchive}
associations={props.associations}
graphConfig={props.notificationsGraphConfig}
groupConfig={props.notificationsGroupConfig}
api={api}
/>
)}
{_.map(
notificationsByDay,
(timeboxes, idx) =>
timeboxes.length > 0 && (
<DaySection
key={idx}
timeboxes={timeboxes}
contacts={props.contacts}
archive={!!props.showArchive}
associations={props.associations}
api={api}
graphConfig={props.notificationsGraphConfig}
groupConfig={props.notificationsGroupConfig}
/>
)
)}
</Col>
);
}
type DatedTimebox = [BigInteger, Timebox];
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({
contacts,
archive,
timeboxes,
latest = false,
associations,
api,
groupConfig,
graphConfig,
}) {
const calendar = latest
? MOMENT_CALENDAR_DATE
: { ...MOMENT_CALENDAR_DATE, sameDay: "[Earlier Today]" };
if (timeboxes.length === 0) {
return null;
}
return (
<>
<Box position="sticky" zIndex="2" top="0px" bg="white">
<Box p="2" bg="scales.black05">
{moment(daToUnix(timeboxes[0][0])).calendar(null, calendar)}
</Box>
</Box>
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<React.Fragment key={j}>
{(i !== 0 || j !== 0) && (
<Box flexShrink="0" height="4px" bg="scales.black05" />
)}
<Notification
graphConfig={graphConfig}
groupConfig={groupConfig}
api={api}
associations={associations}
notification={not}
archived={archive}
contacts={contacts}
time={date}
/>
</React.Fragment>
))
)}
</>
);
}

View File

@ -0,0 +1,43 @@
import React from "react";
import { Box } from "@tlon/indigo-react";
import { MetadataBody, NotificationProps } from "./types";
import { Header } from "./header";
function getInvolvedUsers(body: MetadataBody) {
return [];
}
function getDescription(body: MetadataBody) {
const b = body.metadata;
if ("new" in b) {
return "created";
} else if ("changedTitle" in b) {
return "changed the title to";
} else if ("changedDescription" in b) {
return "changed the description to";
} else if ("changedColor" in b) {
return "changed the color to";
} else if ("deleted" in b) {
return "deleted";
} else {
throw new Error("bad metadata frond");
}
}
export function MetadataNotification(props: NotificationProps<"metadata">) {
const { unread } = props;
const description = getDescription(unread.unreads[0].body);
return (
<Box p="2">
<Header
authors={[]}
description={description}
group={unread.group}
channel={unread.channel}
date={unread.date}
/>
</Box>
);
}

View File

@ -0,0 +1,142 @@
import React, { ReactNode, useCallback, useMemo } from "react";
import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import _ from "lodash";
import {
GraphNotificationContents,
IndexedNotification,
GroupNotificationContents,
NotificationGraphConfig,
GroupNotificationsConfig,
NotifIndex,
Associations
} from "~/types";
import GlobalApi from "~/logic/api/global";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import { GroupNotification } from "./group";
import { GraphNotification } from "./graph";
interface NotificationProps {
notification: IndexedNotification;
time: BigInteger;
associations: Associations;
api: GlobalApi;
archived: boolean;
graphConfig: NotificationGraphConfig;
groupConfig: GroupNotificationsConfig;
}
function getMuted(
idx: NotifIndex,
groups: GroupNotificationsConfig,
graphs: NotificationGraphConfig
) {
if ("graph" in idx) {
const { graph } = idx.graph;
return _.findIndex(graphs?.watching || [], (g) => g === graph) === -1;
}
if ("group" in idx) {
return _.findIndex(groups || [], (g) => g === idx.group.group) === -1;
}
return false;
}
function NotificationWrapper(props: {
api: GlobalApi;
time: BigInteger;
notif: IndexedNotification;
children: ReactNode;
archived: boolean;
graphConfig: NotificationGraphConfig;
groupConfig: GroupNotificationsConfig;
}) {
const { api, time, notif, children } = props;
const onArchive = useCallback(async () => {
return api.hark.archive(time, notif.index);
}, [time, notif]);
const isMuted = getMuted(notif.index, props.groupConfig, props.graphConfig);
const onChangeMute = useCallback(async () => {
const func = isMuted ? "unmute" : "mute";
return api.hark[func](notif.index);
}, [notif, api, isMuted]);
const changeMuteDesc = isMuted ? "Unmute" : "Mute";
return (
<Row alignItems="center" justifyContent="space-between">
{children}
<Row gapX="2" p="2" alignItems="center">
<StatelessAsyncAction name={changeMuteDesc} onClick={onChangeMute}>
{changeMuteDesc}
</StatelessAsyncAction>
{!props.archived && (
<StatelessAsyncAction name={time.toString()} onClick={onArchive}>
Archive
</StatelessAsyncAction>
)}
</Row>
</Row>
);
}
export function Notification(props: NotificationProps) {
const { notification, associations, archived } = props;
const { read, contents, time } = notification.notification;
if ("graph" in notification.index) {
const index = notification.index.graph;
const c: GraphNotificationContents = (contents as any).graph;
return (
<NotificationWrapper
archived={archived}
notif={notification}
time={props.time}
api={props.api}
graphConfig={props.graphConfig}
groupConfig={props.groupConfig}
>
<GraphNotification
api={props.api}
index={index}
contents={c}
contacts={props.contacts}
read={read}
archived={archived}
timebox={props.time}
time={time}
associations={associations}
/>
</NotificationWrapper>
);
}
if ("group" in notification.index) {
const index = notification.index.group;
const c: GroupNotificationContents = (contents as any).group;
return (
<NotificationWrapper
archived={archived}
notif={notification}
time={props.time}
api={props.api}
graphConfig={props.graphConfig}
groupConfig={props.groupConfig}
>
<GroupNotification
api={props.api}
index={index}
contents={c}
contacts={props.contacts}
read={read}
timebox={props.time}
archived={archived}
time={time}
associations={associations}
/>
</NotificationWrapper>
);
}
return null;
}

View File

@ -0,0 +1,93 @@
import React, { useCallback } from "react";
import { Box, Col, Text, Row } from "@tlon/indigo-react";
import { Link, Switch, Route } from "react-router-dom";
import { Body } from "~/views/components/Body";
import { PropFunc } from "~/types/util";
import Inbox from "./inbox";
import NotificationPreferences from "./preferences";
const baseUrl = "/~notifications";
const HeaderLink = (
props: PropFunc<typeof Text> & { view?: string; current: string }
) => {
const { current, view, ...textProps } = props;
const to = view ? `${baseUrl}/${view}` : baseUrl;
const active = view ? current === view : !current;
return (
<Link to={to}>
<Text px="2" {...textProps} gray={!active} />
</Link>
);
};
export default function NotificationsScreen(props: any) {
const relativePath = (p: string) => baseUrl + p;
return (
<Switch>
<Route
path={[relativePath("/:view"), relativePath("")]}
render={(routeProps) => {
const { view } = routeProps.match.params;
return (
<Body>
<Col height="100%">
<Row
p="3"
alignItems="center"
height="48px"
justifyContent="space-between"
width="100%"
borderBottom="1"
borderBottomColor="washedGray"
>
<Box>Updates </Box>
<Row>
<Box>
<HeaderLink current={view} view="">
Inbox
</HeaderLink>
</Box>
<Box>
<HeaderLink current={view} view="archive">
Archive
</HeaderLink>
</Box>
<Box>
<HeaderLink current={view} view="preferences">
Preferences
</HeaderLink>
</Box>
</Row>
<Box>
<Text mr="1" gray>
Filter:
</Text>
All
</Box>
</Row>
{view === "archive" && (
<Inbox
{...props}
archive={props.archivedNotifications}
showArchive
/>
)}
{view === "preferences" && (
<NotificationPreferences
graphConfig={props.notificationsGraphConfig}
api={props.api}
dnd={props.doNotDisturb}
/>
)}
{!view && <Inbox {...props} />}
</Col>
</Body>
);
}}
/>
</Switch>
);
}

View File

@ -0,0 +1,90 @@
import React, { useCallback } from "react";
import { Box, Col, ManagedCheckboxField as Checkbox } from "@tlon/indigo-react";
import { Formik, Form, FormikHelpers } from "formik";
import * as Yup from "yup";
import _ from "lodash";
import { AsyncButton } from "~/views/components/AsyncButton";
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
import { NotificationGraphConfig } from "~/types";
import GlobalApi from "~/logic/api/global";
interface FormSchema {
mentions: boolean;
dnd: boolean;
watchOnSelf: boolean;
watching: string[];
}
interface NotificationPreferencesProps {
graphConfig: NotificationGraphConfig;
dnd: boolean;
api: GlobalApi;
}
export default function NotificationPreferences(
props: NotificationPreferencesProps
) {
const { graphConfig, api, dnd } = props;
const initialValues: FormSchema = {
mentions: graphConfig.mentions,
watchOnSelf: graphConfig.watchOnSelf,
dnd,
watching: graphConfig.watching,
};
const onSubmit = useCallback(
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
console.log(values);
try {
let promises: Promise<any>[] = [];
if (values.mentions !== graphConfig.mentions) {
promises.push(api.hark.setMentions(values.mentions));
}
if (values.watchOnSelf !== graphConfig.watchOnSelf) {
promises.push(api.hark.setWatchOnSelf(values.watchOnSelf));
}
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
promises.push(api.hark.setDoNotDisturb(values.dnd))
}
await Promise.all(promises);
actions.setStatus({ success: null });
actions.resetForm({ values: initialValues });
} catch (e) {
console.error(e);
actions.setStatus({ error: e.message });
}
},
[api, graphConfig]
);
return (
<FormikOnBlur
initialValues={initialValues}
onSubmit={onSubmit}
>
<Form>
<Col maxWidth="384px" p="3" gapY="4">
<Checkbox
label="Do not disturb"
id="dnd"
caption="You won't seee the notification badge, but notifications will still appear in your inbox."
/>
<Checkbox
label="Watch for replies"
id="watchOnSelf"
caption="Automatically follow a post for notifications when it's yours"
/>
<Checkbox
label="Watch for mentions"
id="mentions"
caption="Notify me if someone mentions my @p in a channel I've joined"
/>
</Col>
</Form>
</FormikOnBlur>
);
}

View File

@ -25,6 +25,11 @@ const StatusBar = (props) => {
<Icon icon='Home' color='black'/> <Icon icon='Home' color='black'/>
</StatusBarItem> </StatusBarItem>
<StatusBarItem mr={2} onClick={() => props.api.local.setOmnibox()}> <StatusBarItem mr={2} onClick={() => props.api.local.setOmnibox()}>
{ !props.doNotDisturb && props.notificationsCount > 0 &&
(<Box display="block" right="-8px" top="-8px" position="absolute" >
<Icon color="blue" icon="Bullet" />
</Box>
)}
<Icon icon='LeapArrow'/> <Icon icon='LeapArrow'/>
<Text ml={2} color='black'> <Text ml={2} color='black'>
Leap Leap