From 6cf2f8cb9457fe54d594cf95aa564d5b3b89ac3a Mon Sep 17 00:00:00 2001 From: m5r Date: Sun, 19 Jun 2022 17:57:51 +0200 Subject: [PATCH] send notifications over SSE to be displayed inside the app --- app/entry.worker.ts | 4 ++ .../core/hooks/use-notifications.client.ts | 13 +++--- app/features/core/hooks/use-notifications.ts | 27 +++++++++-- app/features/phone-calls/hooks/use-call.ts | 8 +++- app/features/phone-calls/hooks/use-device.ts | 6 +-- .../settings/notifications-settings.tsx | 13 +++++- app/queues/notify-incoming-message.server.ts | 5 +- app/routes/__app/settings/notifications.tsx | 35 ++------------ app/routes/__app/sse.notifications.ts | 46 +++++++++++++++++++ app/service-worker/cache-utils.ts | 3 +- app/service-worker/push.ts | 12 +---- app/utils/events.server.ts | 15 ++++++ server.ts | 22 ++++++++- 13 files changed, 146 insertions(+), 63 deletions(-) create mode 100644 app/routes/__app/sse.notifications.ts create mode 100644 app/utils/events.server.ts diff --git a/app/entry.worker.ts b/app/entry.worker.ts index 496238f..d307a8d 100644 --- a/app/entry.worker.ts +++ b/app/entry.worker.ts @@ -30,5 +30,9 @@ self.addEventListener("message", (event) => { }); self.addEventListener("fetch", (event) => { + if (event.request.headers.get("Accept") === "text/event-stream") { + return; + } + event.respondWith(handleFetch(event)); }); diff --git a/app/features/core/hooks/use-notifications.client.ts b/app/features/core/hooks/use-notifications.client.ts index 8b8cc08..e62eeca 100644 --- a/app/features/core/hooks/use-notifications.client.ts +++ b/app/features/core/hooks/use-notifications.client.ts @@ -5,7 +5,8 @@ import useAppLoaderData from "~/features/core/hooks/use-app-loader-data"; export default function useNotifications() { const isServiceWorkerSupported = useMemo(() => "serviceWorker" in navigator, []); - const [subscription, setSubscription] = useState(null); + const isWebPushSupported = useMemo(() => "PushManager" in window, []); + const [subscription, setSubscription] = useState(); const { webPushPublicKey } = useAppLoaderData().config; const fetcher = useFetcher(); const subscribeToNotifications = (subscription: PushSubscriptionJSON) => { @@ -29,7 +30,7 @@ export default function useNotifications() { useEffect(() => { (async () => { - if (!isServiceWorkerSupported) { + if (!isServiceWorkerSupported || !isWebPushSupported) { return; } @@ -37,10 +38,10 @@ export default function useNotifications() { const subscription = await registration.pushManager.getSubscription(); setSubscription(subscription); })(); - }, [isServiceWorkerSupported]); + }, [isServiceWorkerSupported, isWebPushSupported]); async function subscribe() { - if (!isServiceWorkerSupported || subscription !== null || fetcher.state !== "idle") { + if (!isServiceWorkerSupported || !isWebPushSupported || subscription !== null || fetcher.state !== "idle") { return; } @@ -54,7 +55,7 @@ export default function useNotifications() { } async function unsubscribe() { - if (!isServiceWorkerSupported || !subscription || fetcher.state !== "idle") { + if (!isServiceWorkerSupported || !isWebPushSupported || !subscription || fetcher.state !== "idle") { return; } @@ -69,7 +70,7 @@ export default function useNotifications() { } return { - isServiceWorkerSupported, + isNotificationSupported: isServiceWorkerSupported && isWebPushSupported, subscription, subscribe, unsubscribe, diff --git a/app/features/core/hooks/use-notifications.ts b/app/features/core/hooks/use-notifications.ts index c4dde32..6cba1c3 100644 --- a/app/features/core/hooks/use-notifications.ts +++ b/app/features/core/hooks/use-notifications.ts @@ -7,22 +7,39 @@ export default function useNotifications() { const [notificationData, setNotificationData] = useAtom(notificationDataAtom); 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) { const payload: NotificationPayload = JSON.parse(event.data); setNotificationData(payload); } - channel.addEventListener("message", eventHandler); + notifyChannel.addEventListener("message", eventHandler); return () => { - channel.removeEventListener("message", eventHandler); - channel.close(); + notifyChannel.removeEventListener("message", eventHandler); + notifyChannel.close(); }; }, [setNotificationData]); useEffect(() => { - if (!notificationData) { + if (!notificationData || notificationData.data.type === "call") { return; } diff --git a/app/features/phone-calls/hooks/use-call.ts b/app/features/phone-calls/hooks/use-call.ts index 0f53412..0c7b7ef 100644 --- a/app/features/phone-calls/hooks/use-call.ts +++ b/app/features/phone-calls/hooks/use-call.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect } from "react"; import type { Call } from "@twilio/voice-sdk"; import { atom, useAtom } from "jotai"; +import { notificationDataAtom } from "~/features/core/hooks/use-notifications"; export default function useCall() { + const [, setNotificationData] = useAtom(notificationDataAtom); const [call, setCall] = useAtom(callAtom); const endCall = useCallback( function endCallFn() { @@ -10,8 +12,9 @@ export default function useCall() { call?.removeListener("disconnect", endCall); call?.disconnect(); setCall(null); + setNotificationData(null); }, - [call, setCall], + [call, setCall, setNotificationData], ); const onError = useCallback( function onErrorFn(error: any) { @@ -19,9 +22,10 @@ export default function useCall() { call?.removeListener("disconnect", endCall); call?.disconnect(); setCall(null); + setNotificationData(null); throw error; // TODO: might not get caught by error boundary }, - [call, setCall, endCall], + [call, setCall, endCall, setNotificationData], ); const eventHandlers = [ diff --git a/app/features/phone-calls/hooks/use-device.ts b/app/features/phone-calls/hooks/use-device.ts index 079fce2..9da30d0 100644 --- a/app/features/phone-calls/hooks/use-device.ts +++ b/app/features/phone-calls/hooks/use-device.ts @@ -82,8 +82,7 @@ export default function useDevice() { setCall(incomingCall); console.log("incomingCall.parameters", incomingCall.parameters); - // TODO prevent making a new call when there is a pending incoming call - const channel = new BroadcastChannel("notifications"); + const notifyChannel = new BroadcastChannel("notifications"); const recipient = incomingCall.parameters.From; const message: NotificationPayload = { title: recipient, // TODO: @@ -100,7 +99,8 @@ export default function useDevice() { ], data: { recipient, type: "call" }, }; - channel.postMessage(JSON.stringify(message)); + notifyChannel.postMessage(JSON.stringify(message)); + notifyChannel.close(); }, [call, setCall], ); diff --git a/app/features/settings/components/settings/notifications-settings.tsx b/app/features/settings/components/settings/notifications-settings.tsx index c6925a3..7cee41a 100644 --- a/app/features/settings/components/settings/notifications-settings.tsx +++ b/app/features/settings/components/settings/notifications-settings.tsx @@ -3,9 +3,13 @@ import useNotifications from "~/features/core/hooks/use-notifications.client"; import { useEffect, useState } from "react"; export default function NotificationsSettings() { - const { subscription, subscribe, unsubscribe } = useNotifications(); + const { isNotificationSupported, subscription, subscribe, unsubscribe } = useNotifications(); 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 onChange = async (checked: boolean) => { if (isChanging) { @@ -46,6 +50,11 @@ export default function NotificationsSettings() { setNotificationsEnabled(!!subscription); }, [subscription]); + if (typeof subscription === "undefined") { + return ; + } + + // TODO: allow disabling in-app notifications return (
    ("notify incoming message", async ({ data }) => { const message = await twilioClient.messages.get(messageSid).fetch(); 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 notifySSE(payload); }); diff --git a/app/routes/__app/settings/notifications.tsx b/app/routes/__app/settings/notifications.tsx index 6ad6753..b30a462 100644 --- a/app/routes/__app/settings/notifications.tsx +++ b/app/routes/__app/settings/notifications.tsx @@ -2,43 +2,16 @@ import type { ActionFunction } from "@remix-run/node"; import { ClientOnly } from "remix-utils"; 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 NotificationsSettings, { FallbackNotificationsSettings, } from "~/features/settings/components/settings/notifications-settings"; +import notifyIncomingMessageQueue from "~/queues/notify-incoming-message.server"; export const action: ActionFunction = async () => { - const phoneNumber = await db.phoneNumber.findUnique({ - where: { id: "PN4f11f0c4155dfb5d5ac8bbab2cc23cbc" }, - select: { - 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" }, + await notifyIncomingMessageQueue.add("ddd", { + messageSid: "SM07ef9eb508f4e04bff596f11ac90e835", + phoneNumberId: "PNb77c9690c394368bdbaf20ea6fe5e9fc", }); return null; }; diff --git a/app/routes/__app/sse.notifications.ts b/app/routes/__app/sse.notifications.ts new file mode 100644 index 0000000..6f1c3d2 --- /dev/null +++ b/app/routes/__app/sse.notifications.ts @@ -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", + }, + }, + ); +}; diff --git a/app/service-worker/cache-utils.ts b/app/service-worker/cache-utils.ts index 3767f13..993a578 100644 --- a/app/service-worker/cache-utils.ts +++ b/app/service-worker/cache-utils.ts @@ -59,7 +59,8 @@ const lastTimeRevalidated: Record = {}; export function fetchLoaderData(event: FetchEvent): Promise { 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); } diff --git a/app/service-worker/push.ts b/app/service-worker/push.ts index d10e36b..1c0be6f 100644 --- a/app/service-worker/push.ts +++ b/app/service-worker/push.ts @@ -20,14 +20,6 @@ export default async function handlePush(event: PushEvent) { revalidateChannel.postMessage("revalidateLoaderData"); 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 addBadge(1); - } + await self.registration.showNotification(payload.title, options); + await addBadge(1); } diff --git a/app/utils/events.server.ts b/app/utils/events.server.ts new file mode 100644 index 0000000..293e227 --- /dev/null +++ b/app/utils/events.server.ts @@ -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); +} diff --git a/server.ts b/server.ts index c8592b9..cecf59f 100644 --- a/server.ts +++ b/server.ts @@ -46,7 +46,27 @@ app.all("*", (req, res, next) => { }); 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 app.use(express.static("public", { immutable: true, maxAge: "1y" }));