diff --git a/README.md b/README.md index 06148a2..484b7ed 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ We at [etke.cc](https://etke.cc) attempting to develop everything open-source, b The following list contains such features - they are only available for [etke.cc](https://etke.cc) customers. * 📊 [Server Status indicator and page](https://github.com/etkecc/synapse-admin/pull/182) +* 📬 [Server Notifications indicator and page](https://github.com/etkecc/synapse-admin/pull/240) ### Development diff --git a/screenshots/etke.cc/server-notifications/badge.webp b/screenshots/etke.cc/server-notifications/badge.webp new file mode 100644 index 0000000..6c351a5 Binary files /dev/null and b/screenshots/etke.cc/server-notifications/badge.webp differ diff --git a/screenshots/etke.cc/server-notifications/page.webp b/screenshots/etke.cc/server-notifications/page.webp new file mode 100644 index 0000000..2c266ce Binary files /dev/null and b/screenshots/etke.cc/server-notifications/page.webp differ diff --git a/src/App.tsx b/src/App.tsx index f24bf1c..7bfd4c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import authProvider from "./synapse/authProvider"; import dataProvider from "./synapse/dataProvider"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import ServerStatusPage from "./components/etke.cc/ServerStatusPage"; +import ServerNotificationsPage from "./components/etke.cc/ServerNotificationsPage"; // TODO: Can we use lazy loading together with browser locale? const messages = { @@ -65,6 +66,7 @@ export const App = () => ( } /> } /> + } /> diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx index 30eaaa0..b87ddb6 100644 --- a/src/components/AdminLayout.tsx +++ b/src/components/AdminLayout.tsx @@ -5,6 +5,7 @@ import { Icons, DefaultIcon } from "../utils/icons"; import { MenuItem, GetConfig, ClearConfig } from "../utils/config"; import Footer from "./Footer"; import ServerStatusBadge from "./etke.cc/ServerStatusBadge"; +import { ServerNotificationsBadge } from "./etke.cc/ServerNotificationsBadge"; const AdminUserMenu = () => { const [open, setOpen] = useState(false); @@ -50,6 +51,7 @@ const AdminAppBar = () => { return (}> + ); }; diff --git a/src/components/etke.cc/README.md b/src/components/etke.cc/README.md index b7dfdf7..21afe85 100644 --- a/src/components/etke.cc/README.md +++ b/src/components/etke.cc/README.md @@ -28,3 +28,15 @@ Server Status page. This page contains the following information: * Overall server status (up/updating/has issues) * Details about the currently running command (if any) * Details about the server's components statuses (up/down with error details and suggested actions) by categories + +### Server Notifications icon + +![Server Notifications icon](../../../screenshots/etke.cc/server-notifications/badge.webp) + +In the application bar the new notifications icon is displayed that shows the number of unread (not removed) notifications + +### Server Notifications page + +![Server Notifications Page](../../../screenshots/etke.cc/server-notifications/page.webp) + +When you click on a notification from the [Server Notifications icon](#server-notifications-icon)'s list in the application bar, you will be redirected to the Server Notifications page. This page contains the full text of all the notifications you have about your server. diff --git a/src/components/etke.cc/ServerNotificationsBadge.tsx b/src/components/etke.cc/ServerNotificationsBadge.tsx new file mode 100644 index 0000000..560cc3d --- /dev/null +++ b/src/components/etke.cc/ServerNotificationsBadge.tsx @@ -0,0 +1,184 @@ +import { Badge, useTheme, Button, Paper, Popper, ClickAwayListener, Box, List, ListItem, ListItemText, Typography, ListSubheader, IconButton, Divider, Tooltip } from "@mui/material"; +import NotificationsIcon from '@mui/icons-material/Notifications'; +import DeleteIcon from "@mui/icons-material/Delete"; +import { useDataProvider, useStore } from "react-admin"; +import { useNavigate } from "react-router"; +import { Fragment, useEffect, useState } from "react"; +import { useAppContext } from "../../Context"; +import { ServerNotificationsResponse } from "../../synapse/dataProvider"; + +const SERVER_NOTIFICATIONS_INTERVAL_TIME = 300000; + +const useServerNotifications = () => { + const [serverNotifications, setServerNotifications] = useStore("serverNotifications", { notifications: [], success: false }); + const { etkeccAdmin } = useAppContext(); + const dataProvider = useDataProvider(); + const { notifications, success } = serverNotifications; + + const fetchNotifications = async () => { + const notificationsResponse: ServerNotificationsResponse = await dataProvider.getServerNotifications(etkeccAdmin); + setServerNotifications({ + ...notificationsResponse, + notifications: notificationsResponse.notifications, + success: notificationsResponse.success + }); + }; + + const deleteServerNotifications = async () => { + const deleteResponse = await dataProvider.deleteServerNotifications(etkeccAdmin); + if (deleteResponse.success) { + await fetchNotifications(); + } + }; + + useEffect(() => { + let serverNotificationsInterval: NodeJS.Timeout; + if (etkeccAdmin) { + fetchNotifications(); + setTimeout(() => { + // start the interval after the SERVER_NOTIFICATIONS_INTERVAL_TIME to avoid too many requests + serverNotificationsInterval = setInterval(fetchNotifications, SERVER_NOTIFICATIONS_INTERVAL_TIME); + }, SERVER_NOTIFICATIONS_INTERVAL_TIME); + } + + return () => { + if (serverNotificationsInterval) { + clearInterval(serverNotificationsInterval); + } + } + }, [etkeccAdmin]); + + return { success, notifications, deleteServerNotifications }; +}; + +export const ServerNotificationsBadge = () => { + const navigate = useNavigate(); + const { success, notifications, deleteServerNotifications } = useServerNotifications(); + const theme = useTheme(); + + // Modify menu state to work with Popper + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(anchorEl ? null : event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleSeeAllNotifications = () => { + handleClose(); + navigate("/server_notifications"); + }; + + const handleClearAllNotifications = async () => { + deleteServerNotifications() + handleClose(); + }; + + if (!success) { + return null; + } + + return ( + + + 0 ? `${notifications.length} new notifications` : `No notifications yet`}> + {notifications && notifications.length > 0 && ( + + + + ) || } + + + + + + {(!notifications || notifications.length === 0) ? ( + No new notifications + ) : ( + + + Notifications + handleSeeAllNotifications()}>See all notifications + + + {notifications.map((notification, index) => { + return ( + handleSeeAllNotifications()} + sx={{ + "&:hover": { + backgroundColor: "action.hover", + cursor: "pointer" + } + }} + > + + } + /> + + + + )})} + + + + + )} + + + + + ); +}; diff --git a/src/components/etke.cc/ServerNotificationsPage.tsx b/src/components/etke.cc/ServerNotificationsPage.tsx new file mode 100644 index 0000000..2476af8 --- /dev/null +++ b/src/components/etke.cc/ServerNotificationsPage.tsx @@ -0,0 +1,58 @@ +import { Box, Typography, Paper, Button } from "@mui/material" +import { Stack } from "@mui/material" +import { useStore } from "react-admin" +import dataProvider, { ServerNotificationsResponse } from "../../synapse/dataProvider" +import { useAppContext } from "../../Context"; +import DeleteIcon from "@mui/icons-material/Delete"; +const DisplayTime = ({ date }: { date: string }) => { + const dateFromDateString = new Date(date); + return <>{dateFromDateString.toLocaleString()}; +}; + +const ServerNotificationsPage = () => { + const { etkeccAdmin } = useAppContext(); + const [serverNotifications, setServerNotifications] = useStore("serverNotifications", { + notifications: [], + success: false, + }); + + const notifications = serverNotifications.notifications; + + return ( + + + + Server Notifications + + + + + {notifications.length === 0 ? ( + + No new notifications. + + ) : ( + notifications.map((notification, index) => ( + + + + + + + + + )) + )} + + ); +}; + +export default ServerNotificationsPage; diff --git a/src/synapse/authProvider.ts b/src/synapse/authProvider.ts index d88c83c..9ee74aa 100644 --- a/src/synapse/authProvider.ts +++ b/src/synapse/authProvider.ts @@ -80,11 +80,7 @@ const authProvider: AuthProvider = { localStorage.setItem("access_token", accessToken ? accessToken : json.access_token); localStorage.setItem("device_id", json.device_id); localStorage.setItem("login_type", accessToken ? "accessToken" : "credentials"); - - // when doing access token auth, config is not fetched, so we need to do it here - if (accessToken) { - await FetchConfig(); - } + await FetchConfig(); return Promise.resolve({redirectTo: "/"}); } catch(err) { diff --git a/src/synapse/dataProvider.ts b/src/synapse/dataProvider.ts index c163781..cd51e65 100644 --- a/src/synapse/dataProvider.ts +++ b/src/synapse/dataProvider.ts @@ -291,6 +291,17 @@ export interface ServerProcessResponse { command?: string; } +export interface ServerNotification { + event_id: string; + output: string; + sent_at: string; +} + +export interface ServerNotificationsResponse { + success: boolean; + notifications: ServerNotification[]; +} + export interface SynapseDataProvider extends DataProvider { deleteMedia: (params: DeleteMediaParams) => Promise; purgeRemoteMedia: (params: DeleteMediaParams) => Promise; @@ -302,6 +313,8 @@ export interface SynapseDataProvider extends DataProvider { makeRoomAdmin: (room_id: string, user_id: string) => Promise<{ success: boolean; error?: string; errcode?: string }>; getServerRunningProcess: (etkeAdminUrl: string) => Promise; getServerStatus: (etkeAdminUrl: string) => Promise; + getServerNotifications: (etkeAdminUrl: string) => Promise; + deleteServerNotifications: (etkeAdminUrl: string) => Promise<{ success: boolean }>; } const resourceMap = { @@ -995,6 +1008,60 @@ const baseDataProvider: SynapseDataProvider = { } return { success: false, ok: false, host: "", results: [] }; + }, + getServerNotifications: async (serverNotificationsUrl: string): Promise => { + try { + const response = await fetch(`${serverNotificationsUrl}/notifications`, { + headers: { + "Authorization": `Bearer ${localStorage.getItem("access_token")}` + } + }); + if (!response.ok) { + console.error(`Error getting server notifications: ${response.status} ${response.statusText}`); + return { success: false, notifications: [] }; + } + + const status = response.status; + if (status === 204) { + return { success: true, notifications: [] }; + } + + if (status === 200) { + const json = await response.json(); + const result = { success: true, notifications: json } as ServerNotificationsResponse; + return result; + } + + return { success: true, notifications: [] }; + } catch (error) { + console.error("Error getting server notifications", error); + } + + return { success: false, notifications: [] }; + }, + deleteServerNotifications: async (serverNotificationsUrl: string) => { + try { + const response = await fetch(`${serverNotificationsUrl}/notifications`, { + headers: { + "Authorization": `Bearer ${localStorage.getItem("access_token")}` + }, + method: "DELETE" + }); + if (!response.ok) { + console.error(`Error deleting server notifications: ${response.status} ${response.statusText}`); + return { success: false }; + } + + const status = response.status; + if (status === 204) { + const result = { success: true } + return result; + } + } catch (error) { + console.error("Error deleting server notifications", error); + } + + return { success: false }; } }; diff --git a/src/utils/config.ts b/src/utils/config.ts index e7fcf79..fe9c4e8 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -53,7 +53,6 @@ export const FetchConfig = async () => { console.log(`${protocol}://${homeserver}/.well-known/matrix/client not found, skipping`, e); } } - } // load config from context diff --git a/src/utils/mxid.ts b/src/utils/mxid.ts index 1f5ec7a..c66ad72 100644 --- a/src/utils/mxid.ts +++ b/src/utils/mxid.ts @@ -42,19 +42,20 @@ export function generateRandomMXID(): string { * @returns full MXID as string */ export function returnMXID(input: string | Identifier): string { - const homeserver = localStorage.getItem("home_server"); + const inputStr = input as string; + const homeserver = localStorage.getItem("home_server") || ""; // when homeserver is not (just) a domain name, but a domain:port or even an IPv6 address - if (input.endsWith(homeserver) && input.startsWith("@")) { - return input as string; // Already a valid MXID + if (homeserver != "" && inputStr.endsWith(homeserver) && inputStr.startsWith("@")) { + return inputStr; // Already a valid MXID } // Check if the input already looks like a valid MXID (i.e., starts with "@" and contains ":") if (isMXID(input)) { - return input as string; // Already a valid MXID + return inputStr; // Already a valid MXID } // If input is not a valid MXID, assume it's a localpart and construct the MXID - const localpart = typeof input === 'string' && input.startsWith('@') ? input.slice(1) : input; + const localpart = typeof input === 'string' && inputStr.startsWith('@') ? inputStr.slice(1) : inputStr; return `@${localpart}:${homeserver}`; }