Desktop notifications
This commit is contained in:
		
							parent
							
								
									530f55c234
								
							
						
					
					
						commit
						aa79fe2861
					
				
					 5 changed files with 101 additions and 42 deletions
				
			
		|  | @ -288,7 +288,7 @@ const formatTitle = (m) => { | ||||||
|     if (m.title) { |     if (m.title) { | ||||||
|         return formatTitleA(m); |         return formatTitleA(m); | ||||||
|     } else { |     } else { | ||||||
|         return `${location.host}/${m.topic}`; |         return `${location.host}/${m.topic}`; // FIXME
 | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -32,13 +32,16 @@ class Connection { | ||||||
|             console.log(`[Connection, ${this.shortUrl}] Message received from server: ${event.data}`); |             console.log(`[Connection, ${this.shortUrl}] Message received from server: ${event.data}`); | ||||||
|             try { |             try { | ||||||
|                 const data = JSON.parse(event.data); |                 const data = JSON.parse(event.data); | ||||||
|  |                 if (data.event === 'open') { | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|                 const relevantAndValid = |                 const relevantAndValid = | ||||||
|                     data.event === 'message' && |                     data.event === 'message' && | ||||||
|                     'id' in data && |                     'id' in data && | ||||||
|                     'time' in data && |                     'time' in data && | ||||||
|                     'message' in data; |                     'message' in data; | ||||||
|                 if (!relevantAndValid) { |                 if (!relevantAndValid) { | ||||||
|                     console.log(`[Connection, ${this.shortUrl}] Message irrelevant or invalid. Ignoring.`); |                     console.log(`[Connection, ${this.shortUrl}] Unexpected message. Ignoring.`); | ||||||
|                     return; |                     return; | ||||||
|                 } |                 } | ||||||
|                 this.since = data.time + 1; // Sigh. This works because on reconnect, we wait 5+ seconds anyway.
 |                 this.since = data.time + 1; // Sigh. This works because on reconnect, we wait 5+ seconds anyway.
 | ||||||
|  |  | ||||||
							
								
								
									
										33
									
								
								web/src/app/NotificationManager.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								web/src/app/NotificationManager.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -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; | ||||||
|  | @ -13,6 +13,7 @@ import Subscriptions from "../app/Subscriptions"; | ||||||
| import Navigation from "./Navigation"; | import Navigation from "./Navigation"; | ||||||
| import ActionBar from "./ActionBar"; | import ActionBar from "./ActionBar"; | ||||||
| import Users from "../app/Users"; | import Users from "../app/Users"; | ||||||
|  | import notificationManager from "../app/NotificationManager"; | ||||||
| 
 | 
 | ||||||
| const App = () => { | const App = () => { | ||||||
|     console.log(`[App] Rendering main view`); |     console.log(`[App] Rendering main view`); | ||||||
|  | @ -21,9 +22,13 @@ const App = () => { | ||||||
|     const [subscriptions, setSubscriptions] = useState(new Subscriptions()); |     const [subscriptions, setSubscriptions] = useState(new Subscriptions()); | ||||||
|     const [users, setUsers] = useState(new Users()); |     const [users, setUsers] = useState(new Users()); | ||||||
|     const [selectedSubscription, setSelectedSubscription] = useState(null); |     const [selectedSubscription, setSelectedSubscription] = useState(null); | ||||||
|  |     const [notificationsGranted, setNotificationsGranted] = useState(notificationManager.granted()); | ||||||
|     const handleNotification = (subscriptionId, notification) => { |     const handleNotification = (subscriptionId, notification) => { | ||||||
|         setSubscriptions(prev => { |         setSubscriptions(prev => { | ||||||
|             const newSubscription = prev.get(subscriptionId).addNotification(notification); |             const newSubscription = prev.get(subscriptionId).addNotification(notification); | ||||||
|  |             notificationManager.notify(newSubscription, notification, () => { | ||||||
|  |                 setSelectedSubscription(newSubscription); | ||||||
|  |             }) | ||||||
|             return prev.update(newSubscription).clone(); |             return prev.update(newSubscription).clone(); | ||||||
|         }); |         }); | ||||||
|     }; |     }; | ||||||
|  | @ -41,6 +46,7 @@ const App = () => { | ||||||
|                     return prev.update(newSubscription).clone(); |                     return prev.update(newSubscription).clone(); | ||||||
|                 }); |                 }); | ||||||
|             }); |             }); | ||||||
|  |         handleRequestPermission(); | ||||||
|     }; |     }; | ||||||
|     const handleDeleteNotification = (subscriptionId, notificationId) => { |     const handleDeleteNotification = (subscriptionId, notificationId) => { | ||||||
|         console.log(`[App] Deleting notification ${notificationId} from ${subscriptionId}`); |         console.log(`[App] Deleting notification ${notificationId} from ${subscriptionId}`); | ||||||
|  | @ -64,6 +70,11 @@ const App = () => { | ||||||
|             return newSubscriptions; |             return newSubscriptions; | ||||||
|         }); |         }); | ||||||
|     }; |     }; | ||||||
|  |     const handleRequestPermission = () => { | ||||||
|  |         notificationManager.maybeRequestPermission((granted) => { | ||||||
|  |             setNotificationsGranted(granted); | ||||||
|  |         }) | ||||||
|  |     }; | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         setSubscriptions(repository.loadSubscriptions()); |         setSubscriptions(repository.loadSubscriptions()); | ||||||
|         setUsers(repository.loadUsers()); |         setUsers(repository.loadUsers()); | ||||||
|  | @ -90,9 +101,11 @@ const App = () => { | ||||||
|                         subscriptions={subscriptions} |                         subscriptions={subscriptions} | ||||||
|                         selectedSubscription={selectedSubscription} |                         selectedSubscription={selectedSubscription} | ||||||
|                         mobileDrawerOpen={mobileDrawerOpen} |                         mobileDrawerOpen={mobileDrawerOpen} | ||||||
|  |                         notificationsGranted={notificationsGranted} | ||||||
|                         onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} |                         onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} | ||||||
|                         onSubscriptionClick={(subscriptionId) => setSelectedSubscription(subscriptions.get(subscriptionId))} |                         onSubscriptionClick={(subscriptionId) => setSelectedSubscription(subscriptions.get(subscriptionId))} | ||||||
|                         onSubscribeSubmit={handleSubscribeSubmit} |                         onSubscribeSubmit={handleSubscribeSubmit} | ||||||
|  |                         onRequestPermissionClick={handleRequestPermission} | ||||||
|                     /> |                     /> | ||||||
|                 </Box> |                 </Box> | ||||||
|                 <Box |                 <Box | ||||||
|  |  | ||||||
|  | @ -1,27 +1,24 @@ | ||||||
| import Drawer from "@mui/material/Drawer"; | import Drawer from "@mui/material/Drawer"; | ||||||
| import * as React from "react"; | import * as React from "react"; | ||||||
|  | import {useState} from "react"; | ||||||
| import ListItemButton from "@mui/material/ListItemButton"; | import ListItemButton from "@mui/material/ListItemButton"; | ||||||
| import ListItemIcon from "@mui/material/ListItemIcon"; | import ListItemIcon from "@mui/material/ListItemIcon"; | ||||||
| import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; | import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; | ||||||
| import ListItemText from "@mui/material/ListItemText"; | import ListItemText from "@mui/material/ListItemText"; | ||||||
| import {useState} from "react"; |  | ||||||
| import Toolbar from "@mui/material/Toolbar"; | import Toolbar from "@mui/material/Toolbar"; | ||||||
| import Divider from "@mui/material/Divider"; | import Divider from "@mui/material/Divider"; | ||||||
| import List from "@mui/material/List"; | import List from "@mui/material/List"; | ||||||
| import SettingsIcon from "@mui/icons-material/Settings"; | import SettingsIcon from "@mui/icons-material/Settings"; | ||||||
| import AddIcon from "@mui/icons-material/Add"; | import AddIcon from "@mui/icons-material/Add"; | ||||||
| import SubscribeDialog from "./SubscribeDialog"; | 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 navWidth = 240; | ||||||
| 
 | 
 | ||||||
