From aa79fe2861fb56d5fa73cbebc0b1ca506eb636c0 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 26 Feb 2022 10:14:43 -0500 Subject: [PATCH] Desktop notifications --- server/static/js/app.js | 2 +- web/src/app/Connection.js | 5 +- web/src/app/NotificationManager.js | 33 +++++++++++ web/src/components/App.js | 13 +++++ web/src/components/Navigation.js | 90 +++++++++++++++++------------- 5 files changed, 101 insertions(+), 42 deletions(-) create mode 100644 web/src/app/NotificationManager.js diff --git a/server/static/js/app.js b/server/static/js/app.js index c8f47a5..d19f8b2 100644 --- a/server/static/js/app.js +++ b/server/static/js/app.js @@ -288,7 +288,7 @@ const formatTitle = (m) => { if (m.title) { return formatTitleA(m); } else { - return `${location.host}/${m.topic}`; + return `${location.host}/${m.topic}`; // FIXME } }; diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index fdf5c99..9aedf21 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -32,13 +32,16 @@ class Connection { console.log(`[Connection, ${this.shortUrl}] Message received from server: ${event.data}`); try { const data = JSON.parse(event.data); + if (data.event === 'open') { + return; + } const relevantAndValid = data.event === 'message' && 'id' in data && 'time' in data && 'message' in data; if (!relevantAndValid) { - console.log(`[Connection, ${this.shortUrl}] Message irrelevant or invalid. Ignoring.`); + console.log(`[Connection, ${this.shortUrl}] Unexpected message. Ignoring.`); return; } this.since = data.time + 1; // Sigh. This works because on reconnect, we wait 5+ seconds anyway. diff --git a/web/src/app/NotificationManager.js b/web/src/app/NotificationManager.js new file mode 100644 index 0000000..6126add --- /dev/null +++ b/web/src/app/NotificationManager.js @@ -0,0 +1,33 @@ +import {formatMessage, formatTitle} from "./utils"; + +class NotificationManager { + notify(subscription, notification, onClickFallback) { + const message = formatMessage(notification); + const title = formatTitle(notification); + const n = new Notification(title, { + body: message, + icon: '/static/img/favicon.png' + }); + if (notification.click) { + n.onclick = (e) => window.open(notification.click); + } else { + n.onclick = onClickFallback; + } + } + + granted() { + return Notification.permission === 'granted'; + } + + maybeRequestPermission(cb) { + if (!this.granted()) { + Notification.requestPermission().then((permission) => { + const granted = permission === 'granted'; + cb(granted); + }); + } + } +} + +const notificationManager = new NotificationManager(); +export default notificationManager; diff --git a/web/src/components/App.js b/web/src/components/App.js index d7aea03..5933734 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -13,6 +13,7 @@ import Subscriptions from "../app/Subscriptions"; import Navigation from "./Navigation"; import ActionBar from "./ActionBar"; import Users from "../app/Users"; +import notificationManager from "../app/NotificationManager"; const App = () => { console.log(`[App] Rendering main view`); @@ -21,9 +22,13 @@ const App = () => { const [subscriptions, setSubscriptions] = useState(new Subscriptions()); const [users, setUsers] = useState(new Users()); const [selectedSubscription, setSelectedSubscription] = useState(null); + const [notificationsGranted, setNotificationsGranted] = useState(notificationManager.granted()); const handleNotification = (subscriptionId, notification) => { setSubscriptions(prev => { const newSubscription = prev.get(subscriptionId).addNotification(notification); + notificationManager.notify(newSubscription, notification, () => { + setSelectedSubscription(newSubscription); + }) return prev.update(newSubscription).clone(); }); }; @@ -41,6 +46,7 @@ const App = () => { return prev.update(newSubscription).clone(); }); }); + handleRequestPermission(); }; const handleDeleteNotification = (subscriptionId, notificationId) => { console.log(`[App] Deleting notification ${notificationId} from ${subscriptionId}`); @@ -64,6 +70,11 @@ const App = () => { return newSubscriptions; }); }; + const handleRequestPermission = () => { + notificationManager.maybeRequestPermission((granted) => { + setNotificationsGranted(granted); + }) + }; useEffect(() => { setSubscriptions(repository.loadSubscriptions()); setUsers(repository.loadUsers()); @@ -90,9 +101,11 @@ const App = () => { subscriptions={subscriptions} selectedSubscription={selectedSubscription} mobileDrawerOpen={mobileDrawerOpen} + notificationsGranted={notificationsGranted} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} onSubscriptionClick={(subscriptionId) => setSelectedSubscription(subscriptions.get(subscriptionId))} onSubscribeSubmit={handleSubscribeSubmit} + onRequestPermissionClick={handleRequestPermission} /> { - const navigationList = - ; + const navigationList = ; return ( <> {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} @@ -64,29 +61,51 @@ const NavList = (props) => { handleSubscribeReset(); props.onSubscribeSubmit(subscription, user); } + const showSubscriptionsList = props.subscriptions.size() > 0; + const showGrantPermissionsBox = props.subscriptions.size() > 0 && !props.notificationsGranted; return ( <> - - {props.subscriptions.size() > 0 && - } - - - + + + {showGrantPermissionsBox && + <> + + + Notifications are disabled + + Grant your browser permission to display desktop notifications. + + + + } + {showSubscriptionsList && + <> + + + } + - + - + setSubscribeDialogOpen(true)}> - + - + { ); }; -const NavSubscriptionList = (props) => { - const subscriptions = props.subscriptions; +const SubscriptionList = (props) => { return ( <> - {subscriptions.map((id, subscription) => - + props.onSubscriptionClick(id)} - />) - } + selected={props.selectedSubscription && props.selectedSubscription.id === id} + > + + + + )} ); } -const NavSubscriptionItem = (props) => { - const subscription = props.subscription; - return ( - - - - - ); -} - export default Navigation;