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
This commit is contained in:
csawatzky 2025-01-20 14:58:36 -06:00
parent 67ea4cbfdc
commit 38e0bcb33c
5 changed files with 487 additions and 11 deletions

View file

@ -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 { Box, IconButton, Theme, Tooltip } from "@mui/material";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { ChatDrawer } from "chat/ChatDrawer"; import { ChatDrawer } from "chat/ChatDrawer";
import { Team, User } from "models"; import { Team, User } from "models";
import moment from "moment"; import moment from "moment";
import { pond } from "protobuf-ts/pond";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getSignatureAccentColour } from "services/whiteLabel"; import { getSignatureAccentColour } from "services/whiteLabel";
import NotificationDrawer from "./NotificationDrawer";
const useStyles = makeStyles((theme: Theme) => ({ const useStyles = makeStyles((theme: Theme) => ({
button: { button: {
@ -47,9 +50,11 @@ interface Props {
} }
export default function HeaderButtons(props: Props) { export default function HeaderButtons(props: Props) {
const {hasTeams, /*user,*/ team} = props const {hasTeams, user, team} = props
const [hasNewChats, setHasNewChats] = useState(false); const [hasNewChats, setHasNewChats] = useState(false);
const [hasNewNotifications, setHasNewNotifications] = useState(false);
const [chatDrawerOpen, setChatDrawerOpen] = useState(false); const [chatDrawerOpen, setChatDrawerOpen] = useState(false);
const [notificationDrawerOpen, setNotificationDrawerOpen] = useState<boolean>(false);
const classes = useStyles(); const classes = useStyles();
useEffect(() => { useEffect(() => {
@ -70,6 +75,30 @@ export default function HeaderButtons(props: Props) {
} }
}, [team]); }, [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 = () => { const chatButton = () => {
if (!hasTeams) return; if (!hasTeams) return;
if (team.key().length < 1) return if (team.key().length < 1) return
@ -83,17 +112,44 @@ export default function HeaderButtons(props: Props) {
setHasNewChats(false); setHasNewChats(false);
}}> }}>
<ChatIcon /> <ChatIcon />
{hasNewChats && (
<Box
width="8px"
height="8px"
borderRadius="50%"
className={classes.on}
/>
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</>
)
}
{hasNewChats && ( const notificationButton = () => {
<Box if (!user.settings?.notificationMethods?.includes(
width="8px" pond.NotificationMethod.NOTIFICATION_METHOD_APP
height="8px" )) return
borderRadius="50%" return (
className={classes.on} <>
/> <Tooltip title="Notifications">
)} <IconButton
className={classes.button}
onClick={() => {
setNotificationDrawerOpen(true);
setHasNewChats(false);
}}>
<Notifications />
{hasNewNotifications && (
<Box
width="8px"
height="8px"
borderRadius="50%"
className={classes.on}
/>
)}
</IconButton>
</Tooltip>
</> </>
) )
} }
@ -108,6 +164,11 @@ export default function HeaderButtons(props: Props) {
team={team} team={team}
/> />
)} )}
{notificationButton()}
<NotificationDrawer
setNotificationDrawerOpen={setNotificationDrawerOpen}
notificationDrawerOpen={notificationDrawerOpen}
/>
</> </>
) )
} }

View file