| const Navigation = (props) => { | const Navigation = (props) => { | ||||||
|     const navigationList = |     const navigationList = <NavList {...props}/>; | ||||||
|         <NavList |  | ||||||
|             subscriptions={props.subscriptions} |  | ||||||
|             selectedSubscription={props.selectedSubscription} |  | ||||||
|             onSubscriptionClick={props.onSubscriptionClick} |  | ||||||
|             onSubscribeSubmit={props.onSubscribeSubmit} |  | ||||||
|         />; |  | ||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|             {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} |             {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} | ||||||
|  | @ -64,17 +61,39 @@ const NavList = (props) => { | ||||||
|         handleSubscribeReset(); |         handleSubscribeReset(); | ||||||
|         props.onSubscribeSubmit(subscription, user); |         props.onSubscribeSubmit(subscription, user); | ||||||
|     } |     } | ||||||
|  |     const showSubscriptionsList = props.subscriptions.size() > 0; | ||||||
|  |     const showGrantPermissionsBox = props.subscriptions.size() > 0 && !props.notificationsGranted; | ||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|             <Toolbar/> |             <Toolbar/> | ||||||
|             {props.subscriptions.size() > 0 && |             <List component="nav" sx={{paddingTop: 0}}> | ||||||
|                 <Divider />} |                 {showGrantPermissionsBox && | ||||||
|             <List component="nav"> |                     <> | ||||||
|                 <NavSubscriptionList |                         <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} |                             subscriptions={props.subscriptions} | ||||||
|                             selectedSubscription={props.selectedSubscription} |                             selectedSubscription={props.selectedSubscription} | ||||||
|                             onSubscriptionClick={props.onSubscriptionClick} |                             onSubscriptionClick={props.onSubscriptionClick} | ||||||
|                         /> |                         /> | ||||||
|  |                     </>} | ||||||
|                 <Divider sx={{my: 1}}/> |                 <Divider sx={{my: 1}}/> | ||||||
|                 <ListItemButton> |                 <ListItemButton> | ||||||
|                     <ListItemIcon> |                     <ListItemIcon> | ||||||
|  | @ -99,30 +118,21 @@ const NavList = (props) => { | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const NavSubscriptionList = (props) => { | const SubscriptionList = (props) => { | ||||||
|     const subscriptions = props.subscriptions; |  | ||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|             {subscriptions.map((id, subscription) => |             {props.subscriptions.map((id, subscription) => | ||||||
|                 <NavSubscriptionItem |                 <ListItemButton | ||||||
|                     key={id} |                     key={id} | ||||||
|                     subscription={subscription} |  | ||||||
|                     selected={props.selectedSubscription && props.selectedSubscription.id === id} |  | ||||||
|                     onClick={() => props.onSubscriptionClick(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; | export default Navigation; | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue