push notifications but installable to home screen yet
This commit is contained in:
@ -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 } });
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user