send notifications over SSE to be displayed inside the app

This commit is contained in:
m5r 2022-06-19 17:57:51 +02:00
parent a46a4a3861
commit 6cf2f8cb94
13 changed files with 146 additions and 63 deletions

View File

@ -30,5 +30,9 @@ self.addEventListener("message", (event) => {
}); });
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
if (event.request.headers.get("Accept") === "text/event-stream") {
return;
}
event.respondWith(handleFetch(event)); event.respondWith(handleFetch(event));
}); });

View File

@ -5,7 +5,8 @@ import useAppLoaderData from "~/features/core/hooks/use-app-loader-data";
export default function useNotifications() { export default function useNotifications() {
const isServiceWorkerSupported = useMemo(() => "serviceWorker" in navigator, []); const isServiceWorkerSupported = useMemo(() => "serviceWorker" in navigator, []);
const [subscription, setSubscription] = useState<PushSubscription | null>(null); const isWebPushSupported = useMemo(() => "PushManager" in window, []);
const [subscription, setSubscription] = useState<PushSubscription | null>();
const { webPushPublicKey } = useAppLoaderData().config; const { webPushPublicKey } = useAppLoaderData().config;
const fetcher = useFetcher(); const fetcher = useFetcher();
const subscribeToNotifications = (subscription: PushSubscriptionJSON) => { const subscribeToNotifications = (subscription: PushSubscriptionJSON) => {
@ -29,7 +30,7 @@ export default function useNotifications() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!isServiceWorkerSupported) { if (!isServiceWorkerSupported || !isWebPushSupported) {
return; return;
} }
@ -37,10 +38,10 @@ export default function useNotifications() {
const subscription = await registration.pushManager.getSubscription(); const subscription = await registration.pushManager.getSubscription();
setSubscription(subscription); setSubscription(subscription);
})(); })();
}, [isServiceWorkerSupported]); }, [isServiceWorkerSupported, isWebPushSupported]);
async function subscribe() { async function subscribe() {
if (!isServiceWorkerSupported || subscription !== null || fetcher.state !== "idle") { if (!isServiceWorkerSupported || !isWebPushSupported || subscription !== null || fetcher.state !== "idle") {
return; return;
} }
@ -54,7 +55,7 @@ export default function useNotifications() {
} }
async function unsubscribe() { async function unsubscribe() {
if (!isServiceWorkerSupported || !subscription || fetcher.state !== "idle") { if (!isServiceWorkerSupported || !isWebPushSupported || !subscription || fetcher.state !== "idle") {
return; return;
} }
@ -69,7 +70,7 @@ export default function useNotifications() {
} }
return { return {
isServiceWorkerSupported, isNotificationSupported: isServiceWorkerSupported && isWebPushSupported,
subscription, subscription,
subscribe, subscribe,
unsubscribe, unsubscribe,

View File

@ -7,22 +7,39 @@ export default function useNotifications() {
const [notificationData, setNotificationData] = useAtom(notificationDataAtom); const [notificationData, setNotificationData] = useAtom(notificationDataAtom);
useEffect(() => { useEffect(() => {
const channel = new BroadcastChannel("notifications"); const eventSource = new EventSource("/sse/notifications");
eventSource.addEventListener("message", onMessage);
return () => {
eventSource.removeEventListener("message", onMessage);
eventSource.close();
};
function onMessage(event: MessageEvent) {
console.log("event.data", JSON.parse(event.data));
const notifyChannel = new BroadcastChannel("notifications");
notifyChannel.postMessage(event.data);
notifyChannel.close();
}
}, []);
useEffect(() => {
const notifyChannel = new BroadcastChannel("notifications");
async function eventHandler(event: MessageEvent) { async function eventHandler(event: MessageEvent) {
const payload: NotificationPayload = JSON.parse(event.data); const payload: NotificationPayload = JSON.parse(event.data);
setNotificationData(payload); setNotificationData(payload);
} }
channel.addEventListener("message", eventHandler); notifyChannel.addEventListener("message", eventHandler);
return () => { return () => {
channel.removeEventListener("message", eventHandler); notifyChannel.removeEventListener("message", eventHandler);
channel.close(); notifyChannel.close();
}; };
}, [setNotificationData]); }, [setNotificationData]);
useEffect(() => { useEffect(() => {
if (!notificationData) { if (!notificationData || notificationData.data.type === "call") {
return; return;
} }

View File

@ -1,8 +1,10 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import type { Call } from "@twilio/voice-sdk"; import type { Call } from "@twilio/voice-sdk";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { notificationDataAtom } from "~/features/core/hooks/use-notifications";
export default function useCall() { export default function useCall() {
const [, setNotificationData] = useAtom(notificationDataAtom);
const [call, setCall] = useAtom(callAtom); const [call, setCall] = useAtom(callAtom);
const endCall = useCallback( const endCall = useCallback(
function endCallFn() { function endCallFn() {
@ -10,8 +12,9 @@ export default function useCall() {
call?.removeListener("disconnect", endCall); call?.removeListener("disconnect", endCall);
call?.disconnect(); call?.disconnect();
setCall(null); setCall(null);
setNotificationData(null);
}, },
[call, setCall], [call, setCall, setNotificationData],
); );
const onError = useCallback( const onError = useCallback(
function onErrorFn(error: any) { function onErrorFn(error: any) {
@ -19,9 +22,10 @@ export default function useCall() {
call?.removeListener("disconnect", endCall); call?.removeListener("disconnect", endCall);
call?.disconnect(); call?.disconnect();
setCall(null); setCall(null);
setNotificationData(null);
throw error; // TODO: might not get caught by error boundary throw error; // TODO: might not get caught by error boundary
}, },
[call, setCall, endCall], [call, setCall, endCall, setNotificationData],
); );
const eventHandlers = [ const eventHandlers = [

View File

@ -82,8 +82,7 @@ export default function useDevice() {
setCall(incomingCall); setCall(incomingCall);
console.log("incomingCall.parameters", incomingCall.parameters); console.log("incomingCall.parameters", incomingCall.parameters);
// TODO prevent making a new call when there is a pending incoming call const notifyChannel = new BroadcastChannel("notifications");
const channel = new BroadcastChannel("notifications");
const recipient = incomingCall.parameters.From; const recipient = incomingCall.parameters.From;
const message: NotificationPayload = { const message: NotificationPayload = {
title: recipient, // TODO: title: recipient, // TODO:
@ -100,7 +99,8 @@ export default function useDevice() {
], ],
data: { recipient, type: "call" }, data: { recipient, type: "call" },
}; };
channel.postMessage(JSON.stringify(message)); notifyChannel.postMessage(JSON.stringify(message));
notifyChannel.close();
}, },
[call, setCall], [call, setCall],
); );

View File

@ -3,9 +3,13 @@ import useNotifications from "~/features/core/hooks/use-notifications.client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function NotificationsSettings() { export default function NotificationsSettings() {
const { subscription, subscribe, unsubscribe } = useNotifications(); const { isNotificationSupported, subscription, subscribe, unsubscribe } = useNotifications();
const [notificationsEnabled, setNotificationsEnabled] = useState(!!subscription); const [notificationsEnabled, setNotificationsEnabled] = useState(!!subscription);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState(() =>
isNotificationSupported
? ""
: "Your browser does not support web push notifications. You will still receive in-app notifications as long as you have Shellphone open.",
);
const [isChanging, setIsChanging] = useState(false); const [isChanging, setIsChanging] = useState(false);
const onChange = async (checked: boolean) => { const onChange = async (checked: boolean) => {
if (isChanging) { if (isChanging) {
@ -46,6 +50,11 @@ export default function NotificationsSettings() {
setNotificationsEnabled(!!subscription); setNotificationsEnabled(!!subscription);
}, [subscription]); }, [subscription]);
if (typeof subscription === "undefined") {
return <FallbackNotificationsSettings />;
}
// TODO: allow disabling in-app notifications
return ( return (
<ul className="mt-2 divide-y divide-gray-200"> <ul className="mt-2 divide-y divide-gray-200">
<Toggle <Toggle

View File

@ -1,8 +1,9 @@
import { Queue } from "~/utils/queue.server"; import { Queue } from "~/utils/queue.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 { buildMessageNotificationPayload, notify } from "~/utils/web-push.server";
import getTwilioClient from "~/utils/twilio.server"; import getTwilioClient from "~/utils/twilio.server";
import { buildMessageNotificationPayload, notify } from "~/utils/web-push.server";
import { notifySSE } from "~/utils/events.server";
type Payload = { type Payload = {
messageSid: string; messageSid: string;
@ -39,6 +40,6 @@ export default Queue<Payload>("notify incoming message", async ({ data }) => {
const message = await twilioClient.messages.get(messageSid).fetch(); const message = await twilioClient.messages.get(messageSid).fetch();
const payload = buildMessageNotificationPayload(message); const payload = buildMessageNotificationPayload(message);
// TODO: implement WS/SSE to push new messages for users who haven't enabled push notifications
await notify(subscriptions, payload); await notify(subscriptions, payload);
await notifySSE(payload);
}); });

View File

@ -2,43 +2,16 @@ import type { ActionFunction } from "@remix-run/node";
import { ClientOnly } from "remix-utils"; import { ClientOnly } from "remix-utils";
import { Form } from "@remix-run/react"; import { Form } from "@remix-run/react";
import db from "~/utils/db.server";
import { notify } from "~/utils/web-push.server";
import Button from "~/features/settings/components/button"; import Button from "~/features/settings/components/button";
import NotificationsSettings, { import NotificationsSettings, {
FallbackNotificationsSettings, FallbackNotificationsSettings,
} from "~/features/settings/components/settings/notifications-settings"; } from "~/features/settings/components/settings/notifications-settings";
import notifyIncomingMessageQueue from "~/queues/notify-incoming-message.server";
export const action: ActionFunction = async () => { export const action: ActionFunction = async () => {
const phoneNumber = await db.phoneNumber.findUnique({ await notifyIncomingMessageQueue.add("ddd", {
where: { id: "PN4f11f0c4155dfb5d5ac8bbab2cc23cbc" }, messageSid: "SM07ef9eb508f4e04bff596f11ac90e835",
select: { phoneNumberId: "PNb77c9690c394368bdbaf20ea6fe5e9fc",
twilioAccount: {
include: {
organization: {
select: {
memberships: {
select: { notificationSubscription: true },
},
},
},
},
},
},
});
const subscriptions = phoneNumber!.twilioAccount.organization.memberships.flatMap(
(membership) => membership.notificationSubscription,
);
await notify(subscriptions, {
title: "+33 6 13 37 07 87",
body: "wesh le zin, wesh la zine, copain copine mais si y'a moyen on pine",
actions: [
{
action: "reply",
title: "Reply",
},
],
data: { recipient: "+33613370787", type: "message" },
}); });
return null; return null;
}; };

View File

@ -0,0 +1,46 @@
import type { LoaderFunction } from "@remix-run/node";
import { events } from "~/utils/events.server";
import type { NotificationPayload } from "~/utils/web-push.server";
export let loader: LoaderFunction = ({ request }) => {
if (!request.signal) {
return new Response(null, { status: 500 });
}
return new Response(
new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const onNotification = (notification: NotificationPayload) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(notification)}\n\n`));
};
let closed = false;
function close() {
if (closed) {
return;
}
closed = true;
events.removeListener("notification", onNotification);
request.signal.removeEventListener("abort", close);
controller.close();
}
events.addListener("notification", onNotification);
request.signal.addEventListener("abort", close);
if (request.signal.aborted) {
close();
return;
}
},
}),
{
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
},
);
};

View File

@ -59,7 +59,8 @@ const lastTimeRevalidated: Record<string, number> = {};
export function fetchLoaderData(event: FetchEvent): Promise<Response> { export function fetchLoaderData(event: FetchEvent): Promise<Response> {
const url = new URL(event.request.url); const url = new URL(event.request.url);
if (url.pathname === "/outgoing-call/twilio-token") { const doNotCacheList = ["/outgoing-call/twilio-token"];
if (doNotCacheList.includes(url.pathname)) {
return fetch(event.request); return fetch(event.request);
} }

View File

@ -20,14 +20,6 @@ export default async function handlePush(event: PushEvent) {
revalidateChannel.postMessage("revalidateLoaderData"); revalidateChannel.postMessage("revalidateLoaderData");
revalidateChannel.close(); revalidateChannel.close();
const clients = await self.clients.matchAll({ type: "window" });
const hasOpenTab = clients.some((client) => client.focused === true);
if (hasOpenTab) {
const notifyChannel = new BroadcastChannel("notifications");
notifyChannel.postMessage(JSON.stringify(payload));
notifyChannel.close();
} else {
await self.registration.showNotification(payload.title, options); await self.registration.showNotification(payload.title, options);
await addBadge(1); await addBadge(1);
}
} }

View File

@ -0,0 +1,15 @@
import { EventEmitter } from "events";
import type { NotificationPayload } from "~/utils/web-push.server";
declare global {
var notifications: EventEmitter;
}
global.notifications = global.notifications || new EventEmitter();
export const events = global.notifications;
export function notifySSE(payload: NotificationPayload) {
global.notifications.emit("notification", payload);
}

View File

@ -46,7 +46,27 @@ app.all("*", (req, res, next) => {
}); });
app.disable("x-powered-by"); app.disable("x-powered-by");
app.use(compression()); app.use(
compression({
filter(req, res) {
const contentTypeHeader = res.getHeader("Content-Type");
let contentType = "";
if (contentTypeHeader) {
if (Array.isArray(contentTypeHeader)) {
contentType = contentTypeHeader.join(" ");
} else {
contentType = String(contentTypeHeader);
}
}
if (contentType.includes("text/event-stream")) {
return false;
}
return true;
},
}),
);
// cache static and immutable assets // cache static and immutable assets
app.use(express.static("public", { immutable: true, maxAge: "1y" })); app.use(express.static("public", { immutable: true, maxAge: "1y" }));