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 { type SessionData, requireLoggedIn } from "~/utils/auth.server";
|
||||||
import Footer from "~/features/core/components/footer";
|
import Footer from "~/features/core/components/footer";
|
||||||
import ServiceWorkerUpdateNotifier from "~/features/core/components/service-worker-update-notifier";
|
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 useServiceWorkerRevalidate from "~/features/core/hooks/use-service-worker-revalidate";
|
||||||
import footerStyles from "~/features/core/components/footer.css";
|
import footerStyles from "~/features/core/components/footer.css";
|
||||||
import appStyles from "~/styles/app.css";
|
import appStyles from "~/styles/app.css";
|
||||||
@ -36,6 +37,7 @@ export default function __App() {
|
|||||||
const hideFooter = matches.some((match) => match.handle?.hideFooter === true);
|
const hideFooter = matches.some((match) => match.handle?.hideFooter === true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="h-full w-full overflow-hidden fixed bg-gray-100">
|
<div className="h-full w-full overflow-hidden fixed bg-gray-100">
|
||||||
<div className="flex flex-col w-full h-full">
|
<div className="flex flex-col w-full h-full">
|
||||||
<ServiceWorkerUpdateNotifier />
|
<ServiceWorkerUpdateNotifier />
|
||||||
@ -47,6 +49,8 @@ export default function __App() {
|
|||||||
{hideFooter ? null : <Footer />}
|
{hideFooter ? null : <Footer />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Notification />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
import { type ActionFunction } from "@remix-run/node";
|
import { type ActionFunction } from "@remix-run/node";
|
||||||
import { badRequest, html, notFound, serverError } from "remix-utils";
|
import { badRequest, html, notFound, serverError } from "remix-utils";
|
||||||
|
import twilio from "twilio";
|
||||||
|
import { z } from "zod";
|
||||||
import { Prisma, SubscriptionStatus } from "@prisma/client";
|
import { Prisma, SubscriptionStatus } from "@prisma/client";
|
||||||
|
|
||||||
import insertIncomingMessageQueue from "~/queues/insert-incoming-message.server";
|
import insertIncomingMessageQueue from "~/queues/insert-incoming-message.server";
|
||||||
|
import notifyIncomingMessageQueue from "~/queues/notify-incoming-message.server";
|
||||||
import logger from "~/utils/logger.server";
|
import logger from "~/utils/logger.server";
|
||||||
import db from "~/utils/db.server";
|
import db from "~/utils/db.server";
|
||||||
import twilio from "twilio";
|
|
||||||
import { smsUrl } from "~/utils/twilio.server";
|
import { smsUrl } from "~/utils/twilio.server";
|
||||||
import { decrypt } from "~/utils/encryption";
|
import { decrypt } from "~/utils/encryption";
|
||||||
|
import { validate } from "~/utils/validation.server";
|
||||||
|
|
||||||
export const action: ActionFunction = async ({ request }) => {
|
export const action: ActionFunction = async ({ request }) => {
|
||||||
const twilioSignature = request.headers.get("X-Twilio-Signature") || request.headers.get("x-twilio-signature");
|
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");
|
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 {
|
try {
|
||||||
const phoneNumbers = await db.phoneNumber.findMany({
|
const phoneNumbers = await db.phoneNumber.findMany({
|
||||||
where: { number: body.To },
|
where: { number: body.To },
|
||||||
@ -47,7 +57,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||||||
return notFound("Phone number not found");
|
return notFound("Phone number not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const phoneNumbersWithActiveSub = phoneNumbers.filter(
|
/*const phoneNumbersWithActiveSub = phoneNumbers.filter(
|
||||||
(phoneNumber) => phoneNumber.twilioAccount.organization.subscriptions.length > 0,
|
(phoneNumber) => phoneNumber.twilioAccount.organization.subscriptions.length > 0,
|
||||||
);
|
);
|
||||||
if (phoneNumbersWithActiveSub.length === 0) {
|
if (phoneNumbersWithActiveSub.length === 0) {
|
||||||
@ -55,7 +65,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||||||
// because the organization is on the free plan
|
// 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
|
console.log("no active subscription"); // TODO: uncomment the line below -- beware: refresh phone numbers refetch those missed messages lol
|
||||||
// return html("<Response></Response>");
|
// return html("<Response></Response>");
|
||||||
}
|
}*/
|
||||||
|
|
||||||
const phoneNumber = phoneNumbers.find((phoneNumber) => {
|
const phoneNumber = phoneNumbers.find((phoneNumber) => {
|
||||||
// TODO: uncomment the line below
|
// 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?
|
// maybe we shouldn't let that happen by restricting a phone number to one org?
|
||||||
const encryptedAuthToken = phoneNumber.twilioAccount.authToken;
|
const encryptedAuthToken = phoneNumber.twilioAccount.authToken;
|
||||||
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
|
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
|
||||||
return twilio.validateRequest(authToken, twilioSignature, smsUrl, body);
|
return twilio.validateRequest(authToken, twilioSignature, smsUrl, formData);
|
||||||
});
|
});
|
||||||
if (!phoneNumber) {
|
if (!phoneNumber) {
|
||||||
return badRequest("Invalid webhook");
|
return badRequest("Invalid webhook");
|
||||||
@ -73,10 +83,16 @@ export const action: ActionFunction = async ({ request }) => {
|
|||||||
|
|
||||||
const messageSid = body.MessageSid;
|
const messageSid = body.MessageSid;
|
||||||
const phoneNumberId = phoneNumber.id;
|
const phoneNumberId = phoneNumber.id;
|
||||||
await insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, {
|
await Promise.all([
|
||||||
|
insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, {
|
||||||
messageSid,
|
messageSid,
|
||||||
phoneNumberId,
|
phoneNumberId,
|
||||||
});
|
}),
|
||||||
|
notifyIncomingMessageQueue.add(`notify incoming message ${messageSid} for ${phoneNumberId}`, {
|
||||||
|
messageSid,
|
||||||
|
phoneNumberId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return html("<Response></Response>");
|
return html("<Response></Response>");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -86,24 +102,25 @@ export const action: ActionFunction = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type Body = {
|
const bodySchema = z.object({
|
||||||
ToCountry: string;
|
MessageSid: z.string(),
|
||||||
ToState: string;
|
To: z.string(),
|
||||||
SmsMessageSid: string;
|
ToCountry: z.string().optional(),
|
||||||
NumMedia: string;
|
ToState: z.string().optional(),
|
||||||
ToCity: string;
|
SmsMessageSid: z.string().optional(),
|
||||||
FromZip: string;
|
NumMedia: z.string().optional(),
|
||||||
SmsSid: string;
|
ToCity: z.string().optional(),
|
||||||
FromState: string;
|
FromZip: z.string().optional(),
|
||||||
SmsStatus: string;
|
SmsSid: z.string().optional(),
|
||||||
FromCity: string;
|
FromState: z.string().optional(),
|
||||||
Body: string;
|
SmsStatus: z.string().optional(),
|
||||||
FromCountry: string;
|
FromCity: z.string().optional(),
|
||||||
To: string;
|
Body: z.string().optional(),
|
||||||
ToZip: string;
|
FromCountry: z.string().optional(),
|
||||||
NumSegments: string;
|
ToZip: z.string().optional(),
|
||||||
MessageSid: string;
|
NumSegments: z.string().optional(),
|
||||||
AccountSid: string;
|
AccountSid: z.string().optional(),
|
||||||
From: string;
|
From: z.string().optional(),
|
||||||
ApiVersion: string;
|
ApiVersion: z.string().optional(),
|
||||||
};
|
ReferralNumMedia: z.string().optional(),
|
||||||
|
});
|
||||||
|
@ -12,8 +12,17 @@ const defaultOptions: NotificationOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function handlePush(event: PushEvent) {
|
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);
|
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);
|
await addBadge(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
import webpush, { type PushSubscription, WebPushError } from "web-push";
|
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 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";
|
||||||
|
import { translateMessageDirection } from "~/utils/twilio.server";
|
||||||
|
|
||||||
export type NotificationPayload = NotificationOptions & {
|
export type NotificationPayload = Omit<NotificationOptions, "data"> & {
|
||||||
title: string; // max 50 characters
|
title: string; // max 50 characters
|
||||||
body: string; // max 150 characters
|
body: string; // max 150 characters
|
||||||
|
data: {
|
||||||
|
recipient: string;
|
||||||
|
type: "message" | "call";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function notify(subscriptions: NotificationSubscription[], payload: NotificationPayload) {
|
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";
|
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