clean up notification stuff

This commit is contained in:
m5r 2022-06-01 22:45:49 +02:00
parent 117a77525e
commit 68570ff3d4
7 changed files with 219 additions and 178 deletions

View File

@ -61,9 +61,10 @@ async function subscribe(request: Request) {
}); });
} catch (error: any) { } catch (error: any) {
if (error.code !== "P2002") { if (error.code !== "P2002") {
logger.error(error);
throw error; throw error;
} }
logger.warn(`Duplicate insertion of subscription with endpoint=${subscription.endpoint}`);
} }
return null; return null;
@ -80,7 +81,15 @@ async function unsubscribe(request: Request) {
} }
const endpoint = validation.data.subscriptionEndpoint; const endpoint = validation.data.subscriptionEndpoint;
try {
await db.notificationSubscription.delete({ where: { endpoint } }); await db.notificationSubscription.delete({ where: { endpoint } });
} catch (error: any) {
if (error.code !== "P2025") {
throw error;
}
logger.warn(`Could not delete subscription with endpoint=${endpoint} because it has already been deleted`);
}
return null; return null;
} }

View File

@ -16,11 +16,6 @@ const action: ActionFunction = async ({ params, request }) => {
const formData = Object.fromEntries(await request.formData()); const formData = Object.fromEntries(await request.formData());
const twilioClient = getTwilioClient(twilioAccount); const twilioClient = getTwilioClient(twilioAccount);
try { try {
console.log({
body: formData.content.toString(),
to: recipient,
from: phoneNumber!.number,
});
const message = await twilioClient.messages.create({ const message = await twilioClient.messages.create({
body: formData.content.toString(), body: formData.content.toString(),
to: recipient, to: recipient,

View File

@ -0,0 +1,84 @@
import Toggle from "~/features/settings/components/settings/toggle";
import useNotifications from "~/features/core/hooks/use-notifications.client";
import { useEffect, useState } from "react";
export default function NotificationsSettings() {
const { subscription, subscribe, unsubscribe } = useNotifications();
const [notificationsEnabled, setNotificationsEnabled] = useState(!!subscription);
const [errorMessage, setErrorMessage] = useState("");
const [isChanging, setIsChanging] = useState(false);
const onChange = async (checked: boolean) => {
if (isChanging) {
return;
}
setIsChanging(true);
setNotificationsEnabled(checked);
setErrorMessage("");
try {
if (checked) {
await subscribe();
} else {
await unsubscribe();
}
} catch (error: any) {
console.error(error);
setNotificationsEnabled(!checked);
if (!checked) {
unsubscribe().catch((error) => console.error(error));
}
switch (error.name) {
case "NotAllowedError":
setErrorMessage(
"Your browser is not allowing Shellphone to register push notifications for you. Please allow Shellphone's notifications in your browser's settings if you wish to receive them.",
);
break;
case "TypeError":
setErrorMessage("Your browser does not support push notifications yet.");
break;
}
} finally {
setIsChanging(false);
}
};
useEffect(() => {
setNotificationsEnabled(!!subscription);
}, [subscription]);
return (
<ul className="mt-2 divide-y divide-gray-200">
<Toggle
as="li"
checked={notificationsEnabled}
description="Get notified on this device when you receive a message or a phone call"
onChange={onChange}
title="Enable notifications"
error={
errorMessage
? {
title: "Browser error",
message: errorMessage,
}
: null
}
/>
</ul>
);
}
export function FallbackNotificationsSettings() {
return (
<ul className="mt-2 divide-y divide-gray-200">
<Toggle
as="li"
checked={false}
description="Get notified on this device when you receive a message or a phone call"
onChange={() => void 0}
title="Enable notifications"
error={null}
isLoading
/>
</ul>
);
}

View File

@ -0,0 +1,77 @@
import type { ElementType } from "react";
import { Switch } from "@headlessui/react";
import clsx from "clsx";
import Alert from "~/features/core/components/alert";
type Props = {
as?: ElementType;
checked: boolean;
description?: string;
onChange(checked: boolean): void;
title: string;
error: null | {
title: string;
message: string;
};
isLoading?: true;
};
export default function Toggle({ as, checked, description, onChange, title, error, isLoading }: Props) {
return (
<Switch.Group as={as} className="py-4 space-y-2 flex flex-col items-center justify-between">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col">
<Switch.Label as="p" className="text-sm font-medium text-gray-900" passive>
{title}
</Switch.Label>
{description && (
<Switch.Description as="span" className="text-sm text-gray-500">
{description}
</Switch.Description>
)}
</div>
{isLoading ? (
<div className="w-11 ml-4 flex-shrink-0">
<Loader />
</div>
) : (
<Switch
checked={checked}
onChange={onChange}
className={clsx(
checked ? "bg-primary-500" : "bg-gray-200",
"ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500",
)}
>
<span
aria-hidden="true"
className={clsx(
checked ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200",
)}
/>
</Switch>
)}
</div>
{error !== null && <Alert title={error.title} message={error.message} variant="error" />}
</Switch.Group>
);
}
function Loader() {
return (
<svg
className="animate-spin h-5 w-5 text-primary-700"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@ -1,17 +1,31 @@
import { type ElementType, useEffect, useState } from "react";
import type { ActionFunction } from "@remix-run/node"; import type { ActionFunction } from "@remix-run/node";
import { ClientOnly } from "remix-utils"; import { ClientOnly } from "remix-utils";
import { Switch } from "@headlessui/react";
import clsx from "clsx";
import useNotifications from "~/features/core/hooks/use-notifications.client";
import Alert from "~/features/core/components/alert";
import { Form } from "@remix-run/react"; import { Form } from "@remix-run/react";
import { notify } from "~/utils/web-push.server"; import { notify } from "~/utils/web-push.server";
import Button from "~/features/settings/components/button"; import Button from "~/features/settings/components/button";
import NotificationsSettings, {
FallbackNotificationsSettings,
} from "~/features/settings/components/settings/notifications-settings";
import db from "~/utils/db.server";
export const action: ActionFunction = async () => { export const action: ActionFunction = async () => {
await notify("PN4f11f0c4155dfb5d5ac8bbab2cc23cbc", { const phoneNumber = await db.phoneNumber.findUnique({
where: { id: "PN4f11f0c4155dfb5d5ac8bbab2cc23cbc" },
select: {
organization: {
select: {
memberships: {
select: { notificationSubscription: true },
},
},
},
},
});
const subscriptions = phoneNumber!.organization.memberships.flatMap(
(membership) => membership.notificationSubscription,
);
await notify(subscriptions, {
title: "+33 6 13 37 07 87", title: "+33 6 13 37 07 87",
body: "wesh le zin, wesh la zine, copain copine mais si y'a moyen on pine", body: "wesh le zin, wesh la zine, copain copine mais si y'a moyen on pine",
actions: [ actions: [
@ -25,134 +39,21 @@ export const action: ActionFunction = async () => {
return null; return null;
}; };
export default function NotificationsPage() { export default function Notifications() {
return <ClientOnly fallback={<Loader />}>{() => <Notifications />}</ClientOnly>;
}
function Notifications() {
const { subscription, subscribe, unsubscribe } = useNotifications();
const [notificationsEnabled, setNotificationsEnabled] = useState(!!subscription);
const [errorMessage, setErrorMessage] = useState("");
const [isChanging, setIsChanging] = useState(false);
const onChange = async (checked: boolean) => {
if (isChanging) {
return;
}
setIsChanging(true);
setNotificationsEnabled(checked);
setErrorMessage("");
try {
if (checked) {
await subscribe();
} else {
await unsubscribe();
}
} catch (error: any) {
console.error(error);
setNotificationsEnabled(!checked);
switch (error.name) {
case "NotAllowedError":
setErrorMessage(
"Your browser is not allowing Shellphone to register push notifications for you. Please allow Shellphone's notifications in your browser's settings if you wish to receive them.",
);
break;
case "TypeError":
setErrorMessage("Your browser does not support push notifications yet.");
break;
}
} finally {
setIsChanging(false);
}
};
useEffect(() => {
setNotificationsEnabled(!!subscription);
}, [subscription]);
return ( return (
<section className="pt-6 divide-y divide-gray-200"> <section className="pt-6 divide-y divide-gray-200">
<div className="px-4 sm:px-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">Notifications</h2>
<ClientOnly fallback={<FallbackNotificationsSettings />}>{() => <NotificationsSettings />}</ClientOnly>
</div>
<section> <section>
<Form method="post"> <Form method="post" action="/settings/notifications">
<Button variant="default" type="submit"> <Button variant="default" type="submit">
send it!!! send it!!!
</Button> </Button>
</Form> </Form>
</section> </section>
<div className="px-4 sm:px-6">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Notifications</h2>
</div>
<ul className="mt-2 divide-y divide-gray-200">
<Toggle
as="li"
checked={notificationsEnabled}
description="Get notified on this device when you receive a message or a phone call"
onChange={onChange}
title="Enable notifications"
/>
</ul>
{errorMessage !== "" && <Alert title="Browser error" message={errorMessage} variant="error" />}
</div>
</section> </section>
); );
} }
type ToggleProps = {
as?: ElementType;
checked: boolean;
description?: string;
onChange(checked: boolean): void;
title: string;
};
function Toggle({ as, checked, description, onChange, title }: ToggleProps) {
return (
<Switch.Group as={as} className="py-4 flex items-center justify-between">
<div className="flex flex-col">
<Switch.Label as="p" className="text-sm font-medium text-gray-900" passive>
{title}
</Switch.Label>
{description && (
<Switch.Description as="span" className="text-sm text-gray-500">
{description}
</Switch.Description>
)}
</div>
<Switch
checked={checked}
onChange={onChange}
className={clsx(
checked ? "bg-primary-500" : "bg-gray-200",
"ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500",
)}
>
<span
aria-hidden="true"
className={clsx(
checked ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200",
)}
/>
</Switch>
</Switch.Group>
);
}
function Loader() {
return (
<svg
className="animate-spin mx-auto h-5 w-5 text-primary-700"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@ -1,20 +0,0 @@
import type { LoaderFunction } from "@remix-run/node";
const manifest = `CACHE MANIFEST
# Version 1.0000
NETWORK:
*
`;
export const loader: LoaderFunction = async () => {
const headers = new Headers({
"Content-Type": "text/cache-manifest",
"Cache-Control": "max-age=0, no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "Thu, 01 Jan 1970 00:00:01 GMT",
});
return new Response(manifest, { headers });
};

View File

@ -1,37 +1,24 @@
import webpush, { type PushSubscription, WebPushError } from "web-push"; import webpush, { type PushSubscription, WebPushError } from "web-push";
import type { NotificationSubscription } from "@prisma/client";
import serverConfig from "~/config/config.server"; import serverConfig from "~/config/config.server";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
import logger from "~/utils/logger.server"; import logger from "~/utils/logger.server";
export type NotificationPayload = NotificationOptions & { export type NotificationPayload = NotificationOptions & {
title: string; title: string; // max 50 characters
body: string; body: string; // max 150 characters
}; };
export async function notify(phoneNumberId: string, payload: NotificationPayload) { export async function notify(subscriptions: NotificationSubscription[], payload: NotificationPayload) {
webpush.setVapidDetails("mailto:mokht@rmi.al", serverConfig.webPush.publicKey, serverConfig.webPush.privateKey); webpush.setVapidDetails("mailto:mokht@rmi.al", serverConfig.webPush.publicKey, serverConfig.webPush.privateKey);
const title = truncate(payload.title, 50);
const phoneNumber = await db.phoneNumber.findUnique({ const body = truncate(payload.body, 150);
where: { id: phoneNumberId }, const _payload = JSON.stringify({
select: { ...payload,
organization: { title,
select: { body,
memberships: {
select: { notificationSubscription: true },
},
},
},
},
}); });
if (!phoneNumber) {
// TODO
return;
}
const subscriptions = phoneNumber.organization.memberships.flatMap(
(membership) => membership.notificationSubscription,
);
await Promise.all( await Promise.all(
subscriptions.map(async (subscription) => { subscriptions.map(async (subscription) => {
@ -44,7 +31,7 @@ export async function notify(phoneNumberId: string, payload: NotificationPayload
}; };
try { try {
await webpush.sendNotification(webPushSubscription, JSON.stringify(payload)); await webpush.sendNotification(webPushSubscription, _payload);
} catch (error: any) { } catch (error: any) {
logger.error(error); logger.error(error);
if (error instanceof WebPushError) { if (error instanceof WebPushError) {
@ -55,3 +42,11 @@ export async function notify(phoneNumberId: string, payload: NotificationPayload
}), }),
); );
} }
function truncate(str: string, maxLength: number) {
if (str.length <= maxLength) {
return str;
}
return str.substring(0, maxLength - 1) + "\u2026";
}