From 9366c67439205bfeb1cefa37a148a8a9600a865f Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 5 Apr 2023 14:19:32 -0400 Subject: [PATCH] feat: added the PWA badge indicator for requests pending refactor: removed unnecessary code when sending web push notification fix: moved all notify user logic into webpush refactor: n refactor: remove all unnecessary prettier changes fix: n fix: n fix: n fix: n fix: increment sw version fix: n --- public/sw.js | 21 +++- server/lib/notifications/agents/agent.ts | 2 + server/lib/notifications/agents/webpush.ts | 133 +++++++++++++++------ src/components/Layout/MobileMenu/index.tsx | 6 +- src/pages/_app.tsx | 30 +++++ 5 files changed, 153 insertions(+), 39 deletions(-) diff --git a/public/sw.js b/public/sw.js index 6a89315ac..3aec6343f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,7 +3,7 @@ // previously cached resources to be updated from the network. // This variable is intentionally declared and unused. // eslint-disable-next-line @typescript-eslint/no-unused-vars -const OFFLINE_VERSION = 3; +const OFFLINE_VERSION = 4; const CACHE_NAME = 'offline'; // Customize this with a different URL if needed. const OFFLINE_URL = '/offline.html'; @@ -107,6 +107,25 @@ self.addEventListener('push', (event) => { ); } + // Set the badge with the amount of pending requests + // Only update the badge if the payload confirms they are the admin + if ( + (payload.notificationType === 'MEDIA_APPROVED' || + payload.notificationType === 'MEDIA_DECLINED') && + payload.isAdmin + ) { + if ('setAppBadge' in navigator) { + navigator.setAppBadge(payload.pendingRequestsCount); + } + return; + } + + if (payload.notificationType === 'MEDIA_PENDING') { + if ('setAppBadge' in navigator) { + navigator.setAppBadge(payload.pendingRequestsCount); + } + } + event.waitUntil(self.registration.showNotification(payload.subject, options)); }); diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index d2b0b1656..952e1acf0 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -19,6 +19,8 @@ export interface NotificationPayload { request?: MediaRequest; issue?: Issue; comment?: IssueComment; + pendingRequestsCount?: number; + isAdmin?: boolean; } export abstract class BaseAgent { diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index 275a77e8e..143961ec4 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -1,6 +1,7 @@ import { IssueType, IssueTypeName } from '@server/constants/issue'; -import { MediaType } from '@server/constants/media'; +import { MediaRequestStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; +import MediaRequest from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; import type { NotificationAgentConfig } from '@server/lib/settings'; @@ -19,6 +20,8 @@ interface PushNotificationPayload { actionUrl?: string; actionUrlTitle?: string; requestId?: number; + pendingRequestsCount?: number; + isAdmin?: boolean; } class WebPushAgent @@ -129,6 +132,8 @@ class WebPushAgent requestId: payload.request?.id, actionUrl, actionUrlTitle, + pendingRequestsCount: payload.pendingRequestsCount, + isAdmin: payload.isAdmin, }; } @@ -152,6 +157,51 @@ class WebPushAgent const mainUser = await userRepository.findOne({ where: { id: 1 } }); + const requestRepository = getRepository(MediaRequest); + + const pendingRequests = await requestRepository.find({ + where: { status: MediaRequestStatus.PENDING }, + }); + + const webPushNotification = async ( + pushSub: UserPushSubscription, + notificationPayload: Buffer + ) => { + logger.debug('Sending web push notification', { + label: 'Notifications', + recipient: pushSub.user.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await webpush.sendNotification( + { + endpoint: pushSub.endpoint, + keys: { + auth: pushSub.auth, + p256dh: pushSub.p256dh, + }, + }, + notificationPayload + ); + } catch (e) { + logger.error( + 'Error sending web push notification; removing subscription', + { + label: 'Notifications', + recipient: pushSub.user.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + } + ); + + // Failed to send notification so we need to remove the subscription + userPushSubRepository.remove(pushSub); + } + }; + if ( payload.notifyUser && // Check if user has webpush notifications enabled and fallback to true if undefined @@ -169,7 +219,11 @@ class WebPushAgent pushSubs.push(...notifySubs); } - if (payload.notifyAdmin) { + if ( + payload.notifyAdmin || + type === Notification.MEDIA_APPROVED || + type === Notification.MEDIA_DECLINED + ) { const users = await userRepository.find(); const manageUsers = users.filter( @@ -192,7 +246,42 @@ class WebPushAgent }) .getMany(); - pushSubs.push(...allSubs); + // We only want to send the custom notification when type is approved or declined + // Otherwise, default to the normal notification + if ( + type === Notification.MEDIA_APPROVED || + type === Notification.MEDIA_DECLINED + ) { + if (mainUser && allSubs.length > 0) { + webpush.setVapidDetails( + `mailto:${mainUser.email}`, + settings.vapidPublic, + settings.vapidPrivate + ); + + // Custom payload only for updating the app badge + const notificationBadgePayload = Buffer.from( + JSON.stringify( + this.getNotificationPayload(type, { + subject: payload.subject, + notifySystem: false, + notifyAdmin: true, + isAdmin: true, + pendingRequestsCount: pendingRequests.length, + }) + ), + 'utf-8' + ); + + await Promise.all( + allSubs.map(async (sub) => { + webPushNotification(sub, notificationBadgePayload); + }) + ); + } + } else { + pushSubs.push(...allSubs); + } } if (mainUser && pushSubs.length > 0) { @@ -202,6 +291,10 @@ class WebPushAgent settings.vapidPrivate ); + if (type === Notification.MEDIA_PENDING) { + payload = { ...payload, pendingRequestsCount: pendingRequests.length }; + } + const notificationPayload = Buffer.from( JSON.stringify(this.getNotificationPayload(type, payload)), 'utf-8' @@ -209,39 +302,7 @@ class WebPushAgent await Promise.all( pushSubs.map(async (sub) => { - logger.debug('Sending web push notification', { - label: 'Notifications', - recipient: sub.user.displayName, - type: Notification[type], - subject: payload.subject, - }); - - try { - await webpush.sendNotification( - { - endpoint: sub.endpoint, - keys: { - auth: sub.auth, - p256dh: sub.p256dh, - }, - }, - notificationPayload - ); - } catch (e) { - logger.error( - 'Error sending web push notification; removing subscription', - { - label: 'Notifications', - recipient: sub.user.displayName, - type: Notification[type], - subject: payload.subject, - errorMessage: e.message, - } - ); - - // Failed to send notification so we need to remove the subscription - userPushSubRepository.remove(sub); - } + webPushNotification(sub, notificationPayload); }) ); } diff --git a/src/components/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index 6d137952b..d454e2adb 100644 --- a/src/components/Layout/MobileMenu/index.tsx +++ b/src/components/Layout/MobileMenu/index.tsx @@ -240,9 +240,11 @@ const MobileMenu = ({ router.pathname.match(link.activeRegExp) ? 'border-indigo-600 from-indigo-700 to-purple-700' : 'border-indigo-500 from-indigo-600 to-purple-600' - } !px-1 !py-[1px] leading-none`} + } flex h-4 w-4 items-center justify-center !px-[9px] !py-[9px] text-[9px]`} > - {pendingRequestsCount} + {pendingRequestsCount > 99 + ? '99+' + : pendingRequestsCount} )} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 01ea14f92..72e77ab70 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -11,6 +11,7 @@ import { LanguageContext } from '@app/context/LanguageContext'; import { SettingsProvider } from '@app/context/SettingsContext'; import { UserContext } from '@app/context/UserContext'; import type { User } from '@app/hooks/useUser'; +import { Permission, useUser } from '@app/hooks/useUser'; import '@app/styles/globals.css'; import { polyfillIntl } from '@app/utils/polyfillIntl'; import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces'; @@ -127,6 +128,35 @@ const CoreApp: Omit = ({ loadLocaleData(currentLocale).then(setMessages); }, [currentLocale]); + const { hasPermission } = useUser(); + + useEffect(() => { + const requestsCount = async () => { + const response = await axios.get('/api/v1/request/count'); + return response.data; + }; + + // Cast navigator to a type that includes setAppBadge and clearAppBadge + // to avoid TypeScript errors while ensuring these methods exist before calling them. + const newNavigator = navigator as unknown as { + setAppBadge?: (count: number) => Promise; + clearAppBadge?: () => Promise; + }; + + if ('setAppBadge' in navigator) { + if ( + !router.pathname.match(/(login|setup|resetpassword)/) && + hasPermission(Permission.ADMIN) + ) { + requestsCount().then((data) => + newNavigator?.setAppBadge?.(data.pending) + ); + } else { + newNavigator?.clearAppBadge?.(); + } + } + }, [hasPermission, router.pathname]); + if (router.pathname.match(/(login|setup|resetpassword)/)) { component = ; } else {