From 3fac1c34323026363a459d40aeadda5f22418c5e Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 23 Feb 2022 20:30:12 -0500 Subject: [PATCH] Refactor to make it more like the Android app --- web/src/app/Api.js | 7 ++- web/src/app/Connection.js | 52 +++++++++++++++++ web/src/app/ConnectionManager.js | 36 ++++++++++++ web/src/app/{Storage.js => Repository.js} | 17 ++++-- web/src/app/Subscription.js | 13 ++++- web/src/app/Subscriptions.js | 52 +++++++++++++++++ web/src/app/WsConnection.js | 53 ----------------- web/src/components/App.js | 71 ++++++++--------------- web/src/components/DetailSettingsIcon.js | 6 +- 9 files changed, 196 insertions(+), 111 deletions(-) create mode 100644 web/src/app/Connection.js create mode 100644 web/src/app/ConnectionManager.js rename web/src/app/{Storage.js => Repository.js} (80%) create mode 100644 web/src/app/Subscriptions.js delete mode 100644 web/src/app/WsConnection.js diff --git a/web/src/app/Api.js b/web/src/app/Api.js index f489415..f126bcf 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -1,7 +1,7 @@ import {topicUrlJsonPoll, fetchLinesIterator, topicUrl} from "./utils"; class Api { - static async poll(baseUrl, topic) { + async poll(baseUrl, topic) { const url = topicUrlJsonPoll(baseUrl, topic); const messages = []; console.log(`[Api] Polling ${url}`); @@ -11,7 +11,7 @@ class Api { return messages.sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first } - static async publish(baseUrl, topic, message) { + async publish(baseUrl, topic, message) { const url = topicUrl(baseUrl, topic); console.log(`[Api] Publishing message to ${url}`); await fetch(url, { @@ -21,4 +21,5 @@ class Api { } } -export default Api; +const api = new Api(); +export default api; diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js new file mode 100644 index 0000000..d5ec790 --- /dev/null +++ b/web/src/app/Connection.js @@ -0,0 +1,52 @@ +class Connection { + constructor(wsUrl, subscriptionId, onNotification) { + this.wsUrl = wsUrl; + this.subscriptionId = subscriptionId; + this.onNotification = onNotification; + this.ws = null; + } + + start() { + const socket = new WebSocket(this.wsUrl); + socket.onopen = (event) => { + console.log(`[Connection] [${this.subscriptionId}] Connection established`); + } + socket.onmessage = (event) => { + console.log(`[Connection] [${this.subscriptionId}] Message received from server: ${event.data}`); + try { + const data = JSON.parse(event.data); + const relevantAndValid = + data.event === 'message' && + 'id' in data && + 'time' in data && + 'message' in data; + if (!relevantAndValid) { + return; + } + this.onNotification(this.subscriptionId, data); + } catch (e) { + console.log(`[Connection] [${this.subscriptionId}] Error handling message: ${e}`); + } + }; + socket.onclose = (event) => { + if (event.wasClean) { + console.log(`[Connection] [${this.subscriptionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`); + } else { + console.log(`[Connection] [${this.subscriptionId}] Connection died`); + } + }; + socket.onerror = (event) => { + console.log(this.subscriptionId, `[Connection] [${this.subscriptionId}] ${event.message}`); + }; + this.ws = socket; + } + + cancel() { + if (this.ws !== null) { + this.ws.close(); + this.ws = null; + } + } +} + +export default Connection; diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js new file mode 100644 index 0000000..9018c61 --- /dev/null +++ b/web/src/app/ConnectionManager.js @@ -0,0 +1,36 @@ +import Connection from "./Connection"; + +export class ConnectionManager { + constructor() { + this.connections = new Map(); + } + + refresh(subscriptions, onNotification) { + console.log(`[ConnectionManager] Refreshing connections`); + const subscriptionIds = subscriptions.ids(); + const deletedIds = Array.from(this.connections.keys()).filter(id => !subscriptionIds.includes(id)); + + // Create and add new connections + subscriptions.forEach((id, subscription) => { + const added = !this.connections.get(id) + if (added) { + const wsUrl = subscription.wsUrl(); + const connection = new Connection(wsUrl, id, onNotification); + this.connections.set(id, connection); + console.log(`[ConnectionManager] Starting new connection ${id} using URL ${wsUrl}`); + connection.start(); + } + }); + + // Delete old connections + deletedIds.forEach(id => { + console.log(`[ConnectionManager] Closing connection ${id}`); + const connection = this.connections.get(id); + this.connections.delete(id); + connection.cancel(); + }); + } +} + +const connectionManager = new ConnectionManager(); +export default connectionManager; diff --git a/web/src/app/Storage.js b/web/src/app/Repository.js similarity index 80% rename from web/src/app/Storage.js rename to web/src/app/Repository.js index b591c0d..7ceb9a7 100644 --- a/web/src/app/Storage.js +++ b/web/src/app/Repository.js @@ -1,8 +1,10 @@ import {topicUrl} from "./utils"; import Subscription from "./Subscription"; -const LocalStorage = { - getSubscriptions() { +export class Repository { + loadSubscriptions() { + console.log(`[Repository] Loading subscriptions from localStorage`); + const subscriptions = {}; const rawSubscriptions = localStorage.getItem('subscriptions'); if (rawSubscriptions === null) { @@ -20,8 +22,12 @@ const LocalStorage = { console.log("LocalStorage", `Unable to deserialize subscriptions: ${e.message}`) return {}; } - }, + } + saveSubscriptions(subscriptions) { + return; + console.log(`[Repository] Saving subscriptions ${subscriptions} to localStorage`); + const serializedSubscriptions = Object.keys(subscriptions).map(k => { const subscription = subscriptions[k]; return { @@ -32,6 +38,7 @@ const LocalStorage = { }); localStorage.setItem('subscriptions', JSON.stringify(serializedSubscriptions)); } -}; +} -export default LocalStorage; +const repository = new Repository(); +export default repository; diff --git a/web/src/app/Subscription.js b/web/src/app/Subscription.js index 1222ee2..8046cff 100644 --- a/web/src/app/Subscription.js +++ b/web/src/app/Subscription.js @@ -6,24 +6,35 @@ export default class Subscription { topic = ''; notifications = []; lastActive = null; + constructor(baseUrl, topic) { this.id = topicUrl(baseUrl, topic); this.baseUrl = baseUrl; this.topic = topic; } + addNotification(notification) { if (notification.time === null) { - return; + return this; } this.notifications.push(notification); this.lastActive = notification.time; + return this; } + + addNotifications(notifications) { + notifications.forEach(n => this.addNotification(n)); + return this; + } + url() { return this.id; } + wsUrl() { return topicUrlWs(this.baseUrl, this.topic); } + shortUrl() { return shortTopicUrl(this.baseUrl, this.topic); } diff --git a/web/src/app/Subscriptions.js b/web/src/app/Subscriptions.js new file mode 100644 index 0000000..aafe7d5 --- /dev/null +++ b/web/src/app/Subscriptions.js @@ -0,0 +1,52 @@ +class Subscriptions { + constructor() { + this.subscriptions = new Map(); + } + + add(subscription) { + this.subscriptions.set(subscription.id, subscription); + return this; + } + + get(subscriptionId) { + const subscription = this.subscriptions.get(subscriptionId); + if (subscription === undefined) return null; + return subscription; + } + + update(subscription) { + return this.add(subscription); + } + + remove(subscriptionId) { + this.subscriptions.delete(subscriptionId); + return this; + } + + forEach(cb) { + this.subscriptions.forEach((value, key) => cb(key, value)); + } + + map(cb) { + return Array.from(this.subscriptions.values()) + .map(subscription => cb(subscription.id, subscription)); + } + + ids() { + return Array.from(this.subscriptions.keys()); + } + + firstOrNull() { + const first = this.subscriptions.values().next().value; + if (first === undefined) return null; + return first; + } + + clone() { + const c = new Subscriptions(); + c.subscriptions = new Map(this.subscriptions); + return c; + } +} + +export default Subscriptions; diff --git a/web/src/app/WsConnection.js b/web/src/app/WsConnection.js deleted file mode 100644 index 6b39fa2..0000000 --- a/web/src/app/WsConnection.js +++ /dev/null @@ -1,53 +0,0 @@ - -export default class WsConnection { - id = ''; - constructor(subscription, onChange) { - this.id = subscription.id; - this.subscription = subscription; - this.onChange = onChange; - this.ws = null; - } - start() { - const socket = new WebSocket(this.subscription.wsUrl()); - socket.onopen = (event) => { - console.log(this.id, "[open] Connection established"); - } - socket.onmessage = (event) => { - console.log(this.id, `[message] Data received from server: ${event.data}`); - try { - const data = JSON.parse(event.data); - const relevantAndValid = - data.event === 'message' && - 'id' in data && - 'time' in data && - 'message' in data; - if (!relevantAndValid) { - return; - } - console.log('adding') - this.subscription.addNotification(data); - this.onChange(this.subscription); - } catch (e) { - console.log(this.id, `[message] Error handling message: ${e}`); - } - }; - socket.onclose = (event) => { - if (event.wasClean) { - console.log(this.id, `[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`); - } else { - console.log(this.id, `[close] Connection died`); - // e.g. server process killed or network down - // event.code is usually 1006 in this case - } - }; - socket.onerror = (event) => { - console.log(this.id, `[error] ${event.message}`); - }; - this.ws = socket; - } - cancel() { - if (this.ws != null) { - this.ws.close(); - } - } -} diff --git a/web/src/components/App.js b/web/src/components/App.js index acca1e8..1181b38 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -2,7 +2,6 @@ import * as React from 'react'; import {useEffect, useState} from 'react'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; -import WsConnection from '../app/WsConnection'; import {styled, ThemeProvider} from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import MuiDrawer from '@mui/material/Drawer'; @@ -23,8 +22,10 @@ import AddDialog from "./AddDialog"; import NotificationList from "./NotificationList"; import DetailSettingsIcon from "./DetailSettingsIcon"; import theme from "./theme"; -import LocalStorage from "../app/Storage"; -import Api from "../app/Api"; +import api from "../app/Api"; +import repository from "../app/Repository"; +import connectionManager from "../app/ConnectionManager"; +import Subscriptions from "../app/Subscriptions"; const drawerWidth = 240; @@ -77,11 +78,11 @@ const SubscriptionNav = (props) => { const subscriptions = props.subscriptions; return ( <> - {Object.keys(subscriptions).map(id => + {subscriptions.map((id, subscription) => props.handleSubscriptionClick(id)} />) } @@ -103,71 +104,49 @@ const App = () => { console.log("Launching App component"); const [drawerOpen, setDrawerOpen] = useState(true); - const [subscriptions, setSubscriptions] = useState(LocalStorage.getSubscriptions()); - const [connections, setConnections] = useState({}); + const [subscriptions, setSubscriptions] = useState(new Subscriptions()); const [selectedSubscription, setSelectedSubscription] = useState(null); const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); - const subscriptionChanged = (subscription) => { - setSubscriptions(prev => ({...prev, [subscription.id]: subscription})); + const handleNotification = (subscriptionId, notification) => { + setSubscriptions(prev => { + const newSubscription = prev.get(subscriptionId).addNotification(notification); + return prev.update(newSubscription).clone(); + }); }; const handleSubscribeSubmit = (subscription) => { - const connection = new WsConnection(subscription, subscriptionChanged); setSubscribeDialogOpen(false); - setSubscriptions(prev => ({...prev, [subscription.id]: subscription})); - setConnections(prev => ({...prev, [subscription.id]: connection})); + setSubscriptions(prev => prev.add(subscription).clone()); setSelectedSubscription(subscription); - Api.poll(subscription.baseUrl, subscription.topic) + api.poll(subscription.baseUrl, subscription.topic) .then(messages => { - messages.forEach(m => subscription.addNotification(m)); - setSubscriptions(prev => ({...prev, [subscription.id]: subscription})); + setSubscriptions(prev => { + const newSubscription = prev.get(subscription.id).addNotifications(messages); + return prev.update(newSubscription).clone(); + }); }); - connection.start(); }; const handleSubscribeCancel = () => { console.log(`Cancel clicked`); setSubscribeDialogOpen(false); }; - const handleUnsubscribe = (subscription) => { + const handleUnsubscribe = (subscriptionId) => { setSubscriptions(prev => { - const newSubscriptions = {...prev}; - delete newSubscriptions[subscription.id]; - const newSubscriptionValues = Object.values(newSubscriptions); - if (newSubscriptionValues.length > 0) { - setSelectedSubscription(newSubscriptionValues[0]); - } else { - setSelectedSubscription(null); - } + const newSubscriptions = prev.remove(subscriptionId).clone(); + setSelectedSubscription(newSubscriptions.firstOrNull()); return newSubscriptions; }); }; const handleSubscriptionClick = (subscriptionId) => { console.log(`Selected subscription ${subscriptionId}`); - setSelectedSubscription(subscriptions[subscriptionId]); + setSelectedSubscription(subscriptions.get(subscriptionId)); }; const notifications = (selectedSubscription !== null) ? selectedSubscription.notifications : []; const toggleDrawer = () => { setDrawerOpen(!drawerOpen); }; useEffect(() => { - console.log("Starting connections"); - Object.keys(subscriptions).forEach(topicUrl => { - console.log(`Starting connection for ${topicUrl}`); - const subscription = subscriptions[topicUrl]; - const connection = new WsConnection(subscription, subscriptionChanged); - connection.start(); - }); - return () => { - console.log("Stopping connections"); - Object.keys(connections).forEach(topicUrl => { - console.log(`Stopping connection for ${topicUrl}`); - const connection = connections[topicUrl]; - connection.cancel(); - }); - }; - }, [/* only on initial render */]); - useEffect(() => { - console.log(`Saving subscriptions`); - LocalStorage.saveSubscriptions(subscriptions); + connectionManager.refresh(subscriptions, handleNotification); + repository.saveSubscriptions(subscriptions); }, [subscriptions]); return ( diff --git a/web/src/components/DetailSettingsIcon.js b/web/src/components/DetailSettingsIcon.js index f7bcc61..486ec83 100644 --- a/web/src/components/DetailSettingsIcon.js +++ b/web/src/components/DetailSettingsIcon.js @@ -8,7 +8,7 @@ import MenuItem from '@mui/material/MenuItem'; import MenuList from '@mui/material/MenuList'; import IconButton from "@mui/material/IconButton"; import MoreVertIcon from "@mui/icons-material/MoreVert"; -import Api from "../app/Api"; +import api from "../app/Api"; // Originally from https://mui.com/components/menus/#MenuListComposition.js const DetailSettingsIcon = (props) => { @@ -28,13 +28,13 @@ const DetailSettingsIcon = (props) => { const handleUnsubscribe = (event) => { handleClose(event); - props.onUnsubscribe(props.subscription); + props.onUnsubscribe(props.subscription.id); }; const handleSendTestMessage = () => { const baseUrl = props.subscription.baseUrl; const topic = props.subscription.topic; - Api.publish(baseUrl, topic, `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.`); // FIXME result ignored + api.publish(baseUrl, topic, `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.`); // FIXME result ignored setOpen(false); }