notify of incoming messages and show in app notifications

This commit is contained in:
m5r 2022-06-12 23:17:22 +02:00
parent a77f02189e
commit ceaadc4f99
7 changed files with 268 additions and 44 deletions

View File

@ -0,0 +1,94 @@
import { Fragment, useEffect, useState } from "react";
import { useNavigate } from "@remix-run/react";
import { Transition } from "@headlessui/react";
import { useAtom } from "jotai";
import useNotifications, { notificationDataAtom } from "~/features/core/hooks/use-notifications";
export default function Notification() {
useNotifications();
const navigate = useNavigate();
const [notificationData] = useAtom(notificationDataAtom);
const [show, setShow] = useState(notificationData !== null);
const close = () => setShow(false);
const actions = buildActions();
useEffect(() => {
setShow(notificationData !== null);
}, [notificationData]);
return (
<div aria-live="assertive" className="fixed inset-0 flex items-start px-4 py-6 pointer-events-none sm:p-6">
<div className="w-full flex flex-col items-center space-y-4 sm:items-end">
{/* Notification panel, dynamically insert this into the live region when it needs to be displayed */}
<Transition
show={show}
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="max-w-md w-full bg-white shadow-lg rounded-lg pointer-events-auto flex ring-1 ring-black ring-opacity-5 divide-x divide-gray-200">
<div className="w-0 flex-1 flex items-center p-4">
<div className="w-full">
<p className="text-sm font-medium text-gray-900">{notificationData?.data.recipient}</p>
<p className="mt-1 text-sm text-gray-500">{notificationData?.body}</p>
</div>
</div>
<div className="flex">
<div className="flex flex-col divide-y divide-gray-200">
<div className="h-0 flex-1 flex">
<button
type="button"
className="w-full border border-transparent rounded-none rounded-tr-lg px-4 py-3 flex items-center justify-center text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:z-10 focus:ring-2 focus:ring-indigo-500"
onClick={actions[0].onClick}
>
{actions[0].title}
</button>
</div>
<div className="h-0 flex-1 flex">
<button
type="button"
className="w-full border border-transparent rounded-none rounded-br-lg px-4 py-3 flex items-center justify-center text-sm font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={actions[1].onClick}
>
{actions[1].title}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
);
function buildActions() {
if (!notificationData) {
return [
{ title: "", onClick: close },
{ title: "", onClick: close },
];
}
return {
message: [
{
title: "Reply",
onClick: () => {
navigate(`/messages/${encodeURIComponent(notificationData.data.recipient)}`);
close();
},
},
{ title: "Close", onClick: close },
],
call: [
{ title: "Answer", onClick: close },
{ title: "Decline", onClick: close },
],
}[notificationData.data.type];
}
}

View File

@ -0,0 +1,34 @@
import { useEffect } from "react";
import { atom, useAtom } from "jotai";
import type { NotificationPayload } from "~/utils/web-push.server";
export default function useNotifications() {
const [notificationData, setNotificationData] = useAtom(notificationDataAtom);
useEffect(() => {
const channel = new BroadcastChannel("notifications");
async function eventHandler(event: MessageEvent) {
const payload: NotificationPayload = JSON.parse(event.data);
setNotificationData(payload);
}
channel.addEventListener("message", eventHandler);
return () => {
channel.removeEventListener("message", eventHandler);
channel.close();
};
}, []);
useEffect(() => {
if (!notificationData) {
return;
}
const timeout = setTimeout(() => setNotificationData(null), 5000);
return () => clearTimeout(timeout);
}, [notificationData]);
}
export const notificationDataAtom = atom<NotificationPayload | null>(null);

View File

