(wip) PWA Push Notifications

This commit is contained in:
Dobromir Popov
2024-05-06 20:30:15 +03:00
parent a39a0aec4d
commit 4e1bbbbd57
13 changed files with 348 additions and 162 deletions

2
.gitignore vendored
View File

@ -17,7 +17,7 @@ lerna-debug.log*
**/public/sw.js.map
**/public/workbox-*.js.map
**/public/worker-*.js.map
public/worker.js
.eslintcache
*.tsbuildinfo

View File

@ -1,5 +1,7 @@
import React, { useEffect, useState } from 'react';
import common from '../src/helpers/common'; // Ensure this path is correct
//use session to get user role
import { useSession } from "next-auth/react"
function PwaManager() {
const [deferredPrompt, setDeferredPrompt] = useState(null);
@ -10,6 +12,8 @@ function PwaManager() {
const [registration, setRegistration] = useState(null);
const [notificationPermission, setNotificationPermission] = useState(Notification.permission);
const { data: session } = useSession();
// Handle PWA installation
useEffect(() => {
@ -54,7 +58,7 @@ function PwaManager() {
}, []);
const installPWA = async (e) => {
console.log('installing PWA');
e.preventDefault();
if (deferredPrompt) {
deferredPrompt.prompt();
@ -107,9 +111,25 @@ function PwaManager() {
applicationServerKey: base64ToUint8Array(vapidPublicKey)
});
// Call your API to save subscription data on server
setSubscription(sub);
setIsSubscribed(true);
console.log('Web push subscribed!');
if (session.user?.id != null) {
await fetch(`/api/notify`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ subscription: sub, id: session.user.id })
}).then(response => {
if (!response.ok) {
throw new Error('Failed to save subscription data on server.');
}
else {
console.log('Subscription data saved on server.');
setSubscription(sub);
setIsSubscribed(true);
console.log('Web push subscribed!');
}
});
}
console.log(sub);
} catch (error) {
console.error('Error subscribing to notifications:', error);
@ -124,6 +144,24 @@ function PwaManager() {
// Call your API to delete or invalidate subscription data on server
setSubscription(null);
setIsSubscribed(false);
if (session?.user?.id != null) {
await fetch(`/api/notify`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: session?.user.id }),
}
).then(response => {
if (!response.ok) {
throw new Error('Failed to delete subscription data on server.');
}
else {
console.log('Subscription data deleted on server.');
}
});
}
console.log('Web push unsubscribed!');
} catch (error) {
console.error('Error unsubscribing from notifications:', error);

View File

@ -1,15 +1,15 @@
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const { InjectManifest } = require('workbox-webpack-plugin');
const { InjectManifest, GenerateSW } = require('workbox-webpack-plugin');
const withPWA = require('next-pwa')({
dest: 'public',
register: true, // ?
publicExcludes: ["!_error*.js"], //?
disable: process.env.NODE_ENV === 'development',
skipWaiting: true,
// disable: process.env.NODE_ENV === 'development',
})
module.exports = {
module.exports = withPWA({
typescript: {
// !! WARN !!
// Dangerously allow production builds to successfully complete even if
@ -20,19 +20,31 @@ module.exports = {
compress: false,
pageExtensions: ['ts', 'tsx', 'md', 'mdx'], // Replace `jsx?` with `tsx?`
env: {
env: process.env.NODE_ENV,
env: process.env.APP_ENV,
server: process.env.NEXT_PUBLIC_PUBLIC_URL
},
plugins: [
// Other plugins...
new InjectManifest({
// These are some common options, and not all are required.
// Consult the docs for more info.
//exclude: [/.../, '...'],
maximumFileSizeToCacheInBytes: 1 * 1024 * 1024,
swSrc: './worker/index.js',
}),
],
// pwa: {
// dest: 'public',
// register: true,
// publicExcludes: ["!_error*.js"],
// disable: process.env.NODE_ENV === 'development',
// // sw: './worker/index.js', // Custom service worker file name
// },
// plugins: [
// // new InjectManifest({
// // // These are some common options, and not all are required.
// // // Consult the docs for more info.
// // //exclude: [/.../, '...'],
// // maximumFileSizeToCacheInBytes: 1 * 1024 * 1024,
// // swSrc: './worker.js',
// // }),
// // new GenerateSW({
// // //disable all files
// // maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
// // // swSrc: './worker.js',
// // }),
// ],
webpack: (config, { isServer, buildId, dev }) => {
// Configure optimization and source maps
config.optimization.minimize = !dev;
@ -46,18 +58,18 @@ module.exports = {
// InjectManifest configuration
if (!isServer) {
config.plugins.push(new InjectManifest({
swSrc: './worker/index.js', // Path to source service worker file
swDest: '/worker/index.js', // Destination filename in the build output
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // Adjust as needed
exclude: [/\.map$/, /_error.js$/, /favicon.ico$/] // Customize exclusion patterns
})
);
// config.plugins.push(new InjectManifest({
// // swSrc: './worker.js', // Path to source service worker file
// // swDest: '/worker.js', // Destination filename in the build output
// maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // Adjust as needed
// exclude: [/\.map$/, /_error.js$/, /favicon.ico$/] // Customize exclusion patterns
// })
// );
}
// Bundle Analyzer Configuration
if (process.env.ANALYZE && !isServer) {
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
//const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: 'static',
@ -77,4 +89,4 @@ module.exports = {
defaultLocale: 'bg',
localeDetection: false,
},
}
})

View File

@ -26,26 +26,47 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
// appleWebApp: true,
// }
// (custom) Service worker registration and push notification logic
// function registerServiceWorkerAndPushNotifications() {
// useEffect(() => {
// const registerServiceWorker = async () => {
// if ('serviceWorker' in navigator) {
// try {
// const registration = await navigator.serviceWorker.register('/worker/index.js')
// .then((registration) => console.log('reg: ', registration));
// } catch (error) {
// console.log('Service Worker registration failed:', error);
// }
// }
// };
// const askForNotificationPermission = async () => {
// if ('serviceWorker' in navigator && 'PushManager' in window) {
// try {
// const permission = await Notification.requestPermission();
// if (permission === 'granted') {
// console.log('Notification permission granted.');
// // TODO: Subscribe the user to push notifications here
// } else {
// console.log('Notification permission not granted.');
// }
// } catch (error) {
// console.error('Error during service worker registration:', error);
// }
// } else {
// console.log('Service Worker or Push notifications not supported in this browser.');
// }
// };
// registerServiceWorker();
// askForNotificationPermission();
// }, []);
// }
//function SmwsApp({ Component, pageProps: { locale, messages, session, ...pageProps }, }: AppProps<{ session: Session }>) {
function SmwsApp({ Component, pageProps, session, locale, messages }) {
// dynamic locale loading using our API endpoint
// const [locale, setLocale] = useState(_locale);
// const [messages, setMessages] = useState(_messages);
// useEffect(() => {
// async function loadLocaleData() {
// const res = await fetch(`/api/translations/${locale}`);
// if (res.ok) {
// const localeMessages = await res.json();
// console.log("Loaded messages for locale:", locale, localeMessages);
// setMessages(localeMessages);
// } else {
// const localeMessages = await import(`../content/i18n/${locale}.json`); setMessages(localeMessages.default);
// }
// console.log("locale set to'", locale, "' ",);
// }
// loadLocaleData();
// }, [locale]);
//registerServiceWorkerAndPushNotifications();
useEffect(() => {
const use = async () => {

View File

@ -1,6 +1,8 @@
const webPush = require('web-push')
import common from '../../src/helpers/common';
//generate and store VAPID keys in .env.local if not already done
if (!process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY || !process.env.WEB_PUSH_PRIVATE_KEY) {
const { publicKey, privateKey } = webPush.generateVAPIDKeys()
@ -28,28 +30,66 @@ const Notification = async (req, res) => {
res.end(process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY)
res.end()
}
// on PUT store the subscription object in the database
if (req.method == 'PUT') {
// store the subscription object in the database
// publisher.pushSubscription = subscription
const prisma = common.getPrismaClient();
const { subscription, id } = req.body
const publisher = await prisma.publisher.update({
where: { id },
data: { pushSubscription: subscription }
})
console.log('Subscription for publisher', id, 'updated:', subscription)
res.statusCode = 200
res.end()
}
if (req.method == 'DELETE') {
// remove the subscription object from the database
// publisher.pushSubscription = null
const prisma = common.getPrismaClient();
const { id } = req.body
const publisher = await prisma.publisher.update({
where: { id },
data: { pushSubscription: null }
})
console.log('Subscription for publisher', id, 'deleted')
res.statusCode = 200
res.end()
}
if (req.method == 'POST') {
const { subscription } = req.body
await webPush
.sendNotification(
subscription,
JSON.stringify({ title: 'Hello Web Push', message: 'Your web push notification is here!' })
)
.then(response => {
res.writeHead(response.statusCode, response.headers).end(response.body)
})
.catch(err => {
if ('statusCode' in err) {
res.writeHead(err.statusCode, err.headers).end(err.body)
} else {
console.error(err)
res.statusCode = 500
res.end()
}
})
const { subscription, id, broadcast, title = 'Hello Web Push', message = 'Your web push notification is here!' } = req.body
if (broadcast) {
await broadcastPush(title, message)
res.statusCode = 200
res.end()
return
}
else if (id) {
await sendPush(id, title, message)
res.statusCode = 200
res.end()
return
} else if (subscription) {
await webPush
.sendNotification(
subscription,
JSON.stringify({ title, message })
)
.then(response => {
res.writeHead(response.statusCode, response.headers).end(response.body)
})
.catch(err => {
if ('statusCode' in err) {
res.writeHead(err.statusCode, err.headers).end(err.body)
} else {
console.error(err)
res.statusCode = 500
res.end()
}
})
}
} else {
res.statusCode = 405
res.end()
@ -57,3 +97,48 @@ const Notification = async (req, res) => {
}
export default Notification
//export pushNotification(userId or email) for use in other files
export const sendPush = async (id, title, message) => {
const prisma = common.getPrismaClient();
const publisher = await prisma.publisher.findUnique({
where: { id }
})
if (!publisher.pushSubscription) {
console.log('No push subscription found for publisher', id)
return
}
await webPush
.sendNotification(
publisher.pushSubscription,
JSON.stringify({ title, message })
)
.then(response => {
console.log('Push notification sent to publisher', id)
})
.catch(err => {
console.error('Error sending push notification to publisher', id, ':', err)
})
}
//export breoadcastNotification for use in other files
export const broadcastPush = async (title, message) => {
const prisma = common.getPrismaClient();
const publishers = await prisma.publisher.findMany({
where: { pushSubscription: { not: null } }
})
for (const publisher of publishers) {
await webPush
.sendNotification(
publisher.pushSubscription,
JSON.stringify({ title, message })
)
.then(response => {
console.log('Push notification sent to publisher', publisher.id)
})
.catch(err => {
console.error('Error sending push notification to publisher', publisher.id, ':', err)
})
}
}

View File

@ -15,6 +15,8 @@ import { toast } from 'react-toastify';
import ProtectedRoute from '../../../components/protectedRoute';
import ConfirmationModal from '../../../components/ConfirmationModal';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
// import notify api
import { sendPush, broadcastPush } from '../../api/notify';
const { DateTime } = require('luxon');
// import { FaPlus, FaCogs, FaTrashAlt, FaSpinner } from 'react-icons/fa'; // Import FontAwesome icons
@ -734,7 +736,19 @@ export default function CalendarPage({ initialEvents, initialShifts }) {
<span title="участия тази седмица" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentWeekAssignments ? 'bg-yellow-500 text-white' : 'bg-yellow-200 text-gray-400'}`}>{pub.currentWeekAssignments || 0}</span>
<span title="участия този месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.currentMonthAssignments ? 'bg-green-500 text-white' : 'bg-green-200 text-gray-400'}`}>{pub.currentMonthAssignments || 0}</span>
<span tooltip="участия миналия месец" title="участия миналия месец" className={`badge py-1 px-2 rounded-full text-xs ${pub.previousMonthAssignments ? 'bg-blue-500 text-white' : 'bg-blue-200 text-gray-400'}`}>{pub.previousMonthAssignments || 0}</span>
<button tooltip="желани участия този месец" title="желани участия" className={`badge py-1 px-2 rounded-md text-xs ${pub.desiredShiftsPerMonth ? 'bg-purple-500 text-white' : 'bg-purple-200 text-gray-400'}`}>{pub.desiredShiftsPerMonth || 0}</button>
<button tooltip="желани участия на месец" title="желани участия" className={`badge py-1 px-2 rounded-md text-xs ${pub.desiredShiftsPerMonth ? 'bg-purple-500 text-white' : 'bg-purple-200 text-gray-400'}`}>{pub.desiredShiftsPerMonth || 0}</button>
<button tooltip="push" title="push" className={`badge py-1 px-2 rounded-md text-xs bg-red-100`}
onClick={async () => {
await fetch('/api/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ broadcast: true, message: "Тестово съобщение", title: "Това е тестово съобщение от https://sofia.mwitnessing.com" })
})
}}
>+</button>
</div>
</li>
);

View File

@ -226,8 +226,8 @@ export const getServerSideProps = async (context) => {
// log first availability startTime to verify timezone and UTC conversion
console.log("First availability startTime: " + items[0].startTime);
console.log("First availability startTime: " + items[0].startTime.toLocaleString());
console.log("First availability startTime: " + items[0]?.startTime);
console.log("First availability startTime: " + items[0]?.startTime.toLocaleString());
const prisma = common.getPrismaClient();
let cartEvents = await prisma.cartEvent.findMany({

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `publisher` ADD COLUMN `pushSubscription` JSON NULL;

View File

@ -123,6 +123,7 @@ model Publisher {
Message Message[]
EventLog EventLog[]
lastLogin DateTime?
pushSubscription Json?
}
model Availability {

View File

@ -31,7 +31,7 @@ const axiosServer = async (context) => {
}
else {
//redirect to next-auth login page
context.res.writeHead(302, { Location: '/api/auth/signin' });
context.res.writeHead(302, { Location: encodeURIComponent('/api/auth/signin') });
context.res.end();
return { props: {} };
}

View File

@ -1,34 +1,37 @@
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js');
// importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js');
// Only import the modules you need; skip precaching and routing if not needed
workbox.core.skipWaiting();
workbox.core.clientsClaim();
// // ToDo: probably not used now as we use next-pwa( check config)
// // Only import the modules you need; skip precaching and routing if not needed
//workbox.precaching.cleanupOutdatedCaches();
//disable precaching
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// workbox.core.skipWaiting();
// workbox.core.clientsClaim();
module.exports = {
// Other webpack config...
plugins: [
// Other plugins...
new InjectManifest({
// These are some common options, and not all are required.
// Consult the docs for more info.
exclude: [/.../, '...'],
maximumFileSizeToCacheInBytes: 1 * 1024 * 1024,
swSrc: './worker/index.js',
}),
],
};
// //workbox.precaching.cleanupOutdatedCaches();
// //disable precaching
// workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// Example: Set up push notification handling
self.addEventListener('push', event => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.message,
icon: '/path/to/icon.png'
})
);
});
// module.exports = {
// // Other webpack config...
// plugins: [
// // Other plugins...
// new InjectManifest({
// // These are some common options, and not all are required.
// // Consult the docs for more info.
// exclude: [/.../, '...'],
// maximumFileSizeToCacheInBytes: 1 * 1024 * 1024,
// // swSrc: './worker.js',
// }),
// ],
// };
// // Example: Set up push notification handling
// self.addEventListener('push', event => {
// console.log('Push event received at workbox.config: ', event);
// const data = event.data.json();
// event.waitUntil(
// self.registration.showNotification(data.title, {
// body: data.message,
// icon: '/path/to/icon.png'
// })
// );
// });

71
worker.js Normal file
View File

@ -0,0 +1,71 @@
// 'use strict'
// // currently not used as we ise next-pwa and in next.config.js we have withPWA.
// // maybe we can have withPWA({sw: './worker.js'}) ?
// console.log('Service Worker worker/index.js Loaded...')
// workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// self.addEventListener('install', () => {
// console.log('service worker installed')
// });
// self.addEventListener('activate', () => {
// console.log('service worker activated')
// });
// self.addEventListener('fetch', (event) => {
// try {
// console.log('Fetch event for ', event.request.url);
// if (event.request.url.includes('/api/auth/callback/')) {
// // Use network only strategy for auth routes, or bypass SW completely
// event.respondWith(fetch(event.request));
// return;
// }
// // other caching strategies...
// } catch (error) {
// console.error(error)
// }
// });
// self.addEventListener('push', function (event) {
// console.log('Push message', event)
// if (!(self.Notification && self.Notification.permission === 'granted')) {
// return
// }
// const data = JSON.parse(event.data.text())
// event.waitUntil(
// registration.showNotification(data.title, {
// body: data.message,
// icon: '/icons/android-chrome-192x192.png'
// })
// )
// })
// self.addEventListener('notificationclick', function (event) {
// console.log('Notification click: tag', event.notification.tag)
// event.notification.close()
// event.waitUntil(
// clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
// if (clientList.length > 0) {
// let client = clientList[0]
// for (let i = 0; i < clientList.length; i++) {
// if (clientList[i].focused) {
// client = clientList[i]
// }
// }
// return client.focus()
// }
// return clients.openWindow('/')
// })
// )
// })
// // self.addEventListener('pushsubscriptionchange', function(event) {
// // event.waitUntil(
// // Promise.all([
// // Promise.resolve(event.oldSubscription ? deleteSubscription(event.oldSubscription) : true),
// // Promise.resolve(event.newSubscription ? event.newSubscription : subscribePush(registration))
// // .then(function(sub) { return saveSubscription(sub) })
// // ])
// // )
// // })

View File

@ -1,61 +0,0 @@
'use strict'
console.log('Service Worker worker/index.js Loaded...')
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
self.addEventListener('fetch', (event) => {
try {
console.log('Fetch event for ', event.request.url);
if (event.request.url.includes('/api/auth/callback/')) {
// Use network only strategy for auth routes, or bypass SW completely
event.respondWith(fetch(event.request));
return;
}
// other caching strategies...
} catch (error) {
console.error(error)
}
});
self.addEventListener('push', function (event) {
console.log('Push message', event)
if (!(self.Notification && self.Notification.permission === 'granted')) {
return
}
const data = JSON.parse(event.data.text())
event.waitUntil(
registration.showNotification(data.title, {
body: data.message,
icon: '/icons/android-chrome-192x192.png'
})
)
})
self.addEventListener('notificationclick', function (event) {
console.log('Notification click: tag', event.notification.tag)
event.notification.close()
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
if (clientList.length > 0) {
let client = clientList[0]
for (let i = 0; i < clientList.length; i++) {
if (clientList[i].focused) {
client = clientList[i]
}
}
return client.focus()
}
return clients.openWindow('/')
})
)
})
// self.addEventListener('pushsubscriptionchange', function(event) {
// event.waitUntil(
// Promise.all([
// Promise.resolve(event.oldSubscription ? deleteSubscription(event.oldSubscription) : true),
// Promise.resolve(event.newSubscription ? event.newSubscription : subscribePush(registration))
// .then(function(sub) { return saveSubscription(sub) })
// ])
// )
// })