diff --git a/src/app/HeaderButtons.tsx b/src/app/HeaderButtons.tsx index f3e948b..f28151c 100644 --- a/src/app/HeaderButtons.tsx +++ b/src/app/HeaderButtons.tsx @@ -1,11 +1,14 @@ -import { Chat as ChatIcon } from "@mui/icons-material"; +import { Chat as ChatIcon, Notifications } from "@mui/icons-material"; import { Box, IconButton, Theme, Tooltip } from "@mui/material"; import { makeStyles } from "@mui/styles"; import { ChatDrawer } from "chat/ChatDrawer"; import { Team, User } from "models"; import moment from "moment"; +import { pond } from "protobuf-ts/pond"; import { useEffect, useState } from "react"; import { getSignatureAccentColour } from "services/whiteLabel"; +import NotificationDrawer from "./NotificationDrawer"; + const useStyles = makeStyles((theme: Theme) => ({ button: { @@ -47,9 +50,11 @@ interface Props { } export default function HeaderButtons(props: Props) { - const {hasTeams, /*user,*/ team} = props + const {hasTeams, user, team} = props const [hasNewChats, setHasNewChats] = useState(false); + const [hasNewNotifications, setHasNewNotifications] = useState(false); const [chatDrawerOpen, setChatDrawerOpen] = useState(false); + const [notificationDrawerOpen, setNotificationDrawerOpen] = useState(false); const classes = useStyles(); useEffect(() => { @@ -70,6 +75,30 @@ export default function HeaderButtons(props: Props) { } }, [team]); + useEffect(() => { + if (!user) return; + if (!user.status) return; + if ( + user.status.lastNotificationViewed.length < 1 && + user.status.lastNotifiedTimestamp.length > 1 + ) { + setHasNewNotifications(true); + return; + } + let viewMoment = moment(new Date(user.status.lastNotificationViewed)); + let notiMoment = moment(new Date(user.status.lastNotifiedTimestamp)); + if (viewMoment.diff(notiMoment) < 0) { + setHasNewNotifications(true); + } else { + setHasNewNotifications(false); + } + }, [user]); + + // If the drawer has been opened, get rid of the ping + useEffect(() => { + if (notificationDrawerOpen) setHasNewNotifications(false); + }, [notificationDrawerOpen]); + const chatButton = () => { if (!hasTeams) return; if (team.key().length < 1) return @@ -83,17 +112,44 @@ export default function HeaderButtons(props: Props) { setHasNewChats(false); }}> + {hasNewChats && ( + + )} + + ) + } - {hasNewChats && ( - - )} + const notificationButton = () => { + if (!user.settings?.notificationMethods?.includes( + pond.NotificationMethod.NOTIFICATION_METHOD_APP + )) return + return ( + <> + + { + setNotificationDrawerOpen(true); + setHasNewChats(false); + }}> + + {hasNewNotifications && ( + + )} + + ) } @@ -108,6 +164,11 @@ export default function HeaderButtons(props: Props) { team={team} /> )} + {notificationButton()} + ) } \ No newline at end of file diff --git a/src/app/NotificationDrawer.tsx b/src/app/NotificationDrawer.tsx new file mode 100644 index 0000000..947d87d --- /dev/null +++ b/src/app/NotificationDrawer.tsx @@ -0,0 +1,296 @@ +import { + Avatar, + Button, + CircularProgress, + createStyles, + Divider, + Drawer, + Grid, + IconButton, + List, + ListItem, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + Theme, + Tooltip, + Typography, + useTheme +} from "@mui/material"; +import { makeStyles } from "@mui/styles" +import { Remove } from "@mui/icons-material"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import DeviceIcon from "assets/editor/device.png"; +import { useMobile, useUserAPI } from "hooks"; +import moment from "moment"; +//import { GetComponentIcon } from "pbHelpers/ComponentType"; //TODO: Put this pack in for the icons once components are done +import { pond } from "protobuf-ts/pond"; +import { useGlobalState } from "providers"; +import { useNotificationAPI } from "providers"; +import React, { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router"; + +const useStyles = makeStyles((theme: Theme) => ({ + chatDrawer: { + padding: theme.spacing(1), + height: "100%", + minWidth: theme.spacing(54), + maxWidth: theme.spacing(54) + }, + chatDrawerMobile: { + padding: theme.spacing(1), + height: "auto", + width: "100%" + }, + drawerHeader: { + display: "flex", + alignItems: "center", + padding: theme.spacing(0, 1), + // necessary for content to be below app bar + ...theme.mixins.toolbar, + justifyContent: "space-between" + } +})); + +interface Props { + notificationDrawerOpen: boolean; + setNotificationDrawerOpen: React.Dispatch>; +} + +export default function NotificationDrawer(props: Props) { + const { notificationDrawerOpen, setNotificationDrawerOpen } = props; + + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [notifications, setNotifications] = useState>(new Map()); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(9999999); + + const history = useNavigate(); + const isMobile = useMobile(); + const classes = useStyles(); + const theme = useTheme(); + const notificationAPI = useNotificationAPI(); + const userAPI = useUserAPI(); + const [{ user, as }] = useGlobalState(); + + const loadNotifications = useCallback(() => { + if (user.id().length < 1) return; + setLoading(true); + notificationAPI + .listNotifications(10, 0, "desc", "timestamp", undefined, as) + .then(resp => { + let r = pond.ListNotificationsResponse.fromObject(resp.data); + setTotal(r.total); + let n: Map = new Map(); + r.notifications.forEach(noti => { + n.set(noti.settings?.key ? noti.settings.key : "", noti); + }); + setNotifications(n); + let u = user.protobuf(); + if (u.status) { + let DATE_RFC2822 = "ddd, DD MMM YYYY HH:mm:ss ZZ"; + u.status.lastNotificationViewed = moment() + .locale("en") + .format(DATE_RFC2822); + userAPI.updateUser(user.id(), u); + } + }) + .finally(() => { + setLoading(false); + }); + }, [notificationAPI, setLoading, setNotifications, user, userAPI, as]); + + useEffect(() => { + if (offset === 0) return; + setLoadingMore(true); + notificationAPI + .listNotifications(10, offset, "desc", "timestamp", undefined, as) + .then(resp => { + let r = pond.ListNotificationsResponse.fromObject(resp.data); + let n: Map = new Map(notifications); + r.notifications.forEach(noti => { + n.set(noti.settings?.key ? noti.settings.key : "", noti); + }); + setNotifications(n); + }) + .finally(() => { + setLoadingMore(false); + }); + }, [offset, notificationAPI]); // eslint-disable-line react-hooks/exhaustive-deps + + const removeNotification = (keys: string[]) => { + notificationAPI.hideNotification(keys).then(() => { + let n = new Map(notifications); + keys.forEach(key => { + n.delete(key); + }); + setNotifications(n); + setTotal(total - keys.length); + }); + }; + + const hasSource = (notification: pond.Notification) => { + return Boolean(notification.settings?.sourceType && notification.settings?.sourceKey); + }; + + const notificationClick = (notification: pond.Notification) => { + setNotificationDrawerOpen(false); + setNotifications(new Map()); + if (!notification.settings?.sourceType) return; + if (!notification.settings?.sourceKey) return; + let url = "/" + notification.settings?.sourceType + "/" + notification.settings?.sourceKey; + history(url); //in order to update the history stack add '{ replace: true }' as a second parameter + }; + + useEffect(() => { + if (loading) return; + if (!notificationDrawerOpen) return; + loadNotifications(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadNotifications, notificationDrawerOpen]); + + return ( + setNotificationDrawerOpen(false)} + style={{ height: "100%", maxWidth: "100%" }} + title={"Notifications"}> +
+
+ setNotificationDrawerOpen(false)}> + + + Notifications + +
+ + + {loading && ( +
+ +
+ )} + {!loading && notifications.size < 1 && ( + + No Notifications + + )} + {!loading && + [...notifications.values()].map(notification => { + let cIcon = DeviceIcon; + // TODO: put this back in when the component stuff has been added + // if (notification.settings?.componentSource) { + // let newIcon = GetComponentIcon( + // notification.settings.componentSource.type, + // undefined, + // theme.palette.type + // ); + // cIcon = newIcon ? newIcon : cIcon; + // } else if (notification.settings?.logo) { + // cIcon = notification.settings?.logo; + // } + let title = notification.settings?.title + ? notification.settings?.title + : "Device " + notification.settings?.sourceKey; + //notification.settings?.sourceKey + return ( + + notificationClick(notification)} + > + + {/**/} + + + + + + + {title} + + + + {notification.settings?.subtitle && + " - " + notification.settings?.subtitle} + + + + {notification.status?.timestamp && ( + + + {moment(notification.status.timestamp).fromNow()} + + + )} + {hasSource(notification) && ( + + + Click to view {notification.settings?.sourceType} + + + )} + + + + + + removeNotification( + notification.settings?.key ? [notification.settings.key] : [] + ) + }> + + + + + + + + ); + })} + {!loading && !loadingMore && notifications.size > 0 && notifications.size < total && ( + setOffset(offset + 10)}> +
+ Load More +
+
+ )}{" "} + {loadingMore && ( + +
+ +
+
+ )}{" "} + {!loadingMore && notifications.size > 0 && notifications.size >= total && ( + +
+ End of list. +
+
+ )} +
+
+
+ ); +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 8c485cb..3e033ad 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -7,6 +7,7 @@ export { useBinAPI, // useBinYardAPI, useNoteAPI, + useNotificationAPI, // useComponentAPI, // useComponentsWebsocket, // useComponentWebsocket, diff --git a/src/providers/pond/notificationAPI.tsx b/src/providers/pond/notificationAPI.tsx new file mode 100644 index 0000000..452dd17 --- /dev/null +++ b/src/providers/pond/notificationAPI.tsx @@ -0,0 +1,114 @@ +import { useHTTP } from "hooks"; +import React, { createContext, PropsWithChildren, useContext } from "react"; +import { pond } from "protobuf-ts/pond"; +import { or } from "utils/types"; +import { pondURL } from "./pond"; +import { AxiosResponse } from "axios"; + +export interface INotificationAPIContext { + listNotifications: ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + as?: string, + asRoot?: boolean + ) => Promise>; + hideNotification: (keys: string[]) => Promise>; + listObjectNotifications: ( + key: string, + type: pond.ObjectType, + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + as?: string + ) => Promise>; +} + +export const NotificationAPIContext = createContext( + {} as INotificationAPIContext +); + +interface Props {} + +export default function NotificationProvider(props: PropsWithChildren) { + const { children } = props; + const { get, put } = useHTTP(); + + const listNotifications = ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + as?: string, + asRoot?: boolean + ) => { + return get( + pondURL( + "/notifications" + + "?limit=" + + limit + + "&offset=" + + offset + + ("&order=" + or(order, "asc")) + + ("&by=" + or(orderBy, "key")) + + (as ? "&as=" + as : "") + + (asRoot ? "&asRoot=" + asRoot.toString() : "") + + (search ? "&search=" + search : "") + ) + ); + }; + + const listObjectNotifications = ( + key: string, + type: pond.ObjectType, + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + as?: string + ) => { + return get( + pondURL( + "/object/notifications" + + "?objectKey=" + + key + + "&objectType=" + + type + + "&limit=" + + limit + + "&offset=" + + offset + + ("&order=" + or(order, "asc")) + + ("&by=" + or(orderBy, "key")) + + (search ? "&search=" + search : "") + + (as ? "&as=" + as : "") + ) + ); + }; + + const hideNotification = (keys: string[]) => { + return put( + pondURL("/notifications/hide"), + pond.NotificationKeys.create({ keys: keys }) + ); + }; + + return ( + + {children} + + ); +} + +export const useNotificationAPI = () => useContext(NotificationAPIContext); \ No newline at end of file diff --git a/src/providers/pond/pond.tsx b/src/providers/pond/pond.tsx index 0272de3..d6debcb 100644 --- a/src/providers/pond/pond.tsx +++ b/src/providers/pond/pond.tsx @@ -10,6 +10,7 @@ import NoteProvider, { useNoteAPI } from "./noteAPI"; import DeviceProvider, { useDeviceAPI } from "./deviceAPI"; import BackpackProvider, { useBackpackAPI } from "./backpackAPI"; import GroupProvider, { useGroupAPI } from "./groupAPI"; +import NotificationProvider, { useNotificationAPI } from "./notificationAPI"; // import NoteProvider from "providers/noteAPI"; export const pondURL = (partial: string, demo: boolean = false): string => { @@ -36,7 +37,9 @@ export default function PondProvider(props: PropsWithChildren) { - {children} + + {children} + @@ -60,4 +63,5 @@ export { useGateAPI, useGroupAPI, useNoteAPI, + useNotificationAPI };