Compare commits

..

3 Commits

Author SHA1 Message Date
Brandon
6e4a52188d fix: moved all notify user logic into webpush 2023-07-26 17:49:16 -05:00
Brandon
e3478a18dd refactor: removed unnecessary code when sending web push notification 2023-07-26 17:49:15 -05:00
Brandon
fa741a690d feat: added the PWA badge indicator for requests pending 2023-07-26 17:49:14 -05:00
15 changed files with 156 additions and 183 deletions

View File

@@ -109,6 +109,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)
);
@@ -128,7 +147,7 @@ self.addEventListener('notificationclick', (event) => {
method: 'POST',
});
}
if (notificationData.actionUrl) {
clients.openWindow(notificationData.actionUrl);
}

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

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

View File

@@ -31,7 +31,7 @@ import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
import * as Yup from 'yup';
const messages = defineMessages({
@@ -144,7 +144,6 @@ const IssueDetails = () => {
autoDismiss: true,
});
revalidateIssue();
mutate('/api/v1/issue/count');
} catch (e) {
addToast(intl.formatMessage(messages.toaststatusupdatefailed), {
appearance: 'error',
@@ -156,7 +155,6 @@ const IssueDetails = () => {
const deleteIssue = async () => {
try {
await axios.delete(`/api/v1/issue/${issueData.id}`);
mutate('/api/v1/issue/count');
addToast(intl.formatMessage(messages.toastissuedeleted), {
appearance: 'success',

View File

@@ -1,4 +1,3 @@
import Badge from '@app/components/Common/Badge';
import { menuMessages } from '@app/components/Layout/Sidebar';
import useClickOutside from '@app/hooks/useClickOutside';
import { Permission, useUser } from '@app/hooks/useUser';
@@ -28,11 +27,6 @@ import { useRouter } from 'next/router';
import { cloneElement, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
interface MobileMenuProps {
pendingRequestsCount: number;
openIssuesCount: number;
}
interface MenuLink {
href: string;
svgIcon: JSX.Element;
@@ -45,10 +39,7 @@ interface MenuLink {
dataTestId?: string;
}
const MobileMenu = ({
pendingRequestsCount,
openIssuesCount,
}: MobileMenuProps) => {
const MobileMenu = () => {
const ref = useRef<HTMLDivElement>(null);
const intl = useIntl();
const [isOpen, setIsOpen] = useState(false);
@@ -153,7 +144,7 @@ const MobileMenu = ({
return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`flex items-center ${
className={`flex items-center space-x-2 ${
isActive ? 'text-indigo-500' : ''
}`}
onKeyDown={(e) => {
@@ -168,25 +159,7 @@ const MobileMenu = ({
{cloneElement(isActive ? link.svgIconSelected : link.svgIcon, {
className: 'h-5 w-5',
})}
<span className="ml-2">{link.content}</span>
{link.href === '/requests' &&
pendingRequestsCount > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="ml-auto">
<Badge className="rounded-md border-indigo-500 bg-gradient-to-br from-indigo-600 to-purple-600">
{pendingRequestsCount}
</Badge>
</div>
)}
{link.href === '/issues' &&
openIssuesCount > 0 &&
hasPermission(Permission.MANAGE_ISSUES) && (
<div className="ml-auto">
<Badge className="rounded-md border-indigo-500 bg-gradient-to-br from-indigo-600 to-purple-600">
{openIssuesCount}
</Badge>
</div>
)}
<span>{link.content}</span>
</a>
</Link>
);
@@ -202,7 +175,7 @@ const MobileMenu = ({
return (
<Link key={`mobile-menu-link-${link.href}`} href={link.href}>
<a
className={`relative flex flex-col items-center space-y-1 ${
className={`flex flex-col items-center space-y-1 ${
isActive ? 'text-indigo-500' : ''
}`}
>
@@ -212,21 +185,6 @@ const MobileMenu = ({
className: 'h-6 w-6',
}
)}
{link.href === '/requests' &&
pendingRequestsCount > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="absolute left-3 bottom-3">
<Badge
className={`bg-gradient-to-br ${
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 leading-none`}
>
{pendingRequestsCount}
</Badge>
</div>
)}
</a>
</Link>
);

View File

@@ -1,4 +1,3 @@
import Badge from '@app/components/Common/Badge';
import VersionStatus from '@app/components/Layout/VersionStatus';
import useClickOutside from '@app/hooks/useClickOutside';
import { Permission, useUser } from '@app/hooks/useUser';
@@ -31,8 +30,6 @@ export const menuMessages = defineMessages({
interface SidebarProps {
open?: boolean;
setClosed: () => void;
pendingRequestsCount: number;
openIssuesCount: number;
}
interface SidebarLinkProps {
@@ -101,12 +98,7 @@ const SidebarLinks: SidebarLinkProps[] = [
},
];
const Sidebar = ({
open,
setClosed,
pendingRequestsCount,
openIssuesCount,
}: SidebarProps) => {
const Sidebar = ({ open, setClosed }: SidebarProps) => {
const navRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const intl = useIntl();
@@ -262,40 +254,6 @@ const Sidebar = ({
{intl.formatMessage(
menuMessages[sidebarLink.messagesKey]
)}
{sidebarLink.messagesKey === 'requests' &&
pendingRequestsCount > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="ml-auto">
<Badge
className={`rounded-md bg-gradient-to-br ${
router.pathname.match(
sidebarLink.activeRegExp
)
? 'border-indigo-600 from-indigo-700 to-purple-700'
: 'border-indigo-500 from-indigo-600 to-purple-600'
}`}
>
{pendingRequestsCount}
</Badge>
</div>
)}
{sidebarLink.messagesKey === 'issues' &&
openIssuesCount > 0 &&
hasPermission(Permission.MANAGE_ISSUES) && (
<div className="ml-auto">
<Badge
className={`rounded-md bg-gradient-to-br ${
router.pathname.match(
sidebarLink.activeRegExp
)
? 'border-indigo-600 from-indigo-700 to-purple-700'
: 'border-indigo-500 from-indigo-600 to-purple-600'
}`}
>
{openIssuesCount}
</Badge>
</div>
)}
</a>
</Link>
);

View File

@@ -10,7 +10,6 @@ import { useUser } from '@app/hooks/useUser';
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
type LayoutProps = {
children: React.ReactNode;
@@ -23,8 +22,6 @@ const Layout = ({ children }: LayoutProps) => {
const router = useRouter();
const { currentSettings } = useSettings();
const { setLocale } = useLocale();
const { data: requestResponse } = useSWR('/api/v1/request/count');
const { data: issueResponse } = useSWR('/api/v1/issue/count');
useEffect(() => {
if (setLocale && user) {
@@ -58,17 +55,9 @@ const Layout = ({ children }: LayoutProps) => {
<div className="absolute top-0 h-64 w-full bg-gradient-to-bl from-gray-800 to-gray-900">
<div className="relative inset-0 h-full w-full bg-gradient-to-t from-gray-900 to-transparent" />
</div>
<Sidebar
open={isSidebarOpen}
setClosed={() => setSidebarOpen(false)}
pendingRequestsCount={requestResponse?.pending}
openIssuesCount={issueResponse?.open}
/>
<Sidebar open={isSidebarOpen} setClosed={() => setSidebarOpen(false)} />
<div className="sm:hidden">
<MobileMenu
pendingRequestsCount={requestResponse?.pending}
openIssuesCount={issueResponse?.open}
/>
<MobileMenu />
</div>
<div className="relative mb-16 flex w-0 min-w-0 flex-1 flex-col lg:ml-64">

View File

@@ -20,7 +20,6 @@ import axios from 'axios';
import Link from 'next/link';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { mutate } from 'swr';
const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
@@ -57,7 +56,6 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
if (onUpdate) {
onUpdate();
mutate('/api/v1/request/count');
}
setIsUpdating(false);
};
@@ -68,7 +66,6 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
if (onUpdate) {
onUpdate();
mutate('/api/v1/request/count');
}
setIsUpdating(false);

View File

@@ -15,7 +15,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
import axios from 'axios';
import { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { mutate } from 'swr';
const messages = defineMessages({
viewrequest: 'View Request',
@@ -98,7 +97,6 @@ const RequestButton = ({
if (response) {
onUpdate();
mutate('/api/v1/request/count');
}
};
@@ -117,7 +115,6 @@ const RequestButton = ({
);
onUpdate();
mutate('/api/v1/request/count');
};
const buttons: ButtonOption[] = [];

View File

@@ -75,7 +75,6 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
};
return (
@@ -253,14 +252,12 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
if (response) {
revalidate();
mutate('/api/v1/request/count');
}
};
const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
};
const retryRequest = async () => {

View File

@@ -25,7 +25,7 @@ import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
const messages = defineMessages({
seasons: '{seasonCount, plural, one {Season} other {Seasons}}',
@@ -62,7 +62,6 @@ const RequestItemError = ({
const deleteRequest = async () => {
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
revalidateList();
mutate('/api/v1/request/count');
};
const { plexUrl, plexUrl4k } = useDeepLinks({
@@ -312,7 +311,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
if (response) {
revalidate();
mutate('/api/v1/request/count');
}
};
@@ -320,7 +318,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
await axios.delete(`/api/v1/request/${request.id}`);
revalidateList();
mutate('/api/v1/request/count');
};
const retryRequest = async () => {

View File

@@ -16,7 +16,7 @@ import axios from 'axios';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
import useSWR from 'swr';
const messages = defineMessages({
requestadmin: 'This request will be approved automatically.',
@@ -211,7 +211,6 @@ const CollectionRequestModal = ({
? MediaStatus.UNKNOWN
: MediaStatus.PARTIALLY_AVAILABLE
);
mutate('/api/v1/request/count');
}
addToast(
@@ -231,16 +230,7 @@ const CollectionRequestModal = ({
} finally {
setIsUpdating(false);
}
}, [
requestOverrides,
data?.parts,
data?.name,
onComplete,
addToast,
intl,
selectedParts,
is4k,
]);
}, [requestOverrides, data, onComplete, addToast, intl, selectedParts, is4k]);
const hasAutoApprove = hasPermission(
[

View File

@@ -95,7 +95,6 @@ const MovieRequestModal = ({
...overrideParams,
});
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
if (response.data) {
if (onComplete) {
@@ -130,16 +129,7 @@ const MovieRequestModal = ({
} finally {
setIsUpdating(false);
}
}, [
requestOverrides,
data?.id,
data?.title,
is4k,
onComplete,
addToast,
intl,
hasPermission,
]);
}, [data, onComplete, addToast, requestOverrides, hasPermission, intl, is4k]);
const cancelRequest = async () => {
setIsUpdating(true);
@@ -149,7 +139,6 @@ const MovieRequestModal = ({
`/api/v1/request/${editRequest?.id}`
);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
if (response.status === 204) {
if (onComplete) {
@@ -187,7 +176,6 @@ const MovieRequestModal = ({
await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
}
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
addToast(
<span>

View File

@@ -106,7 +106,6 @@ const TvRequestModal = ({
if (onUpdating) {
onUpdating(true);
mutate('/api/v1/request/count');
}
try {
@@ -129,7 +128,6 @@ const TvRequestModal = ({
await axios.delete(`/api/v1/request/${editRequest.id}`);
}
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
mutate('/api/v1/request/count');
addToast(
<span>
@@ -178,7 +176,6 @@ const TvRequestModal = ({
if (onUpdating) {
onUpdating(true);
mutate('/api/v1/request/count');
}
try {

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,30 @@ 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;
};
//Set navigator to new variable with any type to prevent setAppBadge unknown error since it does actually exist on navigator
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const newNavigator: any = navigator;
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 {