diff --git a/pkg/interface/src/views/App.js b/pkg/interface/src/views/App.js index c19e0a78a..02b9593da 100644 --- a/pkg/interface/src/views/App.js +++ b/pkg/interface/src/views/App.js @@ -124,6 +124,9 @@ class App extends React.Component { const theme = state.dark ? dark : light; const { background } = state; + const notificationsCount = state.notificationsCount || 0; + const doNotDisturb = state.doNotDisturb || false; + return ( @@ -142,6 +145,8 @@ class App extends React.Component { connection={this.state.connection} subscription={this.subscription} ship={this.ship} + doNotDisturb={doNotDisturb} + notificationsCount={notificationsCount} /> diff --git a/pkg/interface/src/views/apps/notifications/graph.tsx b/pkg/interface/src/views/apps/notifications/graph.tsx new file mode 100644 index 000000000..cabe058c7 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/graph.tsx @@ -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 }) => ( + + + + {title} + + +); + +const GraphNodeContent = ({ contents, module, description, index }) => { + if (module === "link") { + const lent = index.slice(1).split("/").length; + if (lent === 1) { + const [{ text }, { url }] = contents; + return ; + } else if (lent === 2) { + const [{ text }] = contents; + return {text}; + } + return null; + } + return null; +}; +const GraphNode = ({ contents, author, module, description, time, index }) => { + author = deSig(author); + + const img = ( + + ); + + return ( + + {img} + + + + {cite(author)} + + + {moment(time).format("HH:mm")} + + + + + + + + ); +}; + +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 ( + +
+ + {_.map(contents, (content, idx) => ( + + ))} + + + ); +} + diff --git a/pkg/interface/src/views/apps/notifications/group.tsx b/pkg/interface/src/views/apps/notifications/group.tsx new file mode 100644 index 000000000..2b5a66f8d --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/group.tsx @@ -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 ( + +
+ + ); +} + diff --git a/pkg/interface/src/views/apps/notifications/header.tsx b/pkg/interface/src/views/apps/notifications/header.tsx new file mode 100644 index 000000000..51d3e3635 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/header.tsx @@ -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) => ( + +); + +const Divider = (props: PropFunc) => ( + + | + +); + +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 ( + + {name} + {!props.last && ", "} + + ); +} + +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 ; + }), + (auths) => ( + + {auths} + + {authors.length > 3 && + ` and ${authors.length - 3} other${authors.length === 4 ? "" : "s"}`} + + ) + )(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 ( + + {!props.archived && ( + + )} + + {authorDesc} + + {description} + {!!moduleIcon && } + {!!channel && {channelTitle}} + + | + + {groupTitle} + + | + + + {time} + + + ); +} diff --git a/pkg/interface/src/views/apps/notifications/inbox.tsx b/pkg/interface/src/views/apps/notifications/inbox.tsx new file mode 100644 index 000000000..79d2de7f4 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/inbox.tsx @@ -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 ( + + {newNotifications && ( + + )} + + {_.map( + notificationsByDay, + (timeboxes, idx) => + timeboxes.length > 0 && ( + + ) + )} + + ); +} + +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 ( + <> + + + {moment(daToUnix(timeboxes[0][0])).calendar(null, calendar)} + + + {_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i) => + _.map(nots.sort(sortIndexedNotification), (not, j: number) => ( + + {(i !== 0 || j !== 0) && ( + + )} + + + )) + )} + + ); +} diff --git a/pkg/interface/src/views/apps/notifications/metadata.tsx b/pkg/interface/src/views/apps/notifications/metadata.tsx new file mode 100644 index 000000000..b5643f359 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/metadata.tsx @@ -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 ( + +
+ + ); +} diff --git a/pkg/interface/src/views/apps/notifications/notification.tsx b/pkg/interface/src/views/apps/notifications/notification.tsx new file mode 100644 index 000000000..c405aaf2b --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/notification.tsx @@ -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 ( + + {children} + + + {changeMuteDesc} + + {!props.archived && ( + + Archive + + )} + + + ); +} + +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 ( + + + + ); + } + if ("group" in notification.index) { + const index = notification.index.group; + const c: GroupNotificationContents = (contents as any).group; + return ( + + + + ); + } + + return null; +} diff --git a/pkg/interface/src/views/apps/notifications/notifications.tsx b/pkg/interface/src/views/apps/notifications/notifications.tsx new file mode 100644 index 000000000..11ce247a4 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/notifications.tsx @@ -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 & { view?: string; current: string } +) => { + const { current, view, ...textProps } = props; + const to = view ? `${baseUrl}/${view}` : baseUrl; + const active = view ? current === view : !current; + + return ( + + + + ); +}; + +export default function NotificationsScreen(props: any) { + const relativePath = (p: string) => baseUrl + p; + return ( + + { + const { view } = routeProps.match.params; + return ( + + + + Updates + + + + Inbox + + + + + Archive + + + + + Preferences + + + + + + Filter: + + All + + + {view === "archive" && ( + + )} + {view === "preferences" && ( + + )} + {!view && } + + + ); + }} + /> + + ); +} diff --git a/pkg/interface/src/views/apps/notifications/preferences.tsx b/pkg/interface/src/views/apps/notifications/preferences.tsx new file mode 100644 index 000000000..877c666af --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/preferences.tsx @@ -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) => { + console.log(values); + try { + let promises: Promise[] = []; + 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 ( + +
+ + + + + + + +
+ ); +} diff --git a/pkg/interface/src/views/components/StatusBar.js b/pkg/interface/src/views/components/StatusBar.js index eb21260b2..f8ff23312 100644 --- a/pkg/interface/src/views/components/StatusBar.js +++ b/pkg/interface/src/views/components/StatusBar.js @@ -25,6 +25,11 @@ const StatusBar = (props) => { props.api.local.setOmnibox()}> + { !props.doNotDisturb && props.notificationsCount > 0 && + ( + + + )} Leap