mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-15 01:52:42 +03:00
hark-fe: add inbox view
This commit is contained in:
parent
ec5804bb3c
commit
0a69c6fd5b
@ -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>
|
||||||
|
162
pkg/interface/src/views/apps/notifications/graph.tsx
Normal file
162
pkg/interface/src/views/apps/notifications/graph.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
90
pkg/interface/src/views/apps/notifications/group.tsx
Normal file
90
pkg/interface/src/views/apps/notifications/group.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
104
pkg/interface/src/views/apps/notifications/header.tsx
Normal file
104
pkg/interface/src/views/apps/notifications/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
143
pkg/interface/src/views/apps/notifications/inbox.tsx
Normal file
143
pkg/interface/src/views/apps/notifications/inbox.tsx
Normal 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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
43
pkg/interface/src/views/apps/notifications/metadata.tsx
Normal file
43
pkg/interface/src/views/apps/notifications/metadata.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
142
pkg/interface/src/views/apps/notifications/notification.tsx
Normal file
142
pkg/interface/src/views/apps/notifications/notification.tsx
Normal 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;
|
||||||
|
}
|
93
pkg/interface/src/views/apps/notifications/notifications.tsx
Normal file
93
pkg/interface/src/views/apps/notifications/notifications.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
90
pkg/interface/src/views/apps/notifications/preferences.tsx
Normal file
90
pkg/interface/src/views/apps/notifications/preferences.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user