@ -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<React.SetStateAction<boolean>>;
}
export default function NotificationDrawer(props: Props) {
const { notificationDrawerOpen, setNotificationDrawerOpen } = props;
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [notifications, setNotifications] = useState<Map<string, pond.Notification>>(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<string, pond.Notification> = 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<string, pond.Notification> = 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 (
<Drawer
anchor="right"
open={notificationDrawerOpen}
onClose={() => setNotificationDrawerOpen(false)}
style={{ height: "100%", maxWidth: "100%" }}
title={"Notifications"}>
<div className={isMobile ? classes.chatDrawerMobile : classes.chatDrawer}>
<div className={classes.drawerHeader}>
<IconButton onClick={() => setNotificationDrawerOpen(false)}>
<ChevronRightIcon />
</IconButton>
<Typography style={{ fontWeight: 650, fontSize: 20 }}>Notifications</Typography>
<Button
variant="contained"
color="primary"
onClick={() => {
removeNotification(Array.from(notifications.keys()));
}}>
Clear
</Button>
</div>
<List>
<Divider />
{loading && (
<div style={{ textAlign: "center", margin: theme.spacing(2) }}>
<CircularProgress />
</div>
)}
{!loading && notifications.size < 1 && (
<Typography
color="textSecondary"
style={{ textAlign: "center", margin: theme.spacing(2) }}>
No Notifications
</Typography>
)}
{!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 (
<React.Fragment key={notification.settings?.key}>
<ListItem
// button={hasSource(notification) as any}
onClick={() => notificationClick(notification)}
>
<ListItemIcon>
{/*<ImgIcon src={cIcon} />*/}
<Avatar alt={user.name()} src={cIcon} />
</ListItemIcon>
<ListItemText>
<Grid container direction="column">
<Grid container direction="row" alignItems="center" alignContent="center">
<Grid item>
<Typography>{title}</Typography>
</Grid>
<Grid item>
<Typography
color={"textSecondary"}
style={{ marginLeft: theme.spacing(0.5) }}>
{notification.settings?.subtitle &&
" - " + notification.settings?.subtitle}
</Typography>
</Grid>
</Grid>
{notification.status?.timestamp && (
<Grid
item
style={{
marginTop: theme.spacing(-0.75),
marginLeft: theme.spacing(1)
}}>
<Typography variant="caption" color="textSecondary">
{moment(notification.status.timestamp).fromNow()}
</Typography>
</Grid>
)}
{hasSource(notification) && (
<Grid item>
<Typography variant="subtitle1" color="textSecondary">
Click to view {notification.settings?.sourceType}
</Typography>
</Grid>
)}
</Grid>
</ListItemText>
<ListItemSecondaryAction>
<Tooltip title="Hide Notification">
<IconButton
onClick={() =>
removeNotification(
notification.settings?.key ? [notification.settings.key] : []
)
}>
<Remove />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
<Divider />
</React.Fragment>
);
})}
{!loading && !loadingMore && notifications.size > 0 && notifications.size < total && (
<ListItem onClick={() => setOffset(offset + 10)}>
<div style={{ margin: "auto" }}>
<Typography>Load More</Typography>
</div>
</ListItem>
)}{" "}
{loadingMore && (
<ListItem>
<div style={{ margin: "auto" }}>
<CircularProgress />
</div>
</ListItem>
)}{" "}
{!loadingMore && notifications.size > 0 && notifications.size >= total && (
<ListItem>
<div style={{ margin: "auto" }}>
<Typography color="textSecondary">End of list.</Typography>
</div>
</ListItem>
)}
</List>
</div>
</Drawer>
);
}

View file

@ -7,6 +7,7 @@ export {
useBinAPI, useBinAPI,
// useBinYardAPI, // useBinYardAPI,
useNoteAPI, useNoteAPI,
useNotificationAPI,
// useComponentAPI, // useComponentAPI,
// useComponentsWebsocket, // useComponentsWebsocket,
// useComponentWebsocket, // useComponentWebsocket,

View file

@ -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<AxiosResponse<pond.ListNotificationsResponse>>;
hideNotification: (keys: string[]) => Promise<AxiosResponse<pond.HideNotificationResponse>>;
listObjectNotifications: (
key: string,
type: pond.ObjectType,
limit: number,
offset: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
as?: string
) => Promise<AxiosResponse<pond.ListObjectNotificationsResponse>>;
}
export const NotificationAPIContext = createContext<INotificationAPIContext>(
{} as INotificationAPIContext
);
interface Props {}
export default function NotificationProvider(props: PropsWithChildren<Props>) {
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<pond.ListNotificationsResponse>(
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<pond.ListObjectNotificationsResponse>(
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<pond.HideNotificationResponse>(
pondURL("/notifications/hide"),
pond.NotificationKeys.create({ keys: keys })
);
};
return (
<NotificationAPIContext.Provider
value={{
listNotifications,
hideNotification,
listObjectNotifications
}}>
{children}
</NotificationAPIContext.Provider>
);
}
export const useNotificationAPI = () => useContext(NotificationAPIContext);

View file

@ -10,6 +10,7 @@ import NoteProvider, { useNoteAPI } from "./noteAPI";
import DeviceProvider, { useDeviceAPI } from "./deviceAPI"; import DeviceProvider, { useDeviceAPI } from "./deviceAPI";
import BackpackProvider, { useBackpackAPI } from "./backpackAPI"; import BackpackProvider, { useBackpackAPI } from "./backpackAPI";
import GroupProvider, { useGroupAPI } from "./groupAPI"; import GroupProvider, { useGroupAPI } from "./groupAPI";
import NotificationProvider, { useNotificationAPI } from "./notificationAPI";
// import NoteProvider from "providers/noteAPI"; // import NoteProvider from "providers/noteAPI";
export const pondURL = (partial: string, demo: boolean = false): string => { export const pondURL = (partial: string, demo: boolean = false): string => {
@ -36,7 +37,9 @@ export default function PondProvider(props: PropsWithChildren<any>) {
<DeviceProvider> <DeviceProvider>
<GroupProvider> <GroupProvider>
<BackpackProvider> <BackpackProvider>
{children} <NotificationProvider>
{children}
</NotificationProvider>
</BackpackProvider> </BackpackProvider>
</GroupProvider> </GroupProvider>
</DeviceProvider> </DeviceProvider>
@ -60,4 +63,5 @@ export {
useGateAPI, useGateAPI,
useGroupAPI, useGroupAPI,
useNoteAPI, useNoteAPI,
useNotificationAPI
}; };