Skip to content

Commit

Permalink
feat: added the PWA badge indicator for requests pending (#3411)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
OwsleyJr authored Feb 26, 2025
1 parent fda24bd commit 389a87b
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 39 deletions.
21 changes: 20 additions & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
});

Expand Down
2 changes: 2 additions & 0 deletions server/lib/notifications/agents/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface NotificationPayload {
request?: MediaRequest;
issue?: Issue;
comment?: IssueComment;
pendingRequestsCount?: number;
isAdmin?: boolean;
}

export abstract class BaseAgent<T extends NotificationAgentConfig> {
Expand Down
133 changes: 97 additions & 36 deletions server/lib/notifications/agents/webpush.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +20,8 @@ interface PushNotificationPayload {
actionUrl?: string;
actionUrlTitle?: string;
requestId?: number;
pendingRequestsCount?: number;
isAdmin?: boolean;
}

class WebPushAgent
Expand Down Expand Up @@ -129,6 +132,8 @@ class WebPushAgent
requestId: payload.request?.id,
actionUrl,
actionUrlTitle,
pendingRequestsCount: payload.pendingRequestsCount,
isAdmin: payload.isAdmin,
};
}

Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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) {
Expand All @@ -202,46 +291,18 @@ 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'
);

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);
})
);
}
Expand Down
6 changes: 4 additions & 2 deletions src/components/Layout/MobileMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
</Badge>
</div>
)}
Expand Down
30 changes: 30 additions & 0 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -127,6 +128,35 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
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<void>;
clearAppBadge?: () => Promise<void>;
};

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 = <Component {...pageProps} />;
} else {
Expand Down

0 comments on commit 389a87b

Please sign in to comment.