notify of incoming messages and show in app notifications
This commit is contained in:
parent
a77f02189e
commit
ceaadc4f99
94
app/features/core/components/notification.tsx
Normal file
94
app/features/core/components/notification.tsx
Normal 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];
|
||||
}
|
||||
}
|
34
app/features/core/hooks/use-notifications.ts
Normal file
34
app/features/core/hooks/use-notifications.ts
Normal 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);
|
43
app/queues/notify-incoming-message.server.ts
Normal file
43
app/queues/notify-incoming-message.server.ts
Normal 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);
|
||||
});
|
@ -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,17 +37,20 @@ 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 />
|
||||
<div className="flex flex-col flex-1 w-full overflow-y-auto">
|
||||
<main className="flex flex-col flex-1 my-0 h-full">
|
||||
<Outlet />
|
||||
</main>
|
||||
<>
|
||||
<div className="h-full w-full overflow-hidden fixed bg-gray-100">
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<ServiceWorkerUpdateNotifier />
|
||||
<div className="flex flex-col flex-1 w-full overflow-y-auto">
|
||||
<main className="flex flex-col flex-1 my-0 h-full">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{hideFooter ? null : <Footer />}
|
||||
</div>
|
||||
{hideFooter ? null : <Footer />}
|
||||
</div>
|
||||
</div>
|
||||
<Notification />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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}`, {
|
||||
messageSid,
|
||||
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(),
|
||||
});
|
||||
|
@ -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);
|
||||
await addBadge(1);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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" },
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user