send notifications over SSE to be displayed inside the app
This commit is contained in:
parent
a46a4a3861
commit
6cf2f8cb94
@ -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));
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 = [
|
||||||
|
@ -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],
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
46
app/routes/__app/sse.notifications.ts
Normal file
46
app/routes/__app/sse.notifications.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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" });
|
await self.registration.showNotification(payload.title, options);
|
||||||
const hasOpenTab = clients.some((client) => client.focused === true);
|
await addBadge(1);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
15
app/utils/events.server.ts
Normal file
15
app/utils/events.server.ts
Normal 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);
|
||||||
|
}
|
22
server.ts
22
server.ts
@ -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" }));
|
||||||
|
Loading…
Reference in New Issue
Block a user