Add PWA, service worker and Web Push
- Use new notification request/opt-in flow for push - Implement unsubscribing - Implement muting - Implement emojis in title - Add iOS specific PWA warning - Don’t use websockets when web push is enabled - Fix duplicate notifications - Implement default web push setting - Implement changing subscription type - Implement web push subscription refresh - Implement web push notification click
This commit is contained in:
		
							parent
							
								
									733ef4664b
								
							
						
					
					
						commit
						ff5c854192
					
				
					 53 changed files with 4363 additions and 249 deletions
				
			
		|  | @ -48,7 +48,7 @@ import routes from "./routes"; | |||
| import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils"; | ||||
| import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi"; | ||||
| import { Pref, PrefGroup } from "./Pref"; | ||||
| import db from "../app/db"; | ||||
| import getDb from "../app/getDb"; | ||||
| import UpgradeDialog from "./UpgradeDialog"; | ||||
| import { AccountContext } from "./App"; | ||||
| import DialogFooter from "./DialogFooter"; | ||||
|  | @ -57,6 +57,7 @@ import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; | |||
| import { ProChip } from "./SubscriptionPopup"; | ||||
| import theme from "./theme"; | ||||
| import session from "../app/Session"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| 
 | ||||
| const Account = () => { | ||||
|   if (!session.exists()) { | ||||
|  | @ -1077,8 +1078,10 @@ const DeleteAccountDialog = (props) => { | |||
| 
 | ||||
|   const handleSubmit = async () => { | ||||
|     try { | ||||
|       await subscriptionManager.unsubscribeAllWebPush(); | ||||
| 
 | ||||
|       await accountApi.delete(password); | ||||
|       await db.delete(); | ||||
|       await getDb().delete(); | ||||
|       console.debug(`[Account] Account deleted`); | ||||
|       session.resetAndRedirect(routes.app); | ||||
|     } catch (e) { | ||||
|  |  | |||
|  | @ -13,7 +13,7 @@ import session from "../app/Session"; | |||
| import logo from "../img/ntfy.svg"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import routes from "./routes"; | ||||
| import db from "../app/db"; | ||||
| import getDb from "../app/getDb"; | ||||
| import { topicDisplayName } from "../app/utils"; | ||||
| import Navigation from "./Navigation"; | ||||
| import accountApi from "../app/AccountApi"; | ||||
|  | @ -120,8 +120,10 @@ const ProfileIcon = () => { | |||
| 
 | ||||
|   const handleLogout = async () => { | ||||
|     try { | ||||
|       await subscriptionManager.unsubscribeAllWebPush(); | ||||
| 
 | ||||
|       await accountApi.logout(); | ||||
|       await db.delete(); | ||||
|       await getDb().delete(); | ||||
|     } finally { | ||||
|       session.resetAndRedirect(routes.app); | ||||
|     } | ||||
|  |  | |||
|  | @ -57,6 +57,10 @@ const App = () => { | |||
| 
 | ||||
| const updateTitle = (newNotificationsCount) => { | ||||
|   document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; | ||||
| 
 | ||||
|   if ("setAppBadge" in window.navigator) { | ||||
|     window.navigator.setAppBadge(newNotificationsCount); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const Layout = () => { | ||||
|  |  | |||
|  | @ -14,7 +14,6 @@ import { | |||
|   ListSubheader, | ||||
|   Portal, | ||||
|   Tooltip, | ||||
|   Button, | ||||
|   Typography, | ||||
|   Box, | ||||
|   IconButton, | ||||
|  | @ -94,15 +93,10 @@ const NavList = (props) => { | |||
|     setSubscribeDialogKey((prev) => prev + 1); | ||||
|   }; | ||||
| 
 | ||||
|   const handleRequestNotificationPermission = () => { | ||||
|     notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted)); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSubscribeSubmit = (subscription) => { | ||||
|     console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); | ||||
|     handleSubscribeReset(); | ||||
|     navigate(routes.forSubscription(subscription)); | ||||
|     handleRequestNotificationPermission(); | ||||
|   }; | ||||
| 
 | ||||
|   const handleAccountClick = () => { | ||||
|  | @ -114,19 +108,27 @@ const NavList = (props) => { | |||
|   const isPaid = account?.billing?.subscription; | ||||
|   const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; | ||||
|   const showSubscriptionsList = props.subscriptions?.length > 0; | ||||
|   const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); | ||||
|   const showNotificationPermissionDenied = notifier.denied(); | ||||
|   const showNotificationIOSInstallRequired = notifier.iosSupportedButInstallRequired(); | ||||
|   const showNotificationBrowserNotSupportedBox = !showNotificationIOSInstallRequired && !notifier.browserSupported(); | ||||
|   const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser | ||||
|   const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; | ||||
| 
 | ||||
|   const navListPadding = | ||||
|     showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : ""; | ||||
|     showNotificationPermissionDenied || | ||||
|     showNotificationIOSInstallRequired || | ||||
|     showNotificationBrowserNotSupportedBox || | ||||
|     showNotificationContextNotSupportedBox | ||||
|       ? "0" | ||||
|       : ""; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Toolbar sx={{ display: { xs: "none", sm: "block" } }} /> | ||||
|       <List component="nav" sx={{ paddingTop: navListPadding }}> | ||||
|         {showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />} | ||||
|         {showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />} | ||||
|         {showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert />} | ||||
|         {showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission} />} | ||||
|         {showNotificationIOSInstallRequired && <NotificationIOSInstallRequiredAlert />} | ||||
|         {!showSubscriptionsList && ( | ||||
|           <ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}> | ||||
|             <ListItemIcon> | ||||
|  | @ -344,16 +346,26 @@ const SubscriptionItem = (props) => { | |||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const NotificationGrantAlert = (props) => { | ||||
| const NotificationPermissionDeniedAlert = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   return ( | ||||
|     <> | ||||
|       <Alert severity="warning" sx={{ paddingTop: 2 }}> | ||||
|         <AlertTitle>{t("alert_grant_title")}</AlertTitle> | ||||
|         <Typography gutterBottom>{t("alert_grant_description")}</Typography> | ||||
|         <Button sx={{ float: "right" }} color="inherit" size="small" onClick={props.onRequestPermissionClick}> | ||||
|           {t("alert_grant_button")} | ||||
|         </Button> | ||||
|         <AlertTitle>{t("alert_notification_permission_denied_title")}</AlertTitle> | ||||
|         <Typography gutterBottom>{t("alert_notification_permission_denied_description")}</Typography> | ||||
|       </Alert> | ||||
|       <Divider /> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const NotificationIOSInstallRequiredAlert = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   return ( | ||||
|     <> | ||||
|       <Alert severity="warning" sx={{ paddingTop: 2 }}> | ||||
|         <AlertTitle>{t("alert_notification_ios_install_required_title")}</AlertTitle> | ||||
|         <Typography gutterBottom>{t("alert_notification_ios_install_required_description")}</Typography> | ||||
|       </Alert> | ||||
|       <Divider /> | ||||
|     </> | ||||
|  |  | |||
|  | @ -48,6 +48,7 @@ import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite | |||
| import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; | ||||
| import { UnauthorizedError } from "../app/errors"; | ||||
| import { subscribeTopic } from "./SubscribeDialog"; | ||||
| import notifier from "../app/Notifier"; | ||||
| 
 | ||||
| const maybeUpdateAccountSettings = async (payload) => { | ||||
|   if (!session.exists()) { | ||||
|  | @ -85,6 +86,7 @@ const Notifications = () => { | |||
|         <Sound /> | ||||
|         <MinPriority /> | ||||
|         <DeleteAfter /> | ||||
|         {notifier.pushSupported() && <WebPushDefaultEnabled />} | ||||
|       </PrefGroup> | ||||
|     </Card> | ||||
|   ); | ||||
|  | @ -232,6 +234,36 @@ const DeleteAfter = () => { | |||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const WebPushDefaultEnabled = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const labelId = "prefWebPushDefaultEnabled"; | ||||
|   const defaultEnabled = useLiveQuery(async () => prefs.webPushDefaultEnabled()); | ||||
|   const handleChange = async (ev) => { | ||||
|     await prefs.setWebPushDefaultEnabled(ev.target.value); | ||||
|   }; | ||||
| 
 | ||||
|   // while loading | ||||
|   if (defaultEnabled == null) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Pref | ||||
|       labelId={labelId} | ||||
|       title={t("prefs_notifications_web_push_default_title")} | ||||
|       description={t("prefs_notifications_web_push_default_description")} | ||||
|     > | ||||
|       <FormControl fullWidth variant="standard" sx={{ m: 1 }}> | ||||
|         <Select value={defaultEnabled} onChange={handleChange} aria-labelledby={labelId}> | ||||
|           {defaultEnabled === "initial" && <MenuItem value="initial">{t("prefs_notifications_web_push_default_initial")}</MenuItem>} | ||||
|           <MenuItem value="enabled">{t("prefs_notifications_web_push_default_enabled")}</MenuItem> | ||||
|           <MenuItem value="disabled">{t("prefs_notifications_web_push_default_disabled")}</MenuItem> | ||||
|         </Select> | ||||
|       </FormControl> | ||||
|     </Pref> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const Users = () => { | ||||
|   const { t } = useTranslation(); | ||||
|   const [dialogKey, setDialogKey] = useState(0); | ||||
|  |  | |||
|  | @ -8,17 +8,20 @@ import { | |||
|   DialogContentText, | ||||
|   DialogTitle, | ||||
|   Autocomplete, | ||||
|   Checkbox, | ||||
|   FormControlLabel, | ||||
|   FormGroup, | ||||
|   useMediaQuery, | ||||
|   Switch, | ||||
|   Stack, | ||||
| } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import { Warning } from "@mui/icons-material"; | ||||
| import { useLiveQuery } from "dexie-react-hooks"; | ||||
| import theme from "./theme"; | ||||
| import api from "../app/Api"; | ||||
| import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils"; | ||||
| import userManager from "../app/UserManager"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import subscriptionManager, { NotificationType } from "../app/SubscriptionManager"; | ||||
| import poller from "../app/Poller"; | ||||
| import DialogFooter from "./DialogFooter"; | ||||
| import session from "../app/Session"; | ||||
|  | @ -28,11 +31,13 @@ import ReserveTopicSelect from "./ReserveTopicSelect"; | |||
| import { AccountContext } from "./App"; | ||||
| import { TopicReservedError, UnauthorizedError } from "../app/errors"; | ||||
| import { ReserveLimitChip } from "./SubscriptionPopup"; | ||||
| import notifier from "../app/Notifier"; | ||||
| import prefs from "../app/Prefs"; | ||||
| 
 | ||||
| const publicBaseUrl = "https://ntfy.sh"; | ||||
| 
 | ||||
| export const subscribeTopic = async (baseUrl, topic) => { | ||||
|   const subscription = await subscriptionManager.add(baseUrl, topic); | ||||
| export const subscribeTopic = async (baseUrl, topic, opts) => { | ||||
|   const subscription = await subscriptionManager.add(baseUrl, topic, opts); | ||||
|   if (session.exists()) { | ||||
|     try { | ||||
|       await accountApi.addSubscription(baseUrl, topic); | ||||
|  | @ -52,14 +57,29 @@ const SubscribeDialog = (props) => { | |||
|   const [showLoginPage, setShowLoginPage] = useState(false); | ||||
|   const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); | ||||
| 
 | ||||
|   const handleSuccess = async () => { | ||||
|   const webPushDefaultEnabled = useLiveQuery(async () => prefs.webPushDefaultEnabled()); | ||||
| 
 | ||||
|   const handleSuccess = async (notificationType) => { | ||||
|     console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); | ||||
|     const actualBaseUrl = baseUrl || config.base_url; | ||||
|     const subscription = await subscribeTopic(actualBaseUrl, topic); | ||||
|     const subscription = await subscribeTopic(actualBaseUrl, topic, { | ||||
|       notificationType, | ||||
|     }); | ||||
|     poller.pollInBackground(subscription); // Dangle! | ||||
| 
 | ||||
|     // if the user hasn't changed the default web push setting yet, set it to enabled | ||||
|     if (notificationType === "background" && webPushDefaultEnabled === "initial") { | ||||
|       await prefs.setWebPushDefaultEnabled(true); | ||||
|     } | ||||
| 
 | ||||
|     props.onSuccess(subscription); | ||||
|   }; | ||||
| 
 | ||||
|   // wait for liveQuery load | ||||
|   if (webPushDefaultEnabled === undefined) { | ||||
|     return <></>; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}> | ||||
|       {!showLoginPage && ( | ||||
|  | @ -72,6 +92,7 @@ const SubscribeDialog = (props) => { | |||
|           onCancel={props.onCancel} | ||||
|           onNeedsLogin={() => setShowLoginPage(true)} | ||||
|           onSuccess={handleSuccess} | ||||
|           webPushDefaultEnabled={webPushDefaultEnabled} | ||||
|         /> | ||||
|       )} | ||||
|       {showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} onSuccess={handleSuccess} />} | ||||
|  | @ -79,6 +100,22 @@ const SubscribeDialog = (props) => { | |||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const browserNotificationsSupported = notifier.supported(); | ||||
| const pushNotificationsSupported = notifier.pushSupported(); | ||||
| const iosInstallRequired = notifier.iosSupportedButInstallRequired(); | ||||
| 
 | ||||
| const getNotificationTypeFromToggles = (browserNotificationsEnabled, backgroundNotificationsEnabled) => { | ||||
|   if (backgroundNotificationsEnabled) { | ||||
|     return NotificationType.BACKGROUND; | ||||
|   } | ||||
| 
 | ||||
|   if (browserNotificationsEnabled) { | ||||
|     return NotificationType.BROWSER; | ||||
|   } | ||||
| 
 | ||||
|   return NotificationType.SOUND; | ||||
| }; | ||||
| 
 | ||||
| const SubscribePage = (props) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const { account } = useContext(AccountContext); | ||||
|  | @ -96,6 +133,30 @@ const SubscribePage = (props) => { | |||
|   const reserveTopicEnabled = | ||||
|     session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0)); | ||||
| 
 | ||||
|   // load initial value, but update it in `handleBrowserNotificationsChanged` | ||||
|   // if we interact with the API and therefore possibly change it (from default -> denied) | ||||
|   const [notificationsExplicitlyDenied, setNotificationsExplicitlyDenied] = useState(notifier.denied()); | ||||
|   // default to on if notifications are already granted | ||||
|   const [browserNotificationsEnabled, setBrowserNotificationsEnabled] = useState(notifier.granted()); | ||||
|   const [backgroundNotificationsEnabled, setBackgroundNotificationsEnabled] = useState(props.webPushDefaultEnabled === "enabled"); | ||||
| 
 | ||||
|   const handleBrowserNotificationsChanged = async (e) => { | ||||
|     if (e.target.checked && (await notifier.maybeRequestPermission())) { | ||||
|       setBrowserNotificationsEnabled(true); | ||||
|       if (props.webPushDefaultEnabled === "enabled") { | ||||
|         setBackgroundNotificationsEnabled(true); | ||||
|       } | ||||
|     } else { | ||||
|       setNotificationsExplicitlyDenied(notifier.denied()); | ||||
|       setBrowserNotificationsEnabled(false); | ||||
|       setBackgroundNotificationsEnabled(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleBackgroundNotificationsChanged = (e) => { | ||||
|     setBackgroundNotificationsEnabled(e.target.checked); | ||||
|   }; | ||||
| 
 | ||||
|   const handleSubscribe = async () => { | ||||
|     const user = await userManager.get(baseUrl); // May be undefined | ||||
|     const username = user ? user.username : t("subscribe_dialog_error_user_anonymous"); | ||||
|  | @ -133,12 +194,15 @@ const SubscribePage = (props) => { | |||
|     } | ||||
| 
 | ||||
|     console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); | ||||
|     props.onSuccess(); | ||||
|     props.onSuccess(getNotificationTypeFromToggles(browserNotificationsEnabled, backgroundNotificationsEnabled)); | ||||
|   }; | ||||
| 
 | ||||
|   const handleUseAnotherChanged = (e) => { | ||||
|     props.setBaseUrl(""); | ||||
|     setAnotherServerVisible(e.target.checked); | ||||
|     if (e.target.checked) { | ||||
|       setBackgroundNotificationsEnabled(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const subscribeButtonEnabled = (() => { | ||||
|  | @ -193,8 +257,7 @@ const SubscribePage = (props) => { | |||
|             <FormControlLabel | ||||
|               variant="standard" | ||||
|               control={ | ||||
|                 <Checkbox | ||||
|                   fullWidth | ||||
|                 <Switch | ||||
|                   disabled={!reserveTopicEnabled} | ||||
|                   checked={reserveTopicVisible} | ||||
|                   onChange={(ev) => setReserveTopicVisible(ev.target.checked)} | ||||
|  | @ -217,8 +280,9 @@ const SubscribePage = (props) => { | |||
|           <FormGroup> | ||||
|             <FormControlLabel | ||||
|               control={ | ||||
|                 <Checkbox | ||||
|                 <Switch | ||||
|                   onChange={handleUseAnotherChanged} | ||||
|                   checked={anotherServerVisible} | ||||
|                   inputProps={{ | ||||
|                     "aria-label": t("subscribe_dialog_subscribe_use_another_label"), | ||||
|                   }} | ||||
|  | @ -244,6 +308,43 @@ const SubscribePage = (props) => { | |||
|             )} | ||||
|           </FormGroup> | ||||
|         )} | ||||
|         {browserNotificationsSupported && ( | ||||
|           <FormGroup> | ||||
|             <FormControlLabel | ||||
|               control={ | ||||
|                 <Switch | ||||
|                   onChange={handleBrowserNotificationsChanged} | ||||
|                   checked={browserNotificationsEnabled} | ||||
|                   disabled={notificationsExplicitlyDenied} | ||||
|                   inputProps={{ | ||||
|                     "aria-label": t("subscribe_dialog_subscribe_enable_browser_notifications_label"), | ||||
|                   }} | ||||
|                 /> | ||||
|               } | ||||
|               label={ | ||||
|                 <Stack direction="row" gap={1} alignItems="center"> | ||||
|                   {t("subscribe_dialog_subscribe_enable_browser_notifications_label")} | ||||
|                   {notificationsExplicitlyDenied && <Warning />} | ||||
|                 </Stack> | ||||
|               } | ||||
|             /> | ||||
|             {pushNotificationsSupported && !anotherServerVisible && browserNotificationsEnabled && ( | ||||
|               <FormControlLabel | ||||
|                 control={ | ||||
|                   <Switch | ||||
|                     onChange={handleBackgroundNotificationsChanged} | ||||
|                     checked={backgroundNotificationsEnabled} | ||||
|                     disabled={iosInstallRequired} | ||||
|                     inputProps={{ | ||||
|                       "aria-label": t("subscribe_dialog_subscribe_enable_background_notifications_label"), | ||||
|                     }} | ||||
|                   /> | ||||
|                 } | ||||
|                 label={t("subscribe_dialog_subscribe_enable_background_notifications_label")} | ||||
|               /> | ||||
|             )} | ||||
|           </FormGroup> | ||||
|         )} | ||||
|       </DialogContent> | ||||
|       <DialogFooter status={error}> | ||||
|         <Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button> | ||||
|  |  | |||
|  | @ -14,12 +14,26 @@ import { | |||
|   useMediaQuery, | ||||
|   MenuItem, | ||||
|   IconButton, | ||||
|   ListItemIcon, | ||||
|   ListItemText, | ||||
|   Divider, | ||||
| } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { Clear } from "@mui/icons-material"; | ||||
| import { | ||||
|   Check, | ||||
|   Clear, | ||||
|   ClearAll, | ||||
|   Edit, | ||||
|   EnhancedEncryption, | ||||
|   Lock, | ||||
|   LockOpen, | ||||
|   NotificationsOff, | ||||
|   RemoveCircle, | ||||
|   Send, | ||||
| } from "@mui/icons-material"; | ||||
| import theme from "./theme"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import subscriptionManager, { NotificationType } from "../app/SubscriptionManager"; | ||||
| import DialogFooter from "./DialogFooter"; | ||||
| import accountApi, { Role } from "../app/AccountApi"; | ||||
| import session from "../app/Session"; | ||||
|  | @ -30,6 +44,7 @@ import api from "../app/Api"; | |||
| import { AccountContext } from "./App"; | ||||
| import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; | ||||
| import { UnauthorizedError } from "../app/errors"; | ||||
| import notifier from "../app/Notifier"; | ||||
| 
 | ||||
| export const SubscriptionPopup = (props) => { | ||||
|   const { t } = useTranslation(); | ||||
|  | @ -70,8 +85,7 @@ export const SubscriptionPopup = (props) => { | |||
|   }; | ||||
| 
 | ||||
|   const handleSendTestMessage = async () => { | ||||
|     const { baseUrl } = props.subscription; | ||||
|     const { topic } = props.subscription; | ||||
|     const { baseUrl, topic } = props.subscription; | ||||
|     const tags = shuffle([ | ||||
|       "grinning", | ||||
|       "octopus", | ||||
|  | @ -133,7 +147,7 @@ export const SubscriptionPopup = (props) => { | |||
| 
 | ||||
|   const handleUnsubscribe = async () => { | ||||
|     console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); | ||||
|     await subscriptionManager.remove(props.subscription.id); | ||||
|     await subscriptionManager.remove(props.subscription); | ||||
|     if (session.exists() && !subscription.internal) { | ||||
|       try { | ||||
|         await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic); | ||||
|  | @ -155,19 +169,72 @@ export const SubscriptionPopup = (props) => { | |||
|   return ( | ||||
|     <> | ||||
|       <PopupMenu horizontal={placement} anchorEl={props.anchor} open={!!props.anchor} onClose={props.onClose}> | ||||
|         <MenuItem onClick={handleChangeDisplayName}>{t("action_bar_change_display_name")}</MenuItem> | ||||
|         {showReservationAdd && <MenuItem onClick={handleReserveAdd}>{t("action_bar_reservation_add")}</MenuItem>} | ||||
|         <NotificationToggle subscription={subscription} /> | ||||
|         <Divider /> | ||||
|         <MenuItem onClick={handleChangeDisplayName}> | ||||
|           <ListItemIcon> | ||||
|             <Edit fontSize="small" /> | ||||
|           </ListItemIcon> | ||||
| 
 | ||||
|           {t("action_bar_change_display_name")} | ||||
|         </MenuItem> | ||||
|         {showReservationAdd && ( | ||||
|           <MenuItem onClick={handleReserveAdd}> | ||||
|             <ListItemIcon> | ||||
|               <Lock fontSize="small" /> | ||||
|             </ListItemIcon> | ||||
|             {t("action_bar_reservation_add")} | ||||
|           </MenuItem> | ||||
|         )} | ||||
|         {showReservationAddDisabled && ( | ||||
|           <MenuItem sx={{ cursor: "default" }}> | ||||
|             <ListItemIcon> | ||||
|               <Lock fontSize="small" color="disabled" /> | ||||
|             </ListItemIcon> | ||||
| 
 | ||||
|             <span style={{ opacity: 0.3 }}>{t("action_bar_reservation_add")}</span> | ||||
|             <ReserveLimitChip /> | ||||
|           </MenuItem> | ||||
|         )} | ||||
|         {showReservationEdit && <MenuItem onClick={handleReserveEdit}>{t("action_bar_reservation_edit")}</MenuItem>} | ||||
|         {showReservationDelete && <MenuItem onClick={handleReserveDelete}>{t("action_bar_reservation_delete")}</MenuItem>} | ||||
|         <MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem> | ||||
|         <MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem> | ||||
|         <MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem> | ||||
|         {showReservationEdit && ( | ||||
|           <MenuItem onClick={handleReserveEdit}> | ||||
|             <ListItemIcon> | ||||
|               <EnhancedEncryption fontSize="small" /> | ||||
|             </ListItemIcon> | ||||
| 
 | ||||
|             {t("action_bar_reservation_edit")} | ||||
|           </MenuItem> | ||||
|         )} | ||||
|         {showReservationDelete && ( | ||||
|           <MenuItem onClick={handleReserveDelete}> | ||||
|             <ListItemIcon> | ||||
|               <LockOpen fontSize="small" /> | ||||
|             </ListItemIcon> | ||||
| 
 | ||||
|             {t("action_bar_reservation_delete")} | ||||
|           </MenuItem> | ||||
|         )} | ||||
|         <MenuItem onClick={handleSendTestMessage}> | ||||
|           <ListItemIcon> | ||||
|             <Send fontSize="small" /> | ||||
|           </ListItemIcon> | ||||
| 
 | ||||
|           {t("action_bar_send_test_notification")} | ||||
|         </MenuItem> | ||||
|         <MenuItem onClick={handleClearAll}> | ||||
|           <ListItemIcon> | ||||
|             <ClearAll fontSize="small" /> | ||||
|           </ListItemIcon> | ||||
| 
 | ||||
|           {t("action_bar_clear_notifications")} | ||||
|         </MenuItem> | ||||
|         <MenuItem onClick={handleUnsubscribe}> | ||||
|           <ListItemIcon> | ||||
|             <RemoveCircle fontSize="small" /> | ||||
|           </ListItemIcon> | ||||
| 
 | ||||
|           {t("action_bar_unsubscribe")} | ||||
|         </MenuItem> | ||||
|       </PopupMenu> | ||||
|       <Portal> | ||||
|         <Snackbar | ||||
|  | @ -267,6 +334,83 @@ const DisplayNameDialog = (props) => { | |||
|   ); | ||||
| }; | ||||
| 
 | ||||
| const getNotificationType = (subscription) => { | ||||
|   if (subscription.mutedUntil === 1) { | ||||
|     return "muted"; | ||||
|   } | ||||
| 
 | ||||
|   return subscription.notificationType ?? NotificationType.BROWSER; | ||||
| }; | ||||
| 
 | ||||
| const checkedItem = ( | ||||
|   <ListItemIcon> | ||||
|     <Check /> | ||||
|   </ListItemIcon> | ||||
| ); | ||||
| 
 | ||||
| const NotificationToggle = ({ subscription }) => { | ||||
|   const { t } = useTranslation(); | ||||
|   const type = getNotificationType(subscription); | ||||
| 
 | ||||
|   const handleChange = async (newType) => { | ||||
|     try { | ||||
|       if (newType !== NotificationType.SOUND && !(await notifier.maybeRequestPermission())) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       await subscriptionManager.setNotificationType(subscription, newType); | ||||
|     } catch (e) { | ||||
|       console.error("[NotificationToggle] Error setting notification type", e); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const unmute = async () => { | ||||
|     await subscriptionManager.setMutedUntil(subscription.id, 0); | ||||
|   }; | ||||
| 
 | ||||
|   if (type === "muted") { | ||||
|     return ( | ||||
|       <MenuItem onClick={unmute}> | ||||
|         <ListItemIcon> | ||||
|           <NotificationsOff /> | ||||
|         </ListItemIcon> | ||||
|         {t("notification_toggle_unmute")} | ||||
|       </MenuItem> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <MenuItem> | ||||
|         {type === NotificationType.SOUND && checkedItem} | ||||
|         <ListItemText inset={type !== NotificationType.SOUND} onClick={() => handleChange(NotificationType.SOUND)}> | ||||
|           {t("notification_toggle_sound")} | ||||
|         </ListItemText> | ||||
|       </MenuItem> | ||||
|       {!notifier.denied() && !notifier.iosSupportedButInstallRequired() && ( | ||||
|         <> | ||||
|           {notifier.supported() && ( | ||||
|             <MenuItem> | ||||
|               {type === NotificationType.BROWSER && checkedItem} | ||||
|               <ListItemText inset={type !== NotificationType.BROWSER} onClick={() => handleChange(NotificationType.BROWSER)}> | ||||
|                 {t("notification_toggle_browser")} | ||||
|               </ListItemText> | ||||
|             </MenuItem> | ||||
|           )} | ||||
|           {notifier.pushSupported() && ( | ||||
|             <MenuItem> | ||||
|               {type === NotificationType.BACKGROUND && checkedItem} | ||||
|               <ListItemText inset={type !== NotificationType.BACKGROUND} onClick={() => handleChange(NotificationType.BACKGROUND)}> | ||||
|                 {t("notification_toggle_background")} | ||||
|               </ListItemText> | ||||
|             </MenuItem> | ||||
|           )} | ||||
|         </> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const ReserveLimitChip = () => { | ||||
|   const { account } = useContext(AccountContext); | ||||
|   if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) { | ||||
|  |  | |||
|  | @ -2,7 +2,6 @@ import { useNavigate, useParams } from "react-router-dom"; | |||
| import { useEffect, useState } from "react"; | ||||
| import subscriptionManager from "../app/SubscriptionManager"; | ||||
| import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils"; | ||||
| import notifier from "../app/Notifier"; | ||||
| import routes from "./routes"; | ||||
| import connectionManager from "../app/ConnectionManager"; | ||||
| import poller from "../app/Poller"; | ||||
|  | @ -10,6 +9,7 @@ import pruner from "../app/Pruner"; | |||
| import session from "../app/Session"; | ||||
| import accountApi from "../app/AccountApi"; | ||||
| import { UnauthorizedError } from "../app/errors"; | ||||
| import webPushWorker from "../app/WebPushWorker"; | ||||
| 
 | ||||
| /** | ||||
|  * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection | ||||
|  | @ -41,7 +41,7 @@ export const useConnectionListeners = (account, subscriptions, users) => { | |||
|         const added = await subscriptionManager.addNotification(subscriptionId, notification); | ||||
|         if (added) { | ||||
|           const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); | ||||
|           await notifier.notify(subscriptionId, notification, defaultClickAction); | ||||
|           await subscriptionManager.notify(subscriptionId, notification, defaultClickAction); | ||||
|         } | ||||
|       }; | ||||
| 
 | ||||
|  | @ -61,7 +61,7 @@ export const useConnectionListeners = (account, subscriptions, users) => { | |||
|         } | ||||
|       }; | ||||
| 
 | ||||
|       connectionManager.registerStateListener(subscriptionManager.updateState); | ||||
|       connectionManager.registerStateListener((id, state) => subscriptionManager.updateState(id, state)); | ||||
|       connectionManager.registerMessageListener(handleMessage); | ||||
| 
 | ||||
|       return () => { | ||||
|  | @ -79,7 +79,7 @@ export const useConnectionListeners = (account, subscriptions, users) => { | |||
|     if (!account || !account.sync_topic) { | ||||
|       return; | ||||
|     } | ||||
|     subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle!
 | ||||
|     subscriptionManager.add(config.base_url, account.sync_topic, { internal: true }); // Dangle!
 | ||||
|   }, [account]); | ||||
| 
 | ||||
|   // When subscriptions or users change, refresh the connections
 | ||||
|  | @ -129,11 +129,30 @@ export const useAutoSubscribe = (subscriptions, selected) => { | |||
|  * and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans | ||||
|  * up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
 | ||||
|  */ | ||||
| 
 | ||||
| const stopWorkers = () => { | ||||
|   poller.stopWorker(); | ||||
|   pruner.stopWorker(); | ||||
|   accountApi.stopWorker(); | ||||
| }; | ||||
| 
 | ||||
| const startWorkers = () => { | ||||
|   poller.startWorker(); | ||||
|   pruner.startWorker(); | ||||
|   accountApi.startWorker(); | ||||
| }; | ||||
| 
 | ||||
| export const useBackgroundProcesses = () => { | ||||
|   useEffect(() => { | ||||
|     poller.startWorker(); | ||||
|     pruner.startWorker(); | ||||
|     accountApi.startWorker(); | ||||
|     console.log("[useBackgroundProcesses] mounting"); | ||||
|     startWorkers(); | ||||
|     webPushWorker.startWorker(); | ||||
| 
 | ||||
|     return () => { | ||||
|       console.log("[useBackgroundProcesses] unloading"); | ||||
|       stopWorkers(); | ||||
|       webPushWorker.stopWorker(); | ||||
|     }; | ||||
|   }, []); | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue