diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js
index ef8a6275..c8814897 100644
--- a/web/src/app/Connection.js
+++ b/web/src/app/Connection.js
@@ -1,9 +1,9 @@
import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
-const retryBackoffSeconds = [5, 10, 15, 20, 30, 45];
+const retryBackoffSeconds = [5, 10, 15, 20, 30];
class Connection {
- constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification) {
+ constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) {
this.connectionId = connectionId;
this.subscriptionId = subscriptionId;
this.baseUrl = baseUrl;
@@ -12,6 +12,7 @@ class Connection {
this.since = since;
this.shortUrl = topicShortUrl(baseUrl, topic);
this.onNotification = onNotification;
+ this.onStateChanged = onStateChanged;
this.ws = null;
this.retryCount = 0;
this.retryTimeout = null;
@@ -28,6 +29,7 @@ class Connection {
this.ws.onopen = (event) => {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event);
this.retryCount = 0;
+ this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
}
this.ws.onmessage = (event) => {
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
@@ -60,6 +62,7 @@ class Connection {
this.retryCount++;
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
+ this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
}
};
this.ws.onerror = (event) => {
@@ -95,4 +98,9 @@ class Connection {
}
}
+export class ConnectionState {
+ static Connected = "connected";
+ static Connecting = "connecting";
+}
+
export default Connection;
diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js
index 42cd0160..50cb4d54 100644
--- a/web/src/app/ConnectionManager.js
+++ b/web/src/app/ConnectionManager.js
@@ -3,10 +3,36 @@ import {sha256} from "./utils";
class ConnectionManager {
constructor() {
+ console.log(`connection manager`)
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
+ this.stateListener = null; // Fired when connection state changes
+ this.notificationListener = null; // Fired when new notifications arrive
}
- async refresh(subscriptions, users, onNotification) {
+ registerStateListener(listener) {
+ this.stateListener = listener;
+ }
+
+ resetStateListener() {
+ this.stateListener = null;
+ }
+
+ registerNotificationListener(listener) {
+ this.notificationListener = listener;
+ }
+
+ resetNotificationListener() {
+ this.notificationListener = null;
+ }
+
+ /**
+ * This function figures out which websocket connections should be running by comparing the
+ * current state of the world (connections) with the target state (targetIds).
+ *
+ * It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
+ * connections. If any of them change, the connection is closed/replaced.
+ */
+ async refresh(subscriptions, users) {
if (!subscriptions || !users) {
return;
}
@@ -17,10 +43,9 @@ class ConnectionManager {
const connectionId = await makeConnectionId(s, user);
return {...s, user, connectionId};
}));
- const activeIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
- const deletedIds = Array.from(this.connections.keys()).filter(id => !activeIds.includes(id));
+ const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
+ const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id));
- console.log(subscriptionsWithUsersAndConnectionId);
// Create and add new connections
subscriptionsWithUsersAndConnectionId.forEach(subscription => {
const subscriptionId = subscription.id;
@@ -31,7 +56,16 @@ class ConnectionManager {
const topic = subscription.topic;
const user = subscription.user;
const since = subscription.last;
- const connection = new Connection(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification);
+ const connection = new Connection(
+ connectionId,
+ subscriptionId,
+ baseUrl,
+ topic,
+ user,
+ since,
+ (subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
+ (subscriptionId, state) => this.stateChanged(subscriptionId, state)
+ );
this.connections.set(connectionId, connection);
console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`);
connection.start();
@@ -46,6 +80,18 @@ class ConnectionManager {
connection.close();
});
}
+
+ stateChanged(subscriptionId, state) {
+ if (this.stateListener) {
+ this.stateListener(subscriptionId, state);
+ }
+ }
+
+ notificationReceived(subscriptionId, notification) {
+ if (this.notificationListener) {
+ this.notificationListener(subscriptionId, notification);
+ }
+ }
}
const makeConnectionId = async (subscription, user) => {
diff --git a/web/src/app/NotificationManager.js b/web/src/app/NotificationManager.js
index f280a15a..46225b48 100644
--- a/web/src/app/NotificationManager.js
+++ b/web/src/app/NotificationManager.js
@@ -1,4 +1,4 @@
-import {formatMessage, formatTitleWithFallback, topicShortUrl} from "./utils";
+import {formatMessage, formatTitleWithFallback, openUrl, topicShortUrl} from "./utils";
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
@@ -19,7 +19,7 @@ class NotificationManager {
icon: '/static/img/favicon.png'
});
if (notification.click) {
- n.onclick = (e) => window.open(notification.click);
+ n.onclick = (e) => openUrl(notification.click);
} else {
n.onclick = onClickFallback;
}
diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js
index e2f9711f..17edce40 100644
--- a/web/src/app/SubscriptionManager.js
+++ b/web/src/app/SubscriptionManager.js
@@ -13,6 +13,11 @@ class SubscriptionManager {
await db.subscriptions.put(subscription);
}
+ async updateState(subscriptionId, state) {
+ console.log(`Update state: ${subscriptionId} ${state}`)
+ db.subscriptions.update(subscriptionId, { state: state });
+ }
+
async remove(subscriptionId) {
await db.subscriptions.delete(subscriptionId);
await db.notifications
diff --git a/web/src/app/utils.js b/web/src/app/utils.js
index 19c5ae4b..914240e5 100644
--- a/web/src/app/utils.js
+++ b/web/src/app/utils.js
@@ -110,6 +110,10 @@ export const formatBytes = (bytes, decimals = 2) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
+export const openUrl = (url) => {
+ window.open(url, "_blank", "noopener,noreferrer");
+};
+
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder('utf-8');
diff --git a/web/src/components/App.js b/web/src/components/App.js
index ada71cc9..8f344c49 100644
--- a/web/src/components/App.js
+++ b/web/src/components/App.js
@@ -23,11 +23,8 @@ import userManager from "../app/UserManager";
// TODO make default server functional
// TODO routing
// TODO embed into ntfy server
-// TODO connection indicator in subscription list
const App = () => {
- console.log(`[App] Rendering main view`);
-
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [prefsOpen, setPrefsOpen] = useState(false);
const [selectedSubscription, setSelectedSubscription] = useState(null);
@@ -75,18 +72,26 @@ const App = () => {
setTimeout(() => load(), 5000);
}, [/* initial render */]);
useEffect(() => {
- const notificationClickFallback = (subscription) => setSelectedSubscription(subscription);
const handleNotification = async (subscriptionId, notification) => {
try {
const added = await subscriptionManager.addNotification(subscriptionId, notification);
if (added) {
- await notificationManager.notify(subscriptionId, notification, notificationClickFallback)
+ const defaultClickAction = (subscription) => setSelectedSubscription(subscription);
+ await notificationManager.notify(subscriptionId, notification, defaultClickAction)
}
} catch (e) {
console.error(`[App] Error handling notification`, e);
}
};
- connectionManager.refresh(subscriptions, users, handleNotification); // Dangle
+ connectionManager.registerStateListener(subscriptionManager.updateState);
+ connectionManager.registerNotificationListener(handleNotification);
+ return () => {
+ connectionManager.resetStateListener();
+ connectionManager.resetNotificationListener();
+ }
+ }, [/* initial render */]);
+ useEffect(() => {
+ connectionManager.refresh(subscriptions, users); // Dangle
}, [subscriptions, users]);
useEffect(() => {
const subscriptionId = (selectedSubscription) ? selectedSubscription.id : "";
diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js
index b96774af..c60c9fc5 100644
--- a/web/src/components/Navigation.js
+++ b/web/src/components/Navigation.js
@@ -11,10 +11,11 @@ import List from "@mui/material/List";
import SettingsIcon from "@mui/icons-material/Settings";
import AddIcon from "@mui/icons-material/Add";
import SubscribeDialog from "./SubscribeDialog";
-import {Alert, AlertTitle, ListSubheader} from "@mui/material";
+import {Alert, AlertTitle, CircularProgress, ListSubheader} from "@mui/material";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import {topicShortUrl} from "../app/utils";
+import {ConnectionState} from "../app/Connection";
const navWidth = 240;
@@ -117,19 +118,29 @@ const SubscriptionList = (props) => {
return (
<>
{props.subscriptions.map(subscription =>
- props.onSubscriptionClick(subscription.id)}
+ subscription={subscription}
selected={props.selectedSubscription && !props.prefsOpen && props.selectedSubscription.id === subscription.id}
- >
-
-
-
- )}
+ onClick={() => props.onSubscriptionClick(subscription.id)}
+ />)}
>
);
}
+const SubscriptionItem = (props) => {
+ const subscription = props.subscription;
+ const icon = (subscription.state === ConnectionState.Connecting)
+ ?
+ : ;
+ return (
+
+ {icon}
+
+
+ );
+};
+
const PermissionAlert = (props) => {
return (
<>
diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js
index 703e0d48..aced7fe1 100644
--- a/web/src/components/Notifications.js
+++ b/web/src/components/Notifications.js
@@ -4,7 +4,15 @@ import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography";
import * as React from "react";
import {useState} from "react";
-import {formatBytes, formatMessage, formatShortDateTime, formatTitle, topicShortUrl, unmatchedTags} from "../app/utils";
+import {
+ formatBytes,
+ formatMessage,
+ formatShortDateTime,
+ formatTitle,
+ openUrl,
+ topicShortUrl,
+ unmatchedTags
+} from "../app/utils";
import IconButton from "@mui/material/IconButton";
import CloseIcon from '@mui/icons-material/Close';
import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles";
@@ -49,6 +57,9 @@ const NotificationItem = (props) => {
await subscriptionManager.deleteNotification(notification.id)
}
const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000;
+ const showAttachmentActions = attachment && !expired;
+ const showClickAction = notification.click;
+ const showActions = showAttachmentActions || showClickAction;
return (
@@ -69,10 +80,13 @@ const NotificationItem = (props) => {
{attachment && }
{tags && Tags: {tags}}
- {attachment && !expired &&
+ {showActions &&
-
-
+ {showAttachmentActions && <>
+
+
+ >}
+ {showClickAction && }
}