Desktop notifications

pull/149/head
Philipp Heckel 2022-02-26 10:14:43 -05:00
parent 530f55c234
commit aa79fe2861
5 changed files with 101 additions and 42 deletions

View File

@ -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
} }
}; };

View File

@ -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.

View 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;

View File

@ -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

View File

@ -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,29 +61,51 @@ 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>
<SettingsIcon /> <SettingsIcon/>
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Settings" /> <ListItemText primary="Settings"/>
</ListItemButton> </ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}> <ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon> <ListItemIcon>
<AddIcon /> <AddIcon/>
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Add subscription" /> <ListItemText primary="Add subscription"/>
</ListItemButton> </ListItemButton>
</List> </List>
<SubscribeDialog <SubscribeDialog
@ -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;