Jelajahi Sumber

Conn state listener, click action button

Philipp Heckel 4 tahun lalu
induk
melakukan
5878d7e5a6

+ 10 - 2
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;

+ 51 - 5
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) => {

+ 2 - 2
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;
         }

+ 5 - 0
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

+ 4 - 0
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');

+ 11 - 6
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 : "";

+ 19 - 8
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 =>
-                <ListItemButton
+                <SubscriptionItem
                     key={subscription.id}
-                    onClick={() => props.onSubscriptionClick(subscription.id)}
+                    subscription={subscription}
                     selected={props.selectedSubscription && !props.prefsOpen && props.selectedSubscription.id === subscription.id}
-                >
-                    <ListItemIcon><ChatBubbleOutlineIcon /></ListItemIcon>
-                    <ListItemText primary={topicShortUrl(subscription.baseUrl, subscription.topic)}/>
-                </ListItemButton>
-            )}
+                    onClick={() => props.onSubscriptionClick(subscription.id)}
+            />)}
         </>
     );
 }
 
+const SubscriptionItem = (props) => {
+    const subscription = props.subscription;
+    const icon = (subscription.state === ConnectionState.Connecting)
+        ? <CircularProgress size="24px"/>
+        : <ChatBubbleOutlineIcon/>;
+    return (
+        <ListItemButton onClick={props.onClick} selected={props.selected}>
+            <ListItemIcon>{icon}</ListItemIcon>
+            <ListItemText primary={topicShortUrl(subscription.baseUrl, subscription.topic)}/>
+        </ListItemButton>
+    );
+};
+
 const PermissionAlert = (props) => {
     return (
         <>

+ 18 - 4
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 (
         <Card sx={{ minWidth: 275, padding: 1 }}>
             <CardContent>
@@ -69,10 +80,13 @@ const NotificationItem = (props) => {
                 {attachment && <Attachment attachment={attachment}/>}
                 {tags && <Typography sx={{ fontSize: 14 }} color="text.secondary">Tags: {tags}</Typography>}
             </CardContent>
-            {attachment && !expired &&
+            {showActions &&
                 <CardActions sx={{paddingTop: 0}}>
-                    <Button onClick={() => navigator.clipboard.writeText(attachment.url)}>Copy URL</Button>
-                    <Button onClick={() => window.open(attachment.url)}>Open</Button>
+                    {showAttachmentActions && <>
+                        <Button onClick={() => navigator.clipboard.writeText(attachment.url)}>Copy URL</Button>
+                        <Button onClick={() => openUrl(attachment.url)}>Open attachment</Button>
+                    </>}
+                    {showClickAction && <Button onClick={() => openUrl(notification.click)}>Open link</Button>}
                 </CardActions>
             }
         </Card>