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
|
node_modules
|
||||||
.next/
|
.next/
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
#env*
|
||||||
|
push-subscriptions.json
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -39,3 +39,6 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --chown=nextjs:nodejs schedule.json .
|
COPY --chown=nextjs:nodejs schedule.json .
|
||||||
|
RUN echo '[]' > push-subscriptions.json && chown nextjs:nodejs push-subscriptions.json
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,63 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
import webPush from "web-push";
|
||||||
import { Schedule } from "@/app/schedule/types";
|
import { Schedule } from "@/app/schedule/types";
|
||||||
import { readFile, writeFile } from "fs/promises";
|
import { readFile, writeFile } from "fs/promises";
|
||||||
import { broadcastUpdate } from "./streams";
|
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) {
|
export async function createEvent(formData: FormData) {
|
||||||
const schedule: Schedule = JSON.parse(await readFile("schedule.json", "utf-8"));
|
const schedule: Schedule = JSON.parse(await readFile("schedule.json", "utf-8"));
|
||||||
const id = formData.get("id") as string;
|
const id = formData.get("id") as string;
|
||||||
|
@ -26,6 +81,7 @@ export async function createEvent(formData: FormData) {
|
||||||
});
|
});
|
||||||
broadcastUpdate(schedule);
|
broadcastUpdate(schedule);
|
||||||
await writeFile("schedule.json", JSON.stringify(schedule, null, "\t"), "utf-8");
|
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) {
|
export async function modifyEvent(formData: FormData) {
|
||||||
|
@ -40,6 +96,7 @@ export async function modifyEvent(formData: FormData) {
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
throw Error("No such event");
|
throw Error("No such event");
|
||||||
}
|
}
|
||||||
|
const timeChanged = schedule.events[index].slots[0].start !== start + "Z";
|
||||||
schedule.events[index] = {
|
schedule.events[index] = {
|
||||||
name,
|
name,
|
||||||
id,
|
id,
|
||||||
|
@ -55,6 +112,8 @@ export async function modifyEvent(formData: FormData) {
|
||||||
};
|
};
|
||||||
broadcastUpdate(schedule);
|
broadcastUpdate(schedule);
|
||||||
await writeFile("schedule.json", JSON.stringify(schedule, null, "\t"), "utf-8");
|
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) {
|
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;
|
margin-inline-start: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p + p {
|
||||||
|
margin-block-start: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
label + label {
|
label + label {
|
||||||
margin-block-start: 0.5rem;
|
margin-block-start: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { ScheduleProvider } from "./context"
|
||||||
import { Events } from "@/ui/events";
|
import { Events } from "@/ui/events";
|
||||||
import { Locations } from "@/ui/locations";
|
import { Locations } from "@/ui/locations";
|
||||||
import { EventsEdit } from "@/ui/events-edit";
|
import { EventsEdit } from "@/ui/events-edit";
|
||||||
|
import { PushNotification } from "@/ui/push-notification";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
@ -17,6 +18,10 @@ export default async function page() {
|
||||||
<p>
|
<p>
|
||||||
Study carefully, we only hold these events once a year.
|
Study carefully, we only hold these events once a year.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Get notified about updates
|
||||||
|
</p>
|
||||||
|
<PushNotification vapidPublicKey={process.env.VAPID_PUBLIC_KEY!} />
|
||||||
<h2>Schedule</h2>
|
<h2>Schedule</h2>
|
||||||
<Timetable />
|
<Timetable />
|
||||||
<EventsEdit />
|
<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": {
|
"dependencies": {
|
||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"csstype": "^3.1.3",
|
"csstype": "^3.1.3",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
|
|
120
pnpm-lock.yaml
generated
120
pnpm-lock.yaml
generated
|
@ -14,6 +14,9 @@ dependencies:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.0.0(react@19.0.0)
|
version: 19.0.0(react@19.0.0)
|
||||||
|
web-push:
|
||||||
|
specifier: ^3.6.7
|
||||||
|
version: 3.6.7
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
|
@ -25,6 +28,9 @@ devDependencies:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
version: 19.0.0
|
version: 19.0.0
|
||||||
|
'@types/web-push':
|
||||||
|
specifier: ^3.6.4
|
||||||
|
version: 3.6.4
|
||||||
csstype:
|
csstype:
|
||||||
specifier: ^3.1.3
|
specifier: ^3.1.3
|
||||||
version: 3.1.3
|
version: 3.1.3
|
||||||
|
@ -324,6 +330,34 @@ packages:
|
||||||
csstype: 3.1.3
|
csstype: 3.1.3
|
||||||
dev: true
|
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:
|
/busboy@1.6.0:
|
||||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||||
engines: {node: '>=10.16.0'}
|
engines: {node: '>=10.16.0'}
|
||||||
|
@ -377,6 +411,18 @@ packages:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
dev: true
|
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:
|
/detect-libc@2.0.3:
|
||||||
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
@ -384,12 +430,64 @@ packages:
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
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:
|
/is-arrayish@0.3.2:
|
||||||
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
|
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
|
||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
dev: false
|
dev: false
|
||||||
optional: true
|
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:
|
/nanoid@3.3.8:
|
||||||
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
@ -468,6 +566,14 @@ packages:
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: false
|
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:
|
/scheduler@0.25.0:
|
||||||
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
|
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -555,3 +661,17 @@ packages:
|
||||||
engines: {node: '>=12.20'}
|
engines: {node: '>=12.20'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dev: 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