diff --git a/.prettierignore b/.prettierignore index 6e942806..1001e6b1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,4 +5,4 @@ config/ # assets src/assets/ -public/ +# public/ diff --git a/public/sw.js b/public/sw.js index e04d229e..cf9593f7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -4,30 +4,30 @@ // This variable is intentionally declared and unused. // eslint-disable-next-line @typescript-eslint/no-unused-vars const OFFLINE_VERSION = 3; -const CACHE_NAME = "offline"; +const CACHE_NAME = 'offline'; // Customize this with a different URL if needed. -const OFFLINE_URL = "/offline.html"; +const OFFLINE_URL = '/offline.html'; -self.addEventListener("install", (event) => { +self.addEventListener('install', (event) => { event.waitUntil( (async () => { const cache = await caches.open(CACHE_NAME); // Setting {cache: 'reload'} in the new request will ensure that the // response isn't fulfilled from the HTTP cache; i.e., it will be from // the network. - await cache.add(new Request(OFFLINE_URL, { cache: "reload" })); + await cache.add(new Request(OFFLINE_URL, { cache: 'reload' })); })() ); // Force the waiting service worker to become the active service worker. self.skipWaiting(); }); -self.addEventListener("activate", (event) => { +self.addEventListener('activate', (event) => { event.waitUntil( (async () => { // Enable navigation preload if it's supported. // See https://developers.google.com/web/updates/2017/02/navigation-preload - if ("navigationPreload" in self.registration) { + if ('navigationPreload' in self.registration) { await self.registration.navigationPreload.enable(); } })() @@ -37,10 +37,10 @@ self.addEventListener("activate", (event) => { clients.claim(); }); -self.addEventListener("fetch", (event) => { +self.addEventListener('fetch', (event) => { // We only want to call event.respondWith() if this is a navigation request // for an HTML page. - if (event.request.mode === "navigate") { + if (event.request.mode === 'navigate') { event.respondWith( (async () => { try { @@ -59,7 +59,7 @@ self.addEventListener("fetch", (event) => { // If fetch() returns a valid HTTP response with a response code in // the 4xx or 5xx range, the catch() will NOT be called. // eslint-disable-next-line no-console - console.log("Fetch failed; returning offline page instead.", error); + console.log('Fetch failed; returning offline page instead.', error); const cache = await caches.open(CACHE_NAME); const cachedResponse = await cache.match(OFFLINE_URL); @@ -70,7 +70,7 @@ self.addEventListener("fetch", (event) => { } }); -self.addEventListener('push', (event) => { +self.addEventListener('push', async (event) => { const payload = event.data ? event.data.json() : {}; const options = { @@ -85,15 +85,13 @@ self.addEventListener('push', (event) => { requestId: payload.requestId, }, actions: [], - } + }; - if (payload.actionUrl){ - options.actions.push( - { - action: 'view', - title: payload.actionUrlTitle ?? 'View', - } - ); + if (payload.actionUrl) { + options.actions.push({ + action: 'view', + title: payload.actionUrlTitle ?? 'View', + }); } if (payload.notificationType === 'MEDIA_PENDING') { @@ -109,27 +107,46 @@ self.addEventListener('push', (event) => { ); } - event.waitUntil( - self.registration.showNotification(payload.subject, options) - ); + if ( + (payload.notificationType === 'MEDIA_APPROVED' || + payload.notificationType === 'MEDIA_DECLINED') && + payload.isAdmin === true + ) { + 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)); }); -self.addEventListener('notificationclick', (event) => { - const notificationData = event.notification.data; +self.addEventListener( + 'notificationclick', + async (event) => { + const notificationData = event.notification.data; - event.notification.close(); + event.notification.close(); - if (event.action === 'approve') { - fetch(`/api/v1/request/${notificationData.requestId}/approve`, { - method: 'POST', - }); - } else if (event.action === 'decline') { - fetch(`/api/v1/request/${notificationData.requestId}/decline`, { - method: 'POST', - }); - } - - if (notificationData.actionUrl) { - clients.openWindow(notificationData.actionUrl); - } -}, false); + if (event.action === 'approve') { + fetch(`/api/v1/request/${notificationData.requestId}/approve`, { + method: 'POST', + }); + } else if (event.action === 'decline') { + fetch(`/api/v1/request/${notificationData.requestId}/decline`, { + method: 'POST', + }); + } + + if (notificationData.actionUrl) { + clients.openWindow(notificationData.actionUrl); + } + }, + false +); diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index e980860c..38bb09a2 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1163,6 +1163,12 @@ export class MediaRequest { private async sendNotification(media: Media, type: Notification) { const tmdb = new TheMovieDb(); + const requestRepository = getRepository(MediaRequest); + + const pendingRequests = await requestRepository.find({ + where: { status: MediaRequestStatus.PENDING }, + }); + try { const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; let event: string | undefined; @@ -1216,6 +1222,7 @@ export class MediaRequest { omission: '…', }), image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + pendingRequestsCount: pendingRequests.length, }); } else if (this.type === MediaType.TV) { const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); @@ -1243,6 +1250,7 @@ export class MediaRequest { .join(', '), }, ], + pendingRequestsCount: pendingRequests.length, }); } } catch (e) { diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index d2b0b165..952e1acf 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 275a77e8..b9117ebd 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -19,6 +19,8 @@ interface PushNotificationPayload { actionUrl?: string; actionUrlTitle?: string; requestId?: number; + pendingRequestsCount?: number; + isAdmin?: boolean; } class WebPushAgent @@ -129,6 +131,8 @@ class WebPushAgent requestId: payload.request?.id, actionUrl, actionUrlTitle, + pendingRequestsCount: payload.pendingRequestsCount, + isAdmin: payload.isAdmin, }; } @@ -195,6 +199,97 @@ class WebPushAgent pushSubs.push(...allSubs); } + if ( + type === Notification.MEDIA_APPROVED || + type === Notification.MEDIA_DECLINED + ) { + const users = await userRepository.find(); + const pushSubsForBadge: UserPushSubscription[] = []; + + const manageUsers = users.filter( + (user) => + // Check if user has webpush notifications enabled and fallback to true if undefined + // since web push should default to true + (user.settings?.hasNotificationType( + NotificationAgentKey.WEBPUSH, + type + ) ?? + true) && + shouldSendAdminNotification(type, user, payload) + ); + + const allSubs = await userPushSubRepository + .createQueryBuilder('pushSub') + .leftJoinAndSelect('pushSub.user', 'user') + .where('pushSub.userId IN (:users)', { + users: manageUsers + .filter((user) => user.id === 1) + .map((user) => user.id), + }) + .getMany(); + + pushSubsForBadge.push(...allSubs); + + if (mainUser && pushSubsForBadge.length > 0) { + webpush.setVapidDetails( + `mailto:${mainUser.email}`, + settings.vapidPublic, + settings.vapidPrivate + ); + + const notificationPayload = Buffer.from( + JSON.stringify( + this.getNotificationPayload(type, { + subject: 'Set PWA badge', + notifySystem: false, + notifyAdmin: true, + isAdmin: true, + pendingRequestsCount: payload.pendingRequestsCount, + }) + ), + 'utf-8' + ); + + await Promise.all( + pushSubsForBadge.map(async (sub) => { + logger.debug('Sending web push notification for badge update', { + 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 to update badge; 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); + } + }) + ); + } + } + if (mainUser && pushSubs.length > 0) { webpush.setVapidDetails( `mailto:${mainUser.email}`, diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 5730e410..74453396 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'; @@ -113,6 +114,33 @@ const CoreApp: Omit = ({ loadLocaleData(currentLocale).then(setMessages); }, [currentLocale]); + const requestsCount = async () => { + const response = await axios.get('/api/v1/request/count'); + + return response.data; + }; + + const { hasPermission } = useUser(); + + useEffect(() => { + //Set navigator to new variable with any type to prevent setAppBadge unknown error + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let newNavigator: any; + + if ('setAppBadge' in navigator) { + newNavigator = 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 {