feat: added the PWA badge indicator for requests pending

This commit is contained in:
Brandon
2023-04-05 14:19:32 -04:00
committed by Brandon Cohen
parent 83b008c839
commit fa741a690d
6 changed files with 189 additions and 39 deletions

View File

@@ -5,4 +5,4 @@ config/
# assets
src/assets/
public/
# public/

View File

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

View File

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

View File

@@ -19,6 +19,8 @@ export interface NotificationPayload {
request?: MediaRequest;
issue?: Issue;
comment?: IssueComment;
pendingRequestsCount?: number;
isAdmin?: boolean;
}
export abstract class BaseAgent<T extends NotificationAgentConfig> {

View File

@@ -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}`,

View File

@@ -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<NextAppComponentType, 'origGetInitialProps'> = ({
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 = <Component {...pageProps} />;
} else {