@ -0,0 +1,43 @@
import { Queue } from "~/utils/queue.server";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
import { buildMessageNotificationPayload, notify } from "~/utils/web-push.server";
import getTwilioClient from "~/utils/twilio.server";
type Payload = {
messageSid: string;
phoneNumberId: string;
};
export default Queue<Payload>("notify incoming message", async ({ data }) => {
const { messageSid, phoneNumberId } = data;
const phoneNumber = await db.phoneNumber.findUnique({
where: { id: phoneNumberId },
select: {
twilioAccount: {
include: {
organization: {
select: {
memberships: {
select: { notificationSubscription: true },
},
},
},
},
},
},
});
if (!phoneNumber) {
logger.warn(`No phone number found with id=${phoneNumberId}`);
return;
}
const subscriptions = phoneNumber.twilioAccount.organization.memberships.flatMap(
(membership) => membership.notificationSubscription,
);
const twilioClient = getTwilioClient(phoneNumber.twilioAccount);
const message = await twilioClient.messages.get(messageSid).fetch();
const payload = buildMessageNotificationPayload(message);
await notify(subscriptions, payload);
});

View File

@ -5,6 +5,7 @@ import serverConfig from "~/config/config.server";
import { type SessionData, requireLoggedIn } from "~/utils/auth.server";
import Footer from "~/features/core/components/footer";
import ServiceWorkerUpdateNotifier from "~/features/core/components/service-worker-update-notifier";
import Notification from "~/features/core/components/notification";
import useServiceWorkerRevalidate from "~/features/core/hooks/use-service-worker-revalidate";
import footerStyles from "~/features/core/components/footer.css";
import appStyles from "~/styles/app.css";
@ -36,6 +37,7 @@ export default function __App() {
const hideFooter = matches.some((match) => match.handle?.hideFooter === true);
return (
<>
<div className="h-full w-full overflow-hidden fixed bg-gray-100">
<div className="flex flex-col w-full h-full">
<ServiceWorkerUpdateNotifier />
@ -47,6 +49,8 @@ export default function __App() {
{hideFooter ? null : <Footer />}
</div>
</div>
<Notification />
</>
);
}

View File

@ -1,13 +1,16 @@
import { type ActionFunction } from "@remix-run/node";
import { badRequest, html, notFound, serverError } from "remix-utils";
import twilio from "twilio";
import { z } from "zod";
import { Prisma, SubscriptionStatus } from "@prisma/client";
import insertIncomingMessageQueue from "~/queues/insert-incoming-message.server";
import notifyIncomingMessageQueue from "~/queues/notify-incoming-message.server";
import logger from "~/utils/logger.server";
import db from "~/utils/db.server";
import twilio from "twilio";
import { smsUrl } from "~/utils/twilio.server";
import { decrypt } from "~/utils/encryption";
import { validate } from "~/utils/validation.server";
export const action: ActionFunction = async ({ request }) => {
const twilioSignature = request.headers.get("X-Twilio-Signature") || request.headers.get("x-twilio-signature");
@ -15,7 +18,14 @@ export const action: ActionFunction = async ({ request }) => {
return badRequest("Invalid header X-Twilio-Signature");
}
const body: Body = Object.fromEntries(await request.formData()) as any;
const formData = Object.fromEntries(await request.formData());
const validation = validate(bodySchema, formData);
if (validation.errors) {
logger.error(validation.errors);
return badRequest("");
}
const body = validation.data;
try {
const phoneNumbers = await db.phoneNumber.findMany({
where: { number: body.To },
@ -47,7 +57,7 @@ export const action: ActionFunction = async ({ request }) => {
return notFound("Phone number not found");
}
const phoneNumbersWithActiveSub = phoneNumbers.filter(
/*const phoneNumbersWithActiveSub = phoneNumbers.filter(
(phoneNumber) => phoneNumber.twilioAccount.organization.subscriptions.length > 0,
);
if (phoneNumbersWithActiveSub.length === 0) {
@ -55,7 +65,7 @@ export const action: ActionFunction = async ({ request }) => {
// because the organization is on the free plan
console.log("no active subscription"); // TODO: uncomment the line below -- beware: refresh phone numbers refetch those missed messages lol
// return html("<Response></Response>");
}
}*/
const phoneNumber = phoneNumbers.find((phoneNumber) => {
// TODO: uncomment the line below
@ -65,7 +75,7 @@ export const action: ActionFunction = async ({ request }) => {
// maybe we shouldn't let that happen by restricting a phone number to one org?
const encryptedAuthToken = phoneNumber.twilioAccount.authToken;
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
return twilio.validateRequest(authToken, twilioSignature, smsUrl, body);
return twilio.validateRequest(authToken, twilioSignature, smsUrl, formData);
});
if (!phoneNumber) {
return badRequest("Invalid webhook");
@ -73,10 +83,16 @@ export const action: ActionFunction = async ({ request }) => {
const messageSid = body.MessageSid;
const phoneNumberId = phoneNumber.id;
await insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, {
await Promise.all([
insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, {
messageSid,
phoneNumberId,
});
}),
notifyIncomingMessageQueue.add(`notify incoming message ${messageSid} for ${phoneNumberId}`, {
messageSid,
phoneNumberId,
}),
]);
return html("<Response></Response>");
} catch (error: any) {
@ -86,24 +102,25 @@ export const action: ActionFunction = async ({ request }) => {
}
};
type Body = {
ToCountry: string;
ToState: string;
SmsMessageSid: string;
NumMedia: string;
ToCity: string;
FromZip: string;
SmsSid: string;
FromState: string;
SmsStatus: string;
FromCity: string;
Body: string;
FromCountry: string;
To: string;
ToZip: string;
NumSegments: string;
MessageSid: string;
AccountSid: string;
From: string;
ApiVersion: string;
};
const bodySchema = z.object({
MessageSid: z.string(),
To: z.string(),
ToCountry: z.string().optional(),
ToState: z.string().optional(),
SmsMessageSid: z.string().optional(),
NumMedia: z.string().optional(),
ToCity: z.string().optional(),
FromZip: z.string().optional(),
SmsSid: z.string().optional(),
FromState: z.string().optional(),
SmsStatus: z.string().optional(),
FromCity: z.string().optional(),
Body: z.string().optional(),
FromCountry: z.string().optional(),
ToZip: z.string().optional(),
NumSegments: z.string().optional(),
AccountSid: z.string().optional(),
From: z.string().optional(),
ApiVersion: z.string().optional(),
ReferralNumMedia: z.string().optional(),
});

View File

@ -12,8 +12,17 @@ const defaultOptions: NotificationOptions = {
};
export default async function handlePush(event: PushEvent) {
const { title, ...payload }: NotificationPayload = event.data!.json();
const payload: NotificationPayload = event.data!.json();
const options = Object.assign({}, defaultOptions, payload);
await self.registration.showNotification(title, options);
const clients = await self.clients.matchAll({ type: "window" });
const hasOpenTab = clients.some((client) => client.focused === true);
if (hasOpenTab) {
const channel = new BroadcastChannel("notifications");
channel.postMessage(JSON.stringify(payload));
channel.close();
} else {
await self.registration.showNotification(payload.title, options);
await addBadge(1);
}
}

View File

@ -1,13 +1,20 @@
import webpush, { type PushSubscription, WebPushError } from "web-push";
import type { NotificationSubscription } from "@prisma/client";
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message";
import { parsePhoneNumber } from "awesome-phonenumber";
import { type NotificationSubscription, Direction } from "@prisma/client";
import serverConfig from "~/config/config.server";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
import { translateMessageDirection } from "~/utils/twilio.server";
export type NotificationPayload = NotificationOptions & {
export type NotificationPayload = Omit<NotificationOptions, "data"> & {
title: string; // max 50 characters
body: string; // max 150 characters
data: {
recipient: string;
type: "message" | "call";
};
};
export async function notify(subscriptions: NotificationSubscription[], payload: NotificationPayload) {
@ -50,3 +57,19 @@ function truncate(str: string, maxLength: number) {
return str.substring(0, maxLength - 1) + "\u2026";
}
export function buildMessageNotificationPayload(message: MessageInstance): NotificationPayload {
const direction = translateMessageDirection(message.direction);
const recipient = direction === Direction.Outbound ? message.to : message.from;
return {
title: parsePhoneNumber(recipient).getNumber("international"),
body: message.body,
actions: [
{
action: "reply",
title: "Reply",
},
],
data: { recipient, type: "message" },
};
}