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 && } }