push notifications but installable to home screen yet

This commit is contained in:
m5r
2022-05-30 02:21:42 +02:00
parent b5bb8e1822
commit 4c22ee83e1
40 changed files with 1104 additions and 83 deletions

View File

@ -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;

View File

@ -0,0 +1,4 @@
.footer-ios {
margin-bottom: var(--safe-area-bottom);
padding-bottom: var(--safe-area-bottom);
}

View File

@ -6,7 +6,7 @@ import clsx from "clsx";
export default function Footer() {
return (
<footer
className="grid grid-cols-4 bg-[#F7F7F7] h-16 border-t border-gray-400 border-opacity-25 py-2 z-10"
className="footer-ios grid grid-cols-4 bg-[#F7F7F7] h-16 border-t border-gray-400 border-opacity-25 py-2 z-10"
// className="grid grid-cols-4 border-t border-gray-400 border-opacity-25 py-3 z-10 backdrop-blur"
>
<FooterLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />

View File

@ -0,0 +1,13 @@
import { useMatches } from "@remix-run/react";
import type { AppLoaderData } from "~/routes/__app";
export default function useAppLoaderData() {
const matches = useMatches();
const __appRoute = matches.find((match) => match.id === "routes/__app");
if (!__appRoute) {
throw new Error("useSession hook called outside _app route");
}
return __appRoute.data as AppLoaderData;
}

View File

@ -0,0 +1,90 @@
import { useEffect, useMemo, useState } from "react";
import { useFetcher } from "@remix-run/react";
import useAppLoaderData from "~/features/core/hooks/use-app-loader-data";
export default function useNotifications() {
const isServiceWorkerSupported = useMemo(() => "serviceWorker" in navigator, []);
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
const { webPushPublicKey } = useAppLoaderData().config;
const fetcher = useFetcher();
const subscribeToNotifications = (subscription: PushSubscriptionJSON) => {
fetcher.submit(
{
_action: "subscribe",
subscription: JSON.stringify(subscription),
},
{ method: "post", action: "/notifications-subscription" },
);
};
const unsubscribeFromNotifications = (subscriptionEndpoint: PushSubscription["endpoint"]) => {
fetcher.submit(
{
_action: "unsubscribe",
subscriptionEndpoint,
},
{ method: "post", action: "/notifications-subscription" },
);
};
useEffect(() => {
(async () => {
if (!isServiceWorkerSupported) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setSubscription(subscription);
})();
}, [isServiceWorkerSupported]);
async function subscribe() {
if (!isServiceWorkerSupported || subscription !== null || fetcher.state !== "idle") {
return;
}
const registration = await navigator.serviceWorker.ready;
const newSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(webPushPublicKey),
});
setSubscription(newSubscription);
subscribeToNotifications(newSubscription.toJSON());
}
async function unsubscribe() {
if (!isServiceWorkerSupported || !subscription || fetcher.state !== "idle") {
return;
}
subscription
.unsubscribe()
.then(() => {
console.log("Unsubscribed from notifications");
setSubscription(null);
})
.catch((error) => console.error("Failed to unsubscribe from notifications", error));
unsubscribeFromNotifications(subscription.endpoint);
}
return {
isServiceWorkerSupported,
subscription,
subscribe,
unsubscribe,
};
}
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@ -1,13 +1,5 @@
import { useMatches } from "@remix-run/react";
import type { SessionData } from "~/utils/auth.server";
import useAppLoaderData from "~/features/core/hooks/use-app-loader-data";
export default function useSession() {
const matches = useMatches();
const __appRoute = matches.find((match) => match.id === "routes/__app");
if (!__appRoute) {
throw new Error("useSession hook called outside _app route");
}
return __appRoute.data as SessionData;
return useAppLoaderData().sessionData;
}