clean up notification stuff
This commit is contained in:
parent
117a77525e
commit
68570ff3d4
@ -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;
|
||||||
await db.notificationSubscription.delete({ where: { endpoint } });
|
try {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
77
app/features/settings/components/settings/toggle.tsx
Normal file
77
app/features/settings/components/settings/toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -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 });
|
|
||||||
};
|
|
@ -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";
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user