diff --git a/package-lock.json b/package-lock.json index 4468a9d0..278edcc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "vite": "^5.4.9", "vite-plugin-dynamic-import": "^1.5.0", "vitest": "^1.5.1", + "web-push": "^3.6.7", "zod": "^3.23.4" } }, @@ -2715,6 +2716,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2896,6 +2907,19 @@ "integrity": "sha512-UfobP5N12Qm4Qu4fwLDIi2v6+wZsSf6snYSxAMeKhrh37YGnNWZPRmVEKc/2wfms53TLQnzfpG8wCx2Y/6NG1w==", "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -3071,6 +3095,13 @@ "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", "dev": true }, + "node_modules/bn.js": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", + "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -5649,6 +5680,16 @@ "node": ">=8.0.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -5673,6 +5714,20 @@ "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -6761,6 +6816,13 @@ "mini-svg-data-uri": "cli.js" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -10080,6 +10142,49 @@ "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", "license": "Apache-2.0" }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/web-push/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/web-worker": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", diff --git a/package.json b/package.json index 82048cc2..33a8b5d4 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "vite": "^5.4.9", "vite-plugin-dynamic-import": "^1.5.0", "vitest": "^1.5.1", + "web-push": "^3.6.7", "zod": "^3.23.4" }, "dependencies": { diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 7bd1f0b1..5f8cc92e 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -43,4 +43,64 @@ export function sendNotification( }; } } -} \ No newline at end of file +} + + +async function unsubscribe() { + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await subscription.unsubscribe(); + } + } + } + +async function sendSubscriptionToServer(subscription) { + try { + const res = await fetch('/api/addPushSubscription', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ subscription }) + }); + if (!res.ok) + throw new Error(`Error saving subscription on server: ${res.statusText} (${res.status})`); + } catch (error) { + console.error('Error saving subscription on server:', error); + unsubscribe(); + } + } + +export async function subscribeUser() { + if ('serviceWorker' in navigator) { + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY + }); + sendSubscriptionToServer(subscription); + } catch (err) { + console.error('Error subscribing:', err); + } + } + } + +export async function checkSubscriptionStatus() { + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.ready; + + const subscription = await registration.pushManager.getSubscription(); + //console.log('check Subscription:', subscription); + const exists = subscription !== null; + //this can be optional + if (exists) { + // just to make sure the subscription is saved on the server + sendSubscriptionToServer(subscription); + } + return exists; + } + return false; + } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ffde8c07..2a66ec63 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -51,13 +51,13 @@ import AudioLine from "lucide-svelte/icons/audio-lines"; import Gem from "lucide-svelte/icons/gem"; import stocknear_logo from "$lib/images/stocknear_logo.png"; - /* + import { requestNotificationPermission, - sendNotification, + subscribeUser, + checkSubscriptionStatus, } from "$lib/notifications"; - */ export let data; let hideHeader = false; @@ -132,7 +132,18 @@ onMount(async () => { if (data?.user?.id) { await loadWorker(); + + const permissionGranted = await requestNotificationPermission(); + + if (permissionGranted) { + const isSubscribed = (await checkSubscriptionStatus()) || false; + + if (!isSubscribed) { + await subscribeUser(); + } + } } + await checkMarketHour(); if ($showCookieConsent === true) { Cookie = (await import("$lib/components/Cookie.svelte")).default; @@ -1181,7 +1192,7 @@ --> - + {#if Cookie && $showCookieConsent === true} {/if} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index eadfd0b7..be5c8830 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -60,6 +60,8 @@ />
{ + const { user, pb } = locals; + + if (!user?.id) { + console.log('No username passed to addSubscription'); + throw error(401, 'Unauthorized'); + } + + const data = await request.json(); + + if (!data?.subscription) { + console.log('No subscription passed to addSubscription', data); + throw error(400, 'Bad Request'); + } + + initWebPush() + // find all subscription of users if they exist and delete them first before creating the new one. + + const output = await pb.collection("pushSubscription").getFullList({ + filter: `user="${user?.id}"`, + }); + + if (output?.length > 0) { + for (const item of output) { + await pb.collection("pushSubscription").delete(item?.id); + } + } + + + await pb.collection("pushSubscription").create({user: user?.id, subscription: data}) + + + //addUserDevice(username, data.subscription); + //addUserToChannel(username, 'album-updates'); + + return new Response(JSON.stringify({'success': true})); + +}) satisfies RequestHandler; \ No newline at end of file diff --git a/src/routes/api/deletePushSubscription/+server.ts b/src/routes/api/deletePushSubscription/+server.ts new file mode 100644 index 00000000..cd54f089 --- /dev/null +++ b/src/routes/api/deletePushSubscription/+server.ts @@ -0,0 +1,35 @@ +import type { RequestHandler } from "./$types"; +import { error} from '@sveltejs/kit'; + + + +export const POST = (async ({ locals, request }) => { + const { user, pb } = locals; + + if (!user?.id) { + console.log('No username passed to addSubscription'); + throw error(401, 'Unauthorized'); + } + + const data = await request.json(); + + if (!data?.subscription) { + console.log('No subscription passed to unsubscribe', data); + throw error(400, 'Bad Request'); + } + + + const output = await pb.collection("pushSubscription").getFullList({ + filter: `user="${user?.id}"`, + }); + + if (output?.length > 0) { + for (const item of output) { + await pb.collection("pushSubscription")?.delete(item?.id); + } + } + + + return new Response(JSON.stringify({'success': true})); + +}) satisfies RequestHandler; \ No newline at end of file diff --git a/src/service-worker.ts b/src/service-worker.ts index 04a6ddaa..ab1fee43 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -5,16 +5,20 @@ declare let self: ServiceWorkerGlobalScope; import { build, files, version } from "$service-worker"; +// Fixed template literal syntax const CACHE = `cache-${version}`; const ASSETS = [...build, ...files]; // install service worker self.addEventListener("install", (event) => { async function addFilesToCache() { - const cache = await caches.open(CACHE); - await cache.addAll(ASSETS); + try { + const cache = await caches.open(CACHE); + await cache.addAll(ASSETS); + } catch (error) { + console.error('Service worker installation failed:', error); + } } - event.waitUntil(addFilesToCache()); }); @@ -72,3 +76,16 @@ self.addEventListener("message", (event) => { self.skipWaiting(); } }); + + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +self.addEventListener('push', function (event: any) { + const payload = event.data?.text() ?? 'no payload'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const registration = (self as any).registration as ServiceWorkerRegistration; + event.waitUntil( + registration.showNotification('Stocknear', { + body: payload + }) + ); +} as EventListener); \ No newline at end of file