diff --git a/server/static/js/app.js b/server/static/js/app.js index c8f47a5e..d19f8b2c 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 fdf5c99f..9aedf211 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 00000000..6126add4 --- /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 d7aea030..59337344 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} /> </Box> <Box diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index 2fc29d23..df56bb8d 100644 --- a/web/src/components/Navigation.js +++ b/web/src/components/Navigation.js @@ -1,27 +1,24 @@ import Drawer from "@mui/material/Drawer"; import * as React from "react"; +import {useState} from "react"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; import ListItemText from "@mui/material/ListItemText"; -import {useState} from "react"; import Toolbar from "@mui/material/Toolbar"; import Divider from "@mui/material/Divider"; 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} from "@mui/material"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; const navWidth = 240; const Navigation = (props) => { - const navigationList = - <NavList - subscriptions={props.subscriptions} - selectedSubscription={props.selectedSubscription} - onSubscriptionClick={props.onSubscriptionClick} - onSubscribeSubmit={props.onSubscribeSubmit} - />; + const navigationList = <NavList {...props}/>; 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 ( <> - <Toolbar /> - {props.subscriptions.size() > 0 && - <Divider />} - <List component="nav"> - <NavSubscriptionList - subscriptions={props.subscriptions} - selectedSubscription={props.selectedSubscription} - onSubscriptionClick={props.onSubscriptionClick} - /> - <Divider sx={{ my: 1 }} /> + <Toolbar/> + <List component="nav" sx={{paddingTop: 0}}> + {showGrantPermissionsBox && + <> + <Divider/> + <Alert severity="warning" sx={{paddingTop: 2}}> + <AlertTitle>Notifications are disabled</AlertTitle> + <Typography gutterBottom> + Grant your browser permission to display desktop notifications. + </Typography> + <Button + sx={{float: 'right'}} + color="inherit" + size="small" + onClick={props.onRequestPermissionClick} + > + Grant now + </Button> + </Alert> + </>} + {showSubscriptionsList && + <> + <Divider/> + <SubscriptionList + subscriptions={props.subscriptions} + selectedSubscription={props.selectedSubscription} + onSubscriptionClick={props.onSubscriptionClick} + /> + </>} + <Divider sx={{my: 1}}/> <ListItemButton> <ListItemIcon> - <SettingsIcon /> + <SettingsIcon/> </ListItemIcon> - <ListItemText primary="Settings" /> + <ListItemText primary="Settings"/> </ListItemButton> <ListItemButton onClick={() => setSubscribeDialogOpen(true)}> <ListItemIcon> - <AddIcon /> + <AddIcon/> </ListItemIcon> - <ListItemText primary="Add subscription" /> + <ListItemText primary="Add subscription"/> </ListItemButton> </List> <SubscribeDialog @@ -99,30 +118,21 @@ const NavList = (props) => { ); }; -const NavSubscriptionList = (props) => { - const subscriptions = props.subscriptions; +const SubscriptionList = (props) => { return ( <> - {subscriptions.map((id, subscription) => - <NavSubscriptionItem + {props.subscriptions.map((id, subscription) => + <ListItemButton key={id} - subscription={subscription} - selected={props.selectedSubscription && props.selectedSubscription.id === id} onClick={() => props.onSubscriptionClick(id)} - />) - } + selected={props.selectedSubscription && props.selectedSubscription.id === id} + > + <ListItemIcon><ChatBubbleOutlineIcon /></ListItemIcon> + <ListItemText primary={subscription.shortUrl()}/> + </ListItemButton> + )} </> ); } -const NavSubscriptionItem = (props) => { - const subscription = props.subscription; - return ( - <ListItemButton onClick={props.onClick} selected={props.selected}> - <ListItemIcon><ChatBubbleOutlineIcon /></ListItemIcon> - <ListItemText primary={subscription.shortUrl()}/> - </ListItemButton> - ); -} - export default Navigation;