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
+
+
+
+In the application bar the new notifications icon is displayed that shows the number of unread (not removed) notifications
+
+### Server Notifications page
+
+
+
+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}`;
}