push notifications but installable to home screen yet
This commit is contained in:
parent
b5bb8e1822
commit
4c22ee83e1
@ -21,3 +21,7 @@ PADDLE_VENDOR_ID=TODO
|
||||
PADDLE_API_KEY=TODO
|
||||
|
||||
TWILIO_AUTH_TOKEN=TODO
|
||||
|
||||
# npx web-push generate-vapid-keys
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY=TODO
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY=TODO
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@ node_modules
|
||||
/.cache
|
||||
/server/build
|
||||
/public/build
|
||||
/public/entry.worker.js
|
||||
/build
|
||||
server.js
|
||||
/app/styles/tailwind.css
|
||||
|
@ -28,6 +28,14 @@ invariant(
|
||||
typeof process.env.MASTER_ENCRYPTION_KEY === "string",
|
||||
`Please define the "MASTER_ENCRYPTION_KEY" environment variable`,
|
||||
);
|
||||
invariant(
|
||||
typeof process.env.WEB_PUSH_VAPID_PRIVATE_KEY === "string",
|
||||
`Please define the "WEB_PUSH_VAPID_PRIVATE_KEY" environment variable`,
|
||||
);
|
||||
invariant(
|
||||
typeof process.env.WEB_PUSH_VAPID_PUBLIC_KEY === "string",
|
||||
`Please define the "WEB_PUSH_VAPID_PUBLIC_KEY" environment variable`,
|
||||
);
|
||||
|
||||
export default {
|
||||
app: {
|
||||
@ -49,4 +57,8 @@ export default {
|
||||
twilio: {
|
||||
authToken: process.env.TWILIO_AUTH_TOKEN,
|
||||
},
|
||||
webPush: {
|
||||
privateKey: process.env.WEB_PUSH_VAPID_PRIVATE_KEY,
|
||||
publicKey: process.env.WEB_PUSH_VAPID_PUBLIC_KEY,
|
||||
},
|
||||
};
|
||||
|
@ -2,3 +2,14 @@ import { hydrate } from "react-dom";
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
|
||||
hydrate(<RemixBrowser />, document);
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", async () => {
|
||||
try {
|
||||
await navigator.serviceWorker.register("/entry.worker.js");
|
||||
await navigator.serviceWorker.ready;
|
||||
} catch (error) {
|
||||
console.error("Service worker registration failed", error, (error as Error).name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
47
app/entry.worker.ts
Normal file
47
app/entry.worker.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
import type { NotificationPayload } from "~/utils/web-push.server";
|
||||
import { addBadge, removeBadge } from "~/utils/pwa.client";
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope;
|
||||
|
||||
const defaultOptions: NotificationOptions = {
|
||||
icon: "/icons/android-chrome-192x192.png",
|
||||
badge: "/icons/android-chrome-48x48.png",
|
||||
dir: "auto",
|
||||
image: undefined,
|
||||
silent: false,
|
||||
};
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
const { title, ...payload }: NotificationPayload = JSON.parse(event?.data!.text());
|
||||
const options = Object.assign({}, defaultOptions, payload);
|
||||
event.waitUntil(async () => {
|
||||
await self.registration.showNotification(title, options);
|
||||
await addBadge(1);
|
||||
});
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
console.log("On notification click: ", event.notification.tag);
|
||||
// Android doesn’t close the notification when you click on it
|
||||
// See: http://crbug.com/463146
|
||||
event.notification.close();
|
||||
await removeBadge();
|
||||
|
||||
if (event.action === "reply") {
|
||||
const recipient = encodeURIComponent(event.notification.data.recipient);
|
||||
return self.clients.openWindow?.(`/messages/${recipient}`);
|
||||
}
|
||||
|
||||
if (event.action === "answer") {
|
||||
const recipient = encodeURIComponent(event.notification.data.recipient);
|
||||
return self.clients.openWindow?.(`/incoming-call/${recipient}`);
|
||||
}
|
||||
|
||||
return self.clients.openWindow?.("/");
|
||||
})(),
|
||||
);
|
||||
});
|
104
app/features/core/actions/notifications-subscription.ts
Normal file
104
app/features/core/actions/notifications-subscription.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { type ActionFunction } from "@remix-run/node";
|
||||
import { badRequest, notFound } from "remix-utils";
|
||||
import { z } from "zod";
|
||||
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
import { validate } from "~/utils/validation.server";
|
||||
import { requireLoggedIn } from "~/utils/auth.server";
|
||||
|
||||
const action: ActionFunction = async ({ request }) => {
|
||||
const formData = await request.clone().formData();
|
||||
const action = formData.get("_action");
|
||||
if (!action) {
|
||||
const errorMessage = "POST /notifications-subscription without any _action";
|
||||
logger.error(errorMessage);
|
||||
return badRequest({ errorMessage });
|
||||
}
|
||||
|
||||
switch (action as Action) {
|
||||
case "subscribe":
|
||||
return subscribe(request);
|
||||
case "unsubscribe":
|
||||
return unsubscribe(request);
|
||||
default:
|
||||
const errorMessage = `POST /notifications-subscription with an invalid _action=${action}`;
|
||||
logger.error(errorMessage);
|
||||
return badRequest({ errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
export default action;
|
||||
|
||||
async function subscribe(request: Request) {
|
||||
const { organization } = await requireLoggedIn(request);
|
||||
const formData = await request.formData();
|
||||
const body = {
|
||||
subscription: JSON.parse(formData.get("subscription")?.toString() ?? "{}"),
|
||||
};
|
||||
const validation = validate(validations.subscribe, body);
|
||||
if (validation.errors) {
|
||||
return badRequest(validation.errors);
|
||||
}
|
||||
|
||||
const { subscription } = validation.data;
|
||||
const membership = await db.membership.findFirst({
|
||||
where: { id: organization.membershipId },
|
||||
});
|
||||
if (!membership) {
|
||||
return notFound("Phone number not found");
|
||||
}
|
||||
|
||||
try {
|
||||
await db.notificationSubscription.create({
|
||||
data: {
|
||||
membershipId: membership.id,
|
||||
endpoint: subscription.endpoint,
|
||||
expirationTime: subscription.expirationTime,
|
||||
keys_p256dh: subscription.keys.p256dh,
|
||||
keys_auth: subscription.keys.auth,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code !== "P2002") {
|
||||
logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function unsubscribe(request: Request) {
|
||||
const formData = await request.formData();
|
||||
const body = {
|
||||
subscriptionEndpoint: formData.get("subscriptionEndpoint"),
|
||||
};
|
||||
const validation = validate(validations.unsubscribe, body);
|
||||
if (validation.errors) {
|
||||
return badRequest(validation.errors);
|
||||
}
|
||||
|
||||
const endpoint = validation.data.subscriptionEndpoint;
|
||||
await db.notificationSubscription.delete({ where: { endpoint } });
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type Action = "subscribe" | "unsubscribe";
|
||||
|
||||
const validations = {
|
||||
subscribe: z.object({
|
||||
subscription: z.object({
|
||||
endpoint: z.string(),
|
||||
expirationTime: z.number().nullable(),
|
||||
keys: z.object({
|
||||
p256dh: z.string(),
|
||||
auth: z.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
unsubscribe: z.object({
|
||||
subscriptionEndpoint: z.string(),
|
||||
}),
|
||||
} as const;
|
4
app/features/core/components/footer.css
Normal file
4
app/features/core/components/footer.css
Normal file
@ -0,0 +1,4 @@
|
||||
.footer-ios {
|
||||
margin-bottom: var(--safe-area-bottom);
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
}
|
@ -6,7 +6,7 @@ import clsx from "clsx";
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
className="grid grid-cols-4 bg-[#F7F7F7] h-16 border-t border-gray-400 border-opacity-25 py-2 z-10"
|
||||
className="footer-ios grid grid-cols-4 bg-[#F7F7F7] h-16 border-t border-gray-400 border-opacity-25 py-2 z-10"
|
||||
// className="grid grid-cols-4 border-t border-gray-400 border-opacity-25 py-3 z-10 backdrop-blur"
|
||||
>
|
||||
<FooterLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />
|
||||
|
13
app/features/core/hooks/use-app-loader-data.ts
Normal file
13
app/features/core/hooks/use-app-loader-data.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { useMatches } from "@remix-run/react";
|
||||
|
||||
import type { AppLoaderData } from "~/routes/__app";
|
||||
|
||||
export default function useAppLoaderData() {
|
||||
const matches = useMatches();
|
||||
const __appRoute = matches.find((match) => match.id === "routes/__app");
|
||||
if (!__appRoute) {
|
||||
throw new Error("useSession hook called outside _app route");
|
||||
}
|
||||
|
||||
return __appRoute.data as AppLoaderData;
|
||||
}
|
90
app/features/core/hooks/use-notifications.client.ts
Normal file
90
app/features/core/hooks/use-notifications.client.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
|
||||
import useAppLoaderData from "~/features/core/hooks/use-app-loader-data";
|
||||
|
||||
export default function useNotifications() {
|
||||
const isServiceWorkerSupported = useMemo(() => "serviceWorker" in navigator, []);
|
||||
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
|
||||
const { webPushPublicKey } = useAppLoaderData().config;
|
||||
const fetcher = useFetcher();
|
||||
const subscribeToNotifications = (subscription: PushSubscriptionJSON) => {
|
||||
fetcher.submit(
|
||||
{
|
||||
_action: "subscribe",
|
||||
subscription: JSON.stringify(subscription),
|
||||
},
|
||||
{ method: "post", action: "/notifications-subscription" },
|
||||
);
|
||||
};
|
||||
const unsubscribeFromNotifications = (subscriptionEndpoint: PushSubscription["endpoint"]) => {
|
||||
fetcher.submit(
|
||||
{
|
||||
_action: "unsubscribe",
|
||||
subscriptionEndpoint,
|
||||
},
|
||||
{ method: "post", action: "/notifications-subscription" },
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!isServiceWorkerSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
setSubscription(subscription);
|
||||
})();
|
||||
}, [isServiceWorkerSupported]);
|
||||
|
||||
async function subscribe() {
|
||||
if (!isServiceWorkerSupported || subscription !== null || fetcher.state !== "idle") {
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const newSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(webPushPublicKey),
|
||||
});
|
||||
setSubscription(newSubscription);
|
||||
subscribeToNotifications(newSubscription.toJSON());
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
if (!isServiceWorkerSupported || !subscription || fetcher.state !== "idle") {
|
||||
return;
|
||||
}
|
||||
|
||||
subscription
|
||||
.unsubscribe()
|
||||
.then(() => {
|
||||
console.log("Unsubscribed from notifications");
|
||||
setSubscription(null);
|
||||
})
|
||||
.catch((error) => console.error("Failed to unsubscribe from notifications", error));
|
||||
unsubscribeFromNotifications(subscription.endpoint);
|
||||
}
|
||||
|
||||
return {
|
||||
isServiceWorkerSupported,
|
||||
subscription,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
};
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string) {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
@ -1,13 +1,5 @@
|
||||
import { useMatches } from "@remix-run/react";
|
||||
|
||||
import type { SessionData } from "~/utils/auth.server";
|
||||
import useAppLoaderData from "~/features/core/hooks/use-app-loader-data";
|
||||
|
||||
export default function useSession() {
|
||||
const matches = useMatches();
|
||||
const __appRoute = matches.find((match) => match.id === "routes/__app");
|
||||
if (!__appRoute) {
|
||||
throw new Error("useSession hook called outside _app route");
|
||||
}
|
||||
|
||||
return __appRoute.data as SessionData;
|
||||
return useAppLoaderData().sessionData;
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ const action: ActionFunction = async ({ params, request }) => {
|
||||
phoneNumberId: phoneNumber!.id,
|
||||
id: message.sid,
|
||||
to: message.to,
|
||||
recipient: message.to,
|
||||
from: message.from,
|
||||
status: translateMessageStatus(message.status),
|
||||
direction: translateMessageDirection(message.direction),
|
||||
|
54
app/root.tsx
54
app/root.tsx
@ -1,4 +1,4 @@
|
||||
import type { FunctionComponent, ReactNode } from "react";
|
||||
import { type FunctionComponent, type PropsWithChildren } from "react";
|
||||
import type { LinksFunction } from "@remix-run/node";
|
||||
import { Link, Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useCatch } from "@remix-run/react";
|
||||
|
||||
@ -6,35 +6,7 @@ import Logo from "~/features/core/components/logo";
|
||||
|
||||
import styles from "./styles/tailwind.css";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: styles },
|
||||
{
|
||||
rel: "icon",
|
||||
href: "/favicon.ico",
|
||||
},
|
||||
{
|
||||
rel: "apple-touch-icon",
|
||||
sizes: "180x180",
|
||||
href: "/apple-touch-icon.png",
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
sizes: "32x32",
|
||||
href: "/favicon-32x32.png",
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
sizes: "16x16",
|
||||
href: "/favicon-16x16.png",
|
||||
},
|
||||
{
|
||||
rel: "mask-icon",
|
||||
href: "/safari-pinned-tab.svg",
|
||||
color: "#663399",
|
||||
},
|
||||
];
|
||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@ -92,11 +64,29 @@ export function CatchBoundary() {
|
||||
);
|
||||
}
|
||||
|
||||
const Document: FunctionComponent<{ children: ReactNode }> = ({ children }) => (
|
||||
const Document: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
|
||||
<html lang="en" className="h-full">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Shellphone" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Shellphone" />
|
||||
<meta name="theme-color" content="#F4F4F5" />
|
||||
|
||||
<meta name="msapplication-navbutton-color" content="#F4F4F5" />
|
||||
<meta name="msapplication-starturl" content="/messages" />
|
||||
<meta name="msapplication-TileColor" content="#F4F4F5" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
|
||||
<link rel="mask-icon" href="/icons/safari-pinned-tab.svg" color="#F4F4F5" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
|
@ -1,15 +1,31 @@
|
||||
import { type LoaderFunction, json } from "@remix-run/node";
|
||||
import { type LinksFunction, type LoaderFunction, json } from "@remix-run/node";
|
||||
import { Outlet, useCatch, useMatches } from "@remix-run/react";
|
||||
|
||||
import serverConfig from "~/config/config.server";
|
||||
import { type SessionData, requireLoggedIn } from "~/utils/auth.server";
|
||||
import Footer from "~/features/core/components/footer";
|
||||
import footerStyles from "~/features/core/components/footer.css";
|
||||
import appStyles from "~/styles/app.css";
|
||||
|
||||
export type AppLoaderData = SessionData;
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: appStyles },
|
||||
{ rel: "stylesheet", href: footerStyles },
|
||||
];
|
||||
|
||||
export type AppLoaderData = {
|
||||
sessionData: SessionData;
|
||||
config: { webPushPublicKey: string };
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const sessionData = await requireLoggedIn(request);
|
||||
|
||||
return json<AppLoaderData>(sessionData);
|
||||
return json<AppLoaderData>({
|
||||
sessionData,
|
||||
config: {
|
||||
webPushPublicKey: serverConfig.webPush.publicKey,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default function __App() {
|
||||
@ -24,7 +40,7 @@ export default function __App() {
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{!hideFooter ? <Footer /> : null}
|
||||
{hideFooter ? null : <Footer />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
3
app/routes/__app/notifications-subscription.ts
Normal file
3
app/routes/__app/notifications-subscription.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import notificationsSubscriptionAction from "~/features/core/actions/notifications-subscription";
|
||||
|
||||
export const action = notificationsSubscriptionAction;
|
@ -8,6 +8,7 @@ import {
|
||||
IoCardOutline,
|
||||
IoCallOutline,
|
||||
IoPersonCircleOutline,
|
||||
IoHelpBuoyOutline,
|
||||
} from "react-icons/io5";
|
||||
|
||||
import Divider from "~/features/settings/components/divider";
|
||||
@ -18,6 +19,7 @@ const subNavigation = [
|
||||
{ name: "Phone", to: "/settings/phone", icon: IoCallOutline },
|
||||
{ name: "Billing", to: "/settings/billing", icon: IoCardOutline },
|
||||
{ name: "Notifications", to: "/settings/notifications", icon: IoNotificationsOutline },
|
||||
{ name: "Support", to: "/settings/support", icon: IoHelpBuoyOutline },
|
||||
];
|
||||
|
||||
export const meta: MetaFunction = () => ({
|
||||
@ -90,4 +92,4 @@ export default function SettingsLayout() {
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ function useSubscription() {
|
||||
};
|
||||
}
|
||||
|
||||
function Billing() {
|
||||
export default function Billing() {
|
||||
const { count: paymentsCount } = usePaymentsHistory();
|
||||
const { subscription, cancelSubscription, updatePaymentMethod } = useSubscription();
|
||||
|
||||
@ -64,5 +64,3 @@ const plansName: Record<number, string> = {
|
||||
727544: "Yearly",
|
||||
727540: "Monthly",
|
||||
};
|
||||
|
||||
export default Billing;
|
||||
|
@ -1,7 +1,158 @@
|
||||
import { type ElementType, useEffect, useState } from "react";
|
||||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { ClientOnly } from "remix-utils";
|
||||
import { Switch } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import useNotifications from "~/features/core/hooks/use-notifications.client";
|
||||
import Alert from "~/features/core/components/alert";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { notify } from "~/utils/web-push.server";
|
||||
import Button from "~/features/settings/components/button";
|
||||
|
||||
export const action: ActionFunction = async () => {
|
||||
await notify("PN4f11f0c4155dfb5d5ac8bbab2cc23cbc", {
|
||||
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" },
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function NotificationsPage() {
|
||||
return <ClientOnly fallback={<Loader />}>{() => <Notifications />}</ClientOnly>;
|
||||
}
|
||||
|
||||
function Notifications() {
|
||||
const { subscription, subscribe, unsubscribe } = useNotifications();
|
||||
const [notificationsEnabled, setNotificationsEnabled] = useState(!!subscription);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
const onChange = async (checked: boolean) => {
|
||||
if (isChanging) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChanging(true);
|
||||
setNotificationsEnabled(checked);
|
||||
setErrorMessage("");
|
||||
try {
|
||||
if (checked) {
|
||||
await subscribe();
|
||||
} else {
|
||||
await unsubscribe();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
setNotificationsEnabled(!checked);
|
||||
|
||||
switch (error.name) {
|
||||
case "NotAllowedError":
|
||||
setErrorMessage(
|
||||
"Your browser is not allowing Shellphone to register push notifications for you. Please allow Shellphone's notifications in your browser's settings if you wish to receive them.",
|
||||
);
|
||||
break;
|
||||
case "TypeError":
|
||||
setErrorMessage("Your browser does not support push notifications yet.");
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
setIsChanging(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
setNotificationsEnabled(!!subscription);
|
||||
}, [subscription]);
|
||||
|
||||
return (
|
||||
<div>Coming soon</div>
|
||||
<section className="pt-6 divide-y divide-gray-200">
|
||||
<section>
|
||||
<Form method="post">
|
||||
<Button variant="default" type="submit">
|
||||
send it!!!
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
<div className="px-4 sm:px-6">
|
||||
<div>
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">Notifications</h2>
|
||||
</div>
|
||||
<ul className="mt-2 divide-y divide-gray-200">
|
||||
<Toggle
|
||||
as="li"
|
||||
checked={notificationsEnabled}
|
||||
description="Get notified on this device when you receive a message or a phone call"
|
||||
onChange={onChange}
|
||||
title="Enable notifications"
|
||||
/>
|
||||
</ul>
|
||||
{errorMessage !== "" && <Alert title="Browser error" message={errorMessage} variant="error" />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default Notifications;
|
||||
type ToggleProps = {
|
||||
as?: ElementType;
|
||||
checked: boolean;
|
||||
description?: string;
|
||||
onChange(checked: boolean): void;
|
||||
title: string;
|
||||
};
|
||||
|
||||
function Toggle({ as, checked, description, onChange, title }: ToggleProps) {
|
||||
return (
|
||||
<Switch.Group as={as} className="py-4 flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Switch.Label as="p" className="text-sm font-medium text-gray-900" passive>
|
||||
{title}
|
||||
</Switch.Label>
|
||||
{description && (
|
||||
<Switch.Description as="span" className="text-sm text-gray-500">
|
||||
{description}
|
||||
</Switch.Description>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
checked ? "bg-primary-500" : "bg-gray-200",
|
||||
"ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
checked ? "translate-x-5" : "translate-x-0",
|
||||
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200",
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Switch.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function Loader() {
|
||||
return (
|
||||
<svg
|
||||
className="animate-spin mx-auto h-5 w-5 text-primary-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ export const loader = settingsPhoneLoader;
|
||||
|
||||
export const action = settingsPhoneAction;
|
||||
|
||||
function PhoneSettings() {
|
||||
export default function PhoneSettings() {
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
<TwilioConnect />
|
||||
@ -15,5 +15,3 @@ function PhoneSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PhoneSettings;
|
||||
|
9
app/routes/__app/settings/support.tsx
Normal file
9
app/routes/__app/settings/support.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
export default function SupportPage() {
|
||||
return (
|
||||
<div>
|
||||
<a className="underline" href="mailto:support@shellphone.app">
|
||||
Email us
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
20
app/routes/cache[.]manifest.ts
Normal file
20
app/routes/cache[.]manifest.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
|
||||
const manifest = `CACHE MANIFEST
|
||||
|
||||
# Version 1.0000
|
||||
|
||||
NETWORK:
|
||||
*
|
||||
`;
|
||||
|
||||
export const loader: LoaderFunction = async () => {
|
||||
const headers = new Headers({
|
||||
"Content-Type": "text/cache-manifest",
|
||||
"Cache-Control": "max-age=0, no-cache, no-store, must-revalidate",
|
||||
Pragma: "no-cache",
|
||||
Expires: "Thu, 01 Jan 1970 00:00:01 GMT",
|
||||
});
|
||||
|
||||
return new Response(manifest, { headers });
|
||||
};
|
10
app/styles/app.css
Normal file
10
app/styles/app.css
Normal file
@ -0,0 +1,10 @@
|
||||
:root {
|
||||
--safe-area-top: env(safe-area-inset-top);
|
||||
--safe-area-right: env(safe-area-inset-right);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom); /* THIS ONE GETS US THE HOME BAR HEIGHT */
|
||||
--safe-area-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
body {
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
@ -13,7 +13,7 @@ type SessionTwilioAccount = Pick<
|
||||
TwilioAccount,
|
||||
"accountSid" | "subAccountSid" | "subAccountAuthToken" | "apiKeySid" | "apiKeySecret" | "twimlAppSid"
|
||||
>;
|
||||
type SessionOrganization = Pick<Organization, "id"> & { role: MembershipRole };
|
||||
type SessionOrganization = Pick<Organization, "id"> & { role: MembershipRole; membershipId: string };
|
||||
type SessionPhoneNumber = Pick<PhoneNumber, "id" | "number">;
|
||||
export type SessionUser = Pick<User, "id" | "role" | "email" | "fullName">;
|
||||
export type SessionData = {
|
||||
@ -190,6 +190,7 @@ async function buildSessionData(id: string): Promise<SessionData> {
|
||||
},
|
||||
},
|
||||
role: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -203,6 +204,7 @@ async function buildSessionData(id: string): Promise<SessionData> {
|
||||
const organizations = memberships.map((membership) => ({
|
||||
...membership.organization,
|
||||
role: membership.role,
|
||||
membershipId: membership.id,
|
||||
}));
|
||||
const { twilioAccount, ...organization } = organizations[0];
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
|
70
app/utils/pwa.client.ts
Normal file
70
app/utils/pwa.client.ts
Normal file
@ -0,0 +1,70 @@
|
||||
type ResponseObject = {
|
||||
status: "success" | "bad";
|
||||
message: string;
|
||||
};
|
||||
|
||||
// use case: prevent making phone calls / queue messages when offline
|
||||
export async function checkConnectivity(online: () => void, offline: () => void): Promise<ResponseObject> {
|
||||
try {
|
||||
if (navigator.onLine) {
|
||||
online();
|
||||
return {
|
||||
status: "success",
|
||||
message: "Connected to the internet",
|
||||
};
|
||||
} else {
|
||||
offline();
|
||||
return {
|
||||
status: "bad",
|
||||
message: "No internet connection available",
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug(err);
|
||||
throw new Error("Unable to check network connectivity!");
|
||||
}
|
||||
}
|
||||
|
||||
// use case: display unread messages + missed phone calls count
|
||||
export async function addBadge(numberCount: number): Promise<ResponseObject> {
|
||||
try {
|
||||
//@ts-ignore
|
||||
if (navigator.setAppBadge) {
|
||||
//@ts-ignore
|
||||
await navigator.setAppBadge(numberCount);
|
||||
return {
|
||||
status: "success",
|
||||
message: "Badge successfully added",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: "bad",
|
||||
message: "Badging API not supported",
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug(err);
|
||||
throw new Error("Error adding badge!");
|
||||
}
|
||||
}
|
||||
export async function removeBadge(): Promise<ResponseObject> {
|
||||
try {
|
||||
//@ts-ignore
|
||||
if (navigator.clearAppBadge) {
|
||||
//@ts-ignore
|
||||
await navigator.clearAppBadge();
|
||||
return {
|
||||
status: "success",
|
||||
message: "Cleared badges",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: "bad",
|
||||
message: "Badging API not supported in this browser!",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug(error);
|
||||
throw new Error("Error removing badge!");
|
||||
}
|
||||
}
|
@ -4,5 +4,5 @@ export const { getSeo, getSeoMeta, getSeoLinks } = initSeo({
|
||||
title: "",
|
||||
titleTemplate: "%s | Shellphone",
|
||||
description: "",
|
||||
defaultTitle: "Shellphone",
|
||||
defaultTitle: "Shellphone: Your Personal Cloud Phone",
|
||||
});
|
||||
|
@ -15,7 +15,7 @@ export default function getTwilioClient({
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
return twilio(subAccountSid, subAccountAuthToken ?? serverConfig.twilio.authToken, {
|
||||
return twilio(subAccountSid, serverConfig.twilio.authToken, {
|
||||
accountSid,
|
||||
});
|
||||
}
|
||||
|
57
app/utils/web-push.server.ts
Normal file
57
app/utils/web-push.server.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import webpush, { type PushSubscription, WebPushError } from "web-push";
|
||||
|
||||
import serverConfig from "~/config/config.server";
|
||||
import db from "~/utils/db.server";
|
||||
import logger from "~/utils/logger.server";
|
||||
|
||||
export type NotificationPayload = NotificationOptions & {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export async function notify(phoneNumberId: string, payload: NotificationPayload) {
|
||||
webpush.setVapidDetails("mailto:mokht@rmi.al", serverConfig.webPush.publicKey, serverConfig.webPush.privateKey);
|
||||
|
||||
const phoneNumber = await db.phoneNumber.findUnique({
|
||||
where: { id: phoneNumberId },
|
||||
select: {
|
||||
organization: {
|
||||
select: {
|
||||
memberships: {
|
||||
select: { notificationSubscription: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!phoneNumber) {
|
||||
// TODO
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptions = phoneNumber.organization.memberships.flatMap(
|
||||
(membership) => membership.notificationSubscription,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const webPushSubscription: PushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.keys_p256dh,
|
||||
auth: subscription.keys_auth,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(webPushSubscription, JSON.stringify(payload));
|
||||
} catch (error: any) {
|
||||
logger.error(error);
|
||||
if (error instanceof WebPushError) {
|
||||
// subscription most likely expired or has been revoked
|
||||
await db.notificationSubscription.delete({ where: { id: subscription.id } });
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
387
package-lock.json
generated
387
package-lock.json
generated
@ -47,6 +47,7 @@
|
||||
"tiny-invariant": "1.2.0",
|
||||
"tslog": "3.3.3",
|
||||
"twilio": "3.77.0",
|
||||
"web-push": "3.5.0",
|
||||
"zod": "3.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -66,6 +67,7 @@
|
||||
"@types/react": "18.0.9",
|
||||
"@types/react-dom": "18.0.4",
|
||||
"@types/secure-password": "3.1.1",
|
||||
"@types/web-push": "3.3.2",
|
||||
"@vitejs/plugin-react": "1.3.2",
|
||||
"c8": "7.11.2",
|
||||
"cypress": "9.6.1",
|
||||
@ -4459,6 +4461,15 @@
|
||||
"integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.3.2.tgz",
|
||||
"integrity": "sha512-JxWGVL/m7mWTIg4mRYO+A6s0jPmBkr4iJr39DqJpRJAc+jrPiEe1/asmkwerzRon8ZZDxaZJpsxpv0Z18Wo9gw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz",
|
||||
@ -5108,6 +5119,17 @@
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assert-never": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz",
|
||||
@ -5533,6 +5555,16 @@
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/big-integer": {
|
||||
"version": "1.6.51",
|
||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
|
||||
"integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/big.js": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
||||
@ -5618,6 +5650,11 @@
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
|
||||
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz",
|
||||
@ -5729,6 +5766,23 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/broadcast-channel": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
|
||||
"integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"detect-node": "^2.1.0",
|
||||
"js-sha3": "0.8.0",
|
||||
"microseconds": "0.2.0",
|
||||
"nano-time": "1.0.0",
|
||||
"oblivious-set": "1.0.0",
|
||||
"rimraf": "3.0.2",
|
||||
"unload": "2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browser-sync": {
|
||||
"version": "2.27.10",
|
||||
"resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-2.27.10.tgz",
|
||||
@ -7764,6 +7818,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/detective": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
|
||||
@ -11217,6 +11278,17 @@
|
||||
"entities": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.1.0.tgz",
|
||||
"integrity": "sha512-bptAfCDdPJxOs5zYSe7Y3lpr772s1G346R4Td5LgRUeCwIGpCGDUTJxRrhTNcAXbx37spge0kWEIH7QAYWNTlA==",
|
||||
"dependencies": {
|
||||
"urlsafe-base64": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-basic": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/http-basic/-/http-basic-8.1.3.tgz",
|
||||
@ -12411,6 +12483,13 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-sha3": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
|
||||
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/js-stringify": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz",
|
||||
@ -13669,6 +13748,17 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/match-sorter": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
|
||||
"integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"remove-accents": "0.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
|
||||
@ -14621,6 +14711,13 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/microseconds": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
|
||||
"integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz",
|
||||
@ -14680,6 +14777,11 @@
|
||||
"mini-svg-data-uri": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@ -14989,6 +15091,16 @@
|
||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nano-time": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
|
||||
"integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"big-integer": "^1.6.16"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoassert": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-1.1.0.tgz",
|
||||
@ -15664,6 +15776,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/oblivious-set": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
|
||||
"integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
@ -17655,6 +17774,33 @@
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-query": {
|
||||
"version": "3.39.0",
|
||||
"resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.0.tgz",
|
||||
"integrity": "sha512-Od0IkSuS79WJOhzWBx/ys0x13+7wFqgnn64vBqqAAnZ9whocVhl/y1padD5uuZ6EIkXbFbInax0qvY7zGM0thA==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"broadcast-channel": "^3.4.1",
|
||||
"match-sorter": "^6.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz",
|
||||
@ -18289,6 +18435,13 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/remove-accents": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
|
||||
"integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/repeat-element": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
|
||||
@ -21169,6 +21322,17 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unload": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
|
||||
"integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.6.2",
|
||||
"detect-node": "^2.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@ -21273,6 +21437,11 @@
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
|
||||
"integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0="
|
||||
},
|
||||
"node_modules/urlsafe-base64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz",
|
||||
"integrity": "sha1-I/iQaabGL0bPOh07ABac77kL4MY="
|
||||
},
|
||||
"node_modules/use": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
||||
@ -21586,6 +21755,44 @@
|
||||
"@zxing/text-encoding": "0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.5.0.tgz",
|
||||
"integrity": "sha512-JC0V9hzKTqlDYJ+LTZUXtW7B175qwwaqzbbMSWDxHWxZvd3xY0C2rcotMGDavub2nAAFw+sXTsqR65/KY2A5AQ==",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.1.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5",
|
||||
"urlsafe-base64": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push/node_modules/jwa": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
|
||||
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push/node_modules/jws": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.0",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-resource-inliner": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-5.0.0.tgz",
|
||||
@ -25252,6 +25459,15 @@
|
||||
"integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/web-push": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.3.2.tgz",
|
||||
"integrity": "sha512-JxWGVL/m7mWTIg4mRYO+A6s0jPmBkr4iJr39DqJpRJAc+jrPiEe1/asmkwerzRon8ZZDxaZJpsxpv0Z18Wo9gw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/yauzl": {
|
||||
"version": "2.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz",
|
||||
@ -25695,6 +25911,17 @@
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"requires": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"assert-never": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz",
|
||||
@ -26011,6 +26238,13 @@
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"big-integer": {
|
||||
"version": "1.6.51",
|
||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
|
||||
"integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"big.js": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
|
||||