From 4c22ee83e15515f56b001c556c0c9bf3611fa0ea Mon Sep 17 00:00:00 2001 From: m5r Date: Mon, 30 May 2022 02:21:42 +0200 Subject: [PATCH] push notifications but installable to home screen yet --- .env.example | 4 + .gitignore | 1 + app/config/config.server.ts | 12 + app/entry.client.tsx | 11 + app/entry.worker.ts | 47 +++ .../actions/notifications-subscription.ts | 104 +++++ app/features/core/components/footer.css | 4 + app/features/core/components/footer.tsx | 2 +- .../core/hooks/use-app-loader-data.ts | 13 + .../core/hooks/use-notifications.client.ts | 90 ++++ app/features/core/hooks/use-session.ts | 12 +- .../messages/actions/messages.$recipient.tsx | 1 + app/root.tsx | 54 +-- app/routes/__app.tsx | 24 +- .../__app/notifications-subscription.ts | 3 + app/routes/__app/settings.tsx | 4 +- app/routes/__app/settings/billing.tsx | 4 +- app/routes/__app/settings/notifications.tsx | 155 ++++++- app/routes/__app/settings/phone.tsx | 4 +- app/routes/__app/settings/support.tsx | 9 + app/routes/cache[.]manifest.ts | 20 + app/styles/app.css | 10 + app/utils/auth.server.ts | 4 +- app/utils/pwa.client.ts | 70 ++++ app/utils/seo.ts | 2 +- app/utils/twilio.server.ts | 2 +- app/utils/web-push.server.ts | 57 +++ package-lock.json | 387 ++++++++++++++++++ package.json | 8 +- .../20220517184134_init/migration.sql | 21 +- prisma/schema.prisma | 30 +- public/browserconfig.xml | 8 - public/{ => icons}/android-chrome-192x192.png | Bin public/{ => icons}/android-chrome-384x384.png | Bin public/{ => icons}/apple-touch-icon.png | Bin public/{ => icons}/favicon-16x16.png | Bin public/{ => icons}/favicon-32x32.png | Bin public/{ => icons}/favicon.ico | Bin public/{ => icons}/safari-pinned-tab.svg | 0 public/manifest.webmanifest | 10 +- 40 files changed, 1104 insertions(+), 83 deletions(-) create mode 100644 app/entry.worker.ts create mode 100644 app/features/core/actions/notifications-subscription.ts create mode 100644 app/features/core/components/footer.css create mode 100644 app/features/core/hooks/use-app-loader-data.ts create mode 100644 app/features/core/hooks/use-notifications.client.ts create mode 100644 app/routes/__app/notifications-subscription.ts create mode 100644 app/routes/__app/settings/support.tsx create mode 100644 app/routes/cache[.]manifest.ts create mode 100644 app/styles/app.css create mode 100644 app/utils/pwa.client.ts create mode 100644 app/utils/web-push.server.ts delete mode 100644 public/browserconfig.xml rename public/{ => icons}/android-chrome-192x192.png (100%) rename public/{ => icons}/android-chrome-384x384.png (100%) rename public/{ => icons}/apple-touch-icon.png (100%) rename public/{ => icons}/favicon-16x16.png (100%) rename public/{ => icons}/favicon-32x32.png (100%) rename public/{ => icons}/favicon.ico (100%) rename public/{ => icons}/safari-pinned-tab.svg (100%) diff --git a/.env.example b/.env.example index 6e7b983..fea866d 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,7 @@ PADDLE_VENDOR_ID=TODO PADDLE_API_KEY=TODO TWILIO_AUTH_TOKEN=TODO + +# npx web-push generate-vapid-keys +WEB_PUSH_VAPID_PUBLIC_KEY=TODO +WEB_PUSH_VAPID_PRIVATE_KEY=TODO diff --git a/.gitignore b/.gitignore index fff689d..08ee14b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules /.cache /server/build /public/build +/public/entry.worker.js /build server.js /app/styles/tailwind.css diff --git a/app/config/config.server.ts b/app/config/config.server.ts index 11d1a15..f47526f 100644 --- a/app/config/config.server.ts +++ b/app/config/config.server.ts @@ -28,6 +28,14 @@ invariant( typeof process.env.MASTER_ENCRYPTION_KEY === "string", `Please define the "MASTER_ENCRYPTION_KEY" environment variable`, ); +invariant( + typeof process.env.WEB_PUSH_VAPID_PRIVATE_KEY === "string", + `Please define the "WEB_PUSH_VAPID_PRIVATE_KEY" environment variable`, +); +invariant( + typeof process.env.WEB_PUSH_VAPID_PUBLIC_KEY === "string", + `Please define the "WEB_PUSH_VAPID_PUBLIC_KEY" environment variable`, +); export default { app: { @@ -49,4 +57,8 @@ export default { twilio: { authToken: process.env.TWILIO_AUTH_TOKEN, }, + webPush: { + privateKey: process.env.WEB_PUSH_VAPID_PRIVATE_KEY, + publicKey: process.env.WEB_PUSH_VAPID_PUBLIC_KEY, + }, }; diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 57dba48..bc75b68 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -2,3 +2,14 @@ import { hydrate } from "react-dom"; import { RemixBrowser } from "@remix-run/react"; hydrate(, document); + +if ("serviceWorker" in navigator) { + window.addEventListener("load", async () => { + try { + await navigator.serviceWorker.register("/entry.worker.js"); + await navigator.serviceWorker.ready; + } catch (error) { + console.error("Service worker registration failed", error, (error as Error).name); + } + }); +} diff --git a/app/entry.worker.ts b/app/entry.worker.ts new file mode 100644 index 0000000..1f462b7 --- /dev/null +++ b/app/entry.worker.ts @@ -0,0 +1,47 @@ +/// + +import type { NotificationPayload } from "~/utils/web-push.server"; +import { addBadge, removeBadge } from "~/utils/pwa.client"; + +declare let self: ServiceWorkerGlobalScope; + +const defaultOptions: NotificationOptions = { + icon: "/icons/android-chrome-192x192.png", + badge: "/icons/android-chrome-48x48.png", + dir: "auto", + image: undefined, + silent: false, +}; + +self.addEventListener("push", (event) => { + const { title, ...payload }: NotificationPayload = JSON.parse(event?.data!.text()); + const options = Object.assign({}, defaultOptions, payload); + event.waitUntil(async () => { + await self.registration.showNotification(title, options); + await addBadge(1); + }); +}); + +self.addEventListener("notificationclick", (event) => { + event.waitUntil( + (async () => { + console.log("On notification click: ", event.notification.tag); + // Android doesn’t close the notification when you click on it + // See: http://crbug.com/463146 + event.notification.close(); + await removeBadge(); + + if (event.action === "reply") { + const recipient = encodeURIComponent(event.notification.data.recipient); + return self.clients.openWindow?.(`/messages/${recipient}`); + } + + if (event.action === "answer") { + const recipient = encodeURIComponent(event.notification.data.recipient); + return self.clients.openWindow?.(`/incoming-call/${recipient}`); + } + + return self.clients.openWindow?.("/"); + })(), + ); +}); diff --git a/app/features/core/actions/notifications-subscription.ts b/app/features/core/actions/notifications-subscription.ts new file mode 100644 index 0000000..1dce1b0 --- /dev/null +++ b/app/features/core/actions/notifications-subscription.ts @@ -0,0 +1,104 @@ +import { type ActionFunction } from "@remix-run/node"; +import { badRequest, notFound } from "remix-utils"; +import { z } from "zod"; + +import db from "~/utils/db.server"; +import logger from "~/utils/logger.server"; +import { validate } from "~/utils/validation.server"; +import { requireLoggedIn } from "~/utils/auth.server"; + +const action: ActionFunction = async ({ request }) => { + const formData = await request.clone().formData(); + const action = formData.get("_action"); + if (!action) { + const errorMessage = "POST /notifications-subscription without any _action"; + logger.error(errorMessage); + return badRequest({ errorMessage }); + } + + switch (action as Action) { + case "subscribe": + return subscribe(request); + case "unsubscribe": + return unsubscribe(request); + default: + const errorMessage = `POST /notifications-subscription with an invalid _action=${action}`; + logger.error(errorMessage); + return badRequest({ errorMessage }); + } +}; + +export default action; + +async function subscribe(request: Request) { + const { organization } = await requireLoggedIn(request); + const formData = await request.formData(); + const body = { + subscription: JSON.parse(formData.get("subscription")?.toString() ?? "{}"), + }; + const validation = validate(validations.subscribe, body); + if (validation.errors) { + return badRequest(validation.errors); + } + + const { subscription } = validation.data; + const membership = await db.membership.findFirst({ + where: { id: organization.membershipId }, + }); + if (!membership) { + return notFound("Phone number not found"); + } + + try { + await db.notificationSubscription.create({ + data: { + membershipId: membership.id, + endpoint: subscription.endpoint, + expirationTime: subscription.expirationTime, + keys_p256dh: subscription.keys.p256dh, + keys_auth: subscription.keys.auth, + }, + }); + } catch (error: any) { + if (error.code !== "P2002") { + logger.error(error); + throw error; + } + } + + return null; +} + +async function unsubscribe(request: Request) { + const formData = await request.formData(); + const body = { + subscriptionEndpoint: formData.get("subscriptionEndpoint"), + }; + const validation = validate(validations.unsubscribe, body); + if (validation.errors) { + return badRequest(validation.errors); + } + + const endpoint = validation.data.subscriptionEndpoint; + await db.notificationSubscription.delete({ where: { endpoint } }); + + return null; +} + +type Action = "subscribe" | "unsubscribe"; + +const validations = { + subscribe: z.object({ + subscription: z.object({ + endpoint: z.string(), + expirationTime: z.number().nullable(), + keys: z.object({ + p256dh: z.string(), + auth: z.string(), + }), + }), + }), + unsubscribe: z.object({ + subscriptionEndpoint: z.string(), + }), +} as const; diff --git a/app/features/core/components/footer.css b/app/features/core/components/footer.css new file mode 100644 index 0000000..993f4a9 --- /dev/null +++ b/app/features/core/components/footer.css @@ -0,0 +1,4 @@ +.footer-ios { + margin-bottom: var(--safe-area-bottom); + padding-bottom: var(--safe-area-bottom); +} diff --git a/app/features/core/components/footer.tsx b/app/features/core/components/footer.tsx index 44e52b9..f6fcf3f 100644 --- a/app/features/core/components/footer.tsx +++ b/app/features/core/components/footer.tsx @@ -6,7 +6,7 @@ import clsx from "clsx"; export default function Footer() { return (