Implement proof of concept push notifications
This commit is contained in:
parent
e3210afe3a
commit
6007f4caeb
13 changed files with 407 additions and 1 deletions
|
@ -1,3 +1,5 @@
|
|||
node_modules
|
||||
.next/
|
||||
next-env.d.ts
|
||||
#env*
|
||||
push-subscriptions.json
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -39,3 +39,6 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# misc
|
||||
push-subscriptions.json
|
||||
|
|
|
@ -34,6 +34,7 @@ COPY --from=builder /app/public ./public
|
|||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --chown=nextjs:nodejs schedule.json .
|
||||
RUN echo '[]' > push-subscriptions.json && chown nextjs:nodejs push-subscriptions.json
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
|
|
@ -1,8 +1,63 @@
|
|||
"use server";
|
||||
import webPush from "web-push";
|
||||
import { Schedule } from "@/app/schedule/types";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { broadcastUpdate } from "./streams";
|
||||
|
||||
webPush.setVapidDetails(
|
||||
"mailto:webmaster@hornwitser.no",
|
||||
process.env.VAPID_PUBLIC_KEY!,
|
||||
process.env.VAPID_PRIVATE_KEY!,
|
||||
)
|
||||
|
||||
async function sendPush(title: string, body: string) {
|
||||
const payload = JSON.stringify({ title, body });
|
||||
let subscriptions: PushSubscriptionJSON[];
|
||||
try {
|
||||
subscriptions = JSON.parse(await readFile("push-subscriptions.json", "utf-8"));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
console.log(`Dropping "${payload}", no push subscribers`);
|
||||
return;
|
||||
}
|
||||
subscriptions = [];
|
||||
}
|
||||
console.log(`Sending "${payload}" to ${subscriptions.length} subscribers`);
|
||||
const removeIndexes = [];
|
||||
for (let index = 0; index < subscriptions.length; index += 1) {
|
||||
const subscription = subscriptions[index];
|
||||
try {
|
||||
await webPush.sendNotification(
|
||||
subscription as webPush.PushSubscription,
|
||||
payload,
|
||||
{
|
||||
TTL: 3600,
|
||||
urgency: "high",
|
||||
}
|
||||
)
|
||||
} catch (err: any) {
|
||||
console.error("Received error sending push notice:", err.message, err?.statusCode)
|
||||
console.error(err);
|
||||
if (err?.statusCode >= 400 && err?.statusCode < 500) {
|
||||
removeIndexes.push(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (removeIndexes.length) {
|
||||
console.log(`Removing indexes ${removeIndexes} from subscriptions`)
|
||||
removeIndexes.reverse();
|
||||
for (const index of removeIndexes) {
|
||||
subscriptions.splice(index, 1);
|
||||
}
|
||||
await writeFile(
|
||||
"push-subscriptions.json",
|
||||
JSON.stringify(subscriptions, undefined, "\t"),
|
||||
"utf-8"
|
||||
);
|
||||
}
|
||||
console.log("Push notices sent");
|
||||
}
|
||||
|
||||
export async function createEvent(formData: FormData) {
|
||||
const schedule: Schedule = JSON.parse(await readFile("schedule.json", "utf-8"));
|
||||
const id = formData.get("id") as string;
|
||||
|
@ -26,6 +81,7 @@ export async function createEvent(formData: FormData) {
|
|||
});
|
||||
broadcastUpdate(schedule);
|
||||
await writeFile("schedule.json", JSON.stringify(schedule, null, "\t"), "utf-8");
|
||||
await sendPush("New event", `${name} will start at ${start}`);
|
||||
}
|
||||
|
||||
export async function modifyEvent(formData: FormData) {
|
||||
|
@ -40,6 +96,7 @@ export async function modifyEvent(formData: FormData) {
|
|||
if (index === -1) {
|
||||
throw Error("No such event");
|
||||
}
|
||||
const timeChanged = schedule.events[index].slots[0].start !== start + "Z";
|
||||
schedule.events[index] = {
|
||||
name,
|
||||
id,
|
||||
|
@ -55,6 +112,8 @@ export async function modifyEvent(formData: FormData) {
|
|||
};
|
||||
broadcastUpdate(schedule);
|
||||
await writeFile("schedule.json", JSON.stringify(schedule, null, "\t"), "utf-8");
|
||||
if (timeChanged)
|
||||
await sendPush(`New time for ${name}`, `${name} will now start at ${start}`);
|
||||
}
|
||||
|
||||
export async function deleteEvent(formData: FormData) {
|
||||
|
|
29
app/api/subscribe/route.ts
Normal file
29
app/api/subscribe/route.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { readFile, writeFile } from "fs/promises";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body: { subscription: PushSubscriptionJSON } = await request.json();
|
||||
let subscriptions: PushSubscriptionJSON[];
|
||||
try {
|
||||
subscriptions = JSON.parse(await readFile("push-subscriptions.json", "utf-8"));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
subscriptions = [];
|
||||
}
|
||||
const existingIndex = subscriptions.findIndex(sub => sub.endpoint === body.subscription.endpoint);
|
||||
if (existingIndex !== -1) {
|
||||
subscriptions[existingIndex] = body.subscription;
|
||||
} else {
|
||||
subscriptions.push(body.subscription);
|
||||
}
|
||||
await writeFile(
|
||||
"push-subscriptions.json",
|
||||
JSON.stringify(subscriptions, undefined, "\t"),
|
||||
"utf-8"
|
||||
);
|
||||
if (existingIndex !== -1) {
|
||||
return new Response(JSON.stringify({ message: "Existing subscription refreshed."}));
|
||||
}
|
||||
return new Response(JSON.stringify({ message: "New subscription registered."}));
|
||||
}
|
26
app/api/unsubscribe/route.ts
Normal file
26
app/api/unsubscribe/route.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { readFile, writeFile } from "fs/promises";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body: { subscription: PushSubscriptionJSON } = await request.json();
|
||||
let subscriptions: PushSubscriptionJSON[];
|
||||
try {
|
||||
subscriptions = JSON.parse(await readFile("push-subscriptions.json", "utf-8"));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
subscriptions = [];
|
||||
}
|
||||
const existingIndex = subscriptions.findIndex(sub => sub.endpoint === body.subscription.endpoint);
|
||||
if (existingIndex !== -1) {
|
||||
subscriptions.splice(existingIndex, 1);
|
||||
} else {
|
||||
return new Response(JSON.stringify({ message: "No subscription registered."}));
|
||||
}
|
||||
await writeFile(
|
||||
"push-subscriptions.json",
|
||||
JSON.stringify(subscriptions, undefined, "\t"),
|
||||
"utf-8"
|
||||
);
|
||||
return new Response(JSON.stringify({ message: "Existing subscription removed."}));
|
||||
}
|
|
@ -61,6 +61,10 @@ label>* {
|
|||
margin-inline-start: 0.5rem;
|
||||
}
|
||||
|
||||
p + p {
|
||||
margin-block-start: 0.5rem;
|
||||
}
|
||||
|
||||
label + label {
|
||||
margin-block-start: 0.5rem;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { ScheduleProvider } from "./context"
|
|||
import { Events } from "@/ui/events";
|
||||
import { Locations } from "@/ui/locations";
|
||||
import { EventsEdit } from "@/ui/events-edit";
|
||||
import { PushNotification } from "@/ui/push-notification";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -17,6 +18,10 @@ export default async function page() {
|
|||
<p>
|
||||
Study carefully, we only hold these events once a year.
|
||||
</p>
|
||||
<p>
|
||||
Get notified about updates
|
||||
</p>
|
||||
<PushNotification vapidPublicKey={process.env.VAPID_PUBLIC_KEY!} />
|
||||
<h2>Schedule</h2>
|
||||
<Timetable />
|
||||
<EventsEdit />
|
||||
|
|
10
generate-vapid-keys.mjs
Normal file
10
generate-vapid-keys.mjs
Normal file
|
@ -0,0 +1,10 @@
|
|||
import webPush from "web-push";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const vapidKeys = webPush.generateVAPIDKeys();
|
||||
|
||||
const envData = `\
|
||||
VAPID_PUBLIC_KEY=${vapidKeys.publicKey}
|
||||
VAPID_PRIVATE_KEY=${vapidKeys.privateKey}
|
||||
`;
|
||||
await fs.writeFile(".env", envData, "utf-8");
|
|
@ -11,12 +11,14 @@
|
|||
"dependencies": {
|
||||
"next": "15.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"csstype": "^3.1.3",
|
||||
"typescript": "^5"
|
||||
},
|
||||
|
|
120
pnpm-lock.yaml
generated
120
pnpm-lock.yaml
generated
|
@ -14,6 +14,9 @@ dependencies:
|
|||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.0.0(react@19.0.0)
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
|
@ -25,6 +28,9 @@ devDependencies:
|
|||
'@types/react-dom':
|
||||
specifier: ^19
|
||||
version: 19.0.0
|
||||
'@types/web-push':
|
||||
specifier: ^3.6.4
|
||||
version: 3.6.4
|
||||
csstype:
|
||||
specifier: ^3.1.3
|
||||
version: 3.1.3
|
||||
|
@ -324,6 +330,34 @@ packages:
|
|||
csstype: 3.1.3
|
||||
dev: true
|
||||
|
||||
/@types/web-push@3.6.4:
|
||||
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||
dependencies:
|
||||
'@types/node': 20.0.0
|
||||
dev: true
|
||||
|
||||
/agent-base@7.1.3:
|
||||
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||
engines: {node: '>= 14'}
|
||||
dev: false
|
||||
|
||||
/asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
dependencies:
|
||||
bn.js: 4.12.1
|
||||
inherits: 2.0.4
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
dev: false
|
||||
|
||||
/bn.js@4.12.1:
|
||||
resolution: {integrity: sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==}
|
||||
dev: false
|
||||
|
||||
/buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
dev: false
|
||||
|
||||
/busboy@1.6.0:
|
||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
@ -377,6 +411,18 @@ packages:
|
|||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
dev: true
|
||||
|
||||
/debug@4.4.0:
|
||||
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
dev: false
|
||||
|
||||
/detect-libc@2.0.3:
|
||||
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -384,12 +430,64 @@ packages:
|
|||
dev: false
|
||||
optional: true
|
||||
|
||||
/ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
dev: false
|
||||
|
||||
/http_ece@1.2.0:
|
||||
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
|
||||
engines: {node: '>=16'}
|
||||
dev: false
|
||||
|
||||
/https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
dependencies:
|
||||
agent-base: 7.1.3
|
||||
debug: 4.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
dev: false
|
||||
|
||||
/is-arrayish@0.3.2:
|
||||
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/jwa@2.0.0:
|
||||
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
dev: false
|
||||
|
||||
/jws@4.0.0:
|
||||
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
|
||||
dependencies:
|
||||
jwa: 2.0.0
|
||||
safe-buffer: 5.2.1
|
||||
dev: false
|
||||
|
||||
/minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
dev: false
|
||||
|
||||
/minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
dev: false
|
||||
|
||||
/ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
dev: false
|
||||
|
||||
/nanoid@3.3.8:
|
||||
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
|
@ -468,6 +566,14 @@ packages:
|
|||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
dev: false
|
||||
|
||||
/safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
dev: false
|
||||
|
||||
/scheduler@0.25.0:
|
||||
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
|
||||
dev: false
|
||||
|
@ -555,3 +661,17 @@ packages:
|
|||
engines: {node: '>=12.20'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/web-push@3.6.7:
|
||||
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||
engines: {node: '>= 16'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
asn1.js: 5.4.1
|
||||
http_ece: 1.2.0
|
||||
https-proxy-agent: 7.0.6
|
||||
jws: 4.0.0
|
||||
minimist: 1.2.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
|
25
public/sw.js
Normal file
25
public/sw.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
self.addEventListener("push", function (event) {
|
||||
console.log(event);
|
||||
if (!event.data)
|
||||
return;
|
||||
|
||||
const payload = event.data.json();
|
||||
const { body, icon, image, badge, url, title } = payload;
|
||||
const notificationTitle = title ?? "No title";
|
||||
const notificationOptions = {
|
||||
body,
|
||||
icon,
|
||||
image,
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
badge,
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(notificationTitle, notificationOptions)
|
||||
.then(() => {
|
||||
console.log("Web push delivered");
|
||||
})
|
||||
);
|
||||
});
|
120
ui/push-notification.tsx
Normal file
120
ui/push-notification.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function notificationUnsupported() {
|
||||
return (
|
||||
!("serviceWorker" in navigator)
|
||||
|| !("PushManager" in window)
|
||||
|| !("showNotification" in ServiceWorkerRegistration.prototype)
|
||||
)
|
||||
}
|
||||
|
||||
async function registerAndSubscribe(
|
||||
vapidPublicKey: string,
|
||||
onSubscribe: (subs: PushSubscription | null ) => void,
|
||||
) {
|
||||
try {
|
||||
await navigator.serviceWorker.register("/sw.js");
|
||||
await subscribe(vapidPublicKey, onSubscribe);
|
||||
} catch (err) {
|
||||
console.error("Failed to register service worker:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe(
|
||||
vapidPublicKey: string,
|
||||
onSubscribe: (subs: PushSubscription | null) => void
|
||||
) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: vapidPublicKey,
|
||||
});
|
||||
console.log("Got subscription object", subscription.toJSON());
|
||||
await submitSubscription(subscription);
|
||||
onSubscribe(subscription);
|
||||
} catch (err) {
|
||||
console.error("Failed to subscribe:" , err);
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe(
|
||||
subscription: PushSubscription,
|
||||
onUnsubscribed: () => void,
|
||||
) {
|
||||
const body = JSON.stringify({ subscription });
|
||||
await subscription.unsubscribe();
|
||||
const res = await fetch("/api/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log("/api/unsubscribe returned", result);
|
||||
onUnsubscribed();
|
||||
}
|
||||
|
||||
async function submitSubscription(subscription: PushSubscription) {
|
||||
const res = await fetch("/api/subscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ subscription }),
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log("/api/subscribe returned", result);
|
||||
}
|
||||
|
||||
async function getSubscription(
|
||||
onSubscribe: (subs: PushSubscription | null) => void,
|
||||
) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
onSubscribe(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
export function PushNotification(props: { vapidPublicKey: string }) {
|
||||
const [unsupported, setUnsupported] = useState<boolean | undefined>(undefined);
|
||||
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
|
||||
useEffect(() => {
|
||||
const isUnsupported = notificationUnsupported();
|
||||
setUnsupported(isUnsupported);
|
||||
if (isUnsupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
getSubscription(setSubscription);
|
||||
}, [props.vapidPublicKey])
|
||||
|
||||
return <section>
|
||||
Notifications are: <b>{subscription ? "Enabled" : "Disabled"}</b>
|
||||
<br />
|
||||
<button
|
||||
disabled={unsupported}
|
||||
onClick={() => {
|
||||
if (!subscription)
|
||||
registerAndSubscribe(props.vapidPublicKey, setSubscription)
|
||||
else
|
||||
unsubscribe(subscription, () => setSubscription(null))
|
||||
}}
|
||||
>
|
||||
{unsupported === undefined && "Checking for support"}
|
||||
{unsupported === true && "Notifications are not supported :(."}
|
||||
{unsupported === false && subscription ? "Disable notifications" : "Enable notifications"}
|
||||
</button>
|
||||
<details>
|
||||
<summary>Debug</summary>
|
||||
<pre>
|
||||
<code>
|
||||
{JSON.stringify(subscription?.toJSON(), undefined, 4) ?? "No subscription set"}
|
||||
</code>
|
||||
</pre>
|
||||
</details>
|
||||
</section>;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue