From 38e0bcb33cd242c7393997136575b1173f5742d3 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 20 Jan 2025 14:58:36 -0600 Subject: [PATCH] added notification drawer to the header for the moment commented out the code to get the component icons and am just displaying the device icons until the component stuff is added to the new frontend --- src/app/HeaderButtons.tsx | 81 ++++++- src/app/NotificationDrawer.tsx | 296 +++++++++++++++++++++++++ src/providers/index.ts | 1 + src/providers/pond/notificationAPI.tsx | 114 ++++++++++ src/providers/pond/pond.tsx | 6 +- 5 files changed, 487 insertions(+), 11 deletions(-) create mode 100644 src/app/NotificationDrawer.tsx create mode 100644 src/providers/pond/notificationAPI.tsx 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 };