Skip to content

Instantly share code, notes, and snippets.

@martinratinaud
Last active May 2, 2024 05:52
Show Gist options
  • Save martinratinaud/d029f028d8088ef2c522bb6b226cee7e to your computer and use it in GitHub Desktop.
Save martinratinaud/d029f028d8088ef2c522bb6b226cee7e to your computer and use it in GitHub Desktop.
Make PWA installable
import React from 'react';
import { AppProps } from 'next/app';
import { PwaProvider, usePwa } from 'modules/pwa';
import InstallPwaPage from 'modules/pwa/pages/InstallPwaPage';
const AppWithHooks = ({ Component, pageProps }: AppProps) => {
const { setPageProps } = usePageProps();
const { isInApp, isMobile, isTablet } = usePwa();
if (!isInApp && (isMobile || isTablet) && process.env.NODE_ENV !== 'development') {
return <InstallPwaPage />;
}
return <Component {...pageProps} />;
};
function MyApp(props: AppProps) {
const { pageProps } = props;
return (
<PwaProvider>
<NotifierContainer />
<AppWithHooks {...props} />
</PwaProvider>
);
}
export default trpc.withTRPC(MyApp);
import React from 'react';
import classNames from 'classnames';
import usePwa from '../hooks/usePwa';
import { useTranslation } from 'modules/i18n'; // Make sure to import useTranslation
type InstallPwaInstructionsProps = React.HTMLAttributes<HTMLDivElement> & {
// TODO
};
const InstallPwaInstructions: React.FC<InstallPwaInstructionsProps> = ({ className, ...props }) => {
const { subscribing, isInApp, installApp, isMobile, isTablet, userAgent } = usePwa();
const { t } = useTranslation(); // Initialize useTranslation hook
const browserName = userAgent.browser.name;
if (isInApp) {
return null;
}
if (!isMobile && !isTablet) {
return null;
}
const renderInstructions = () => {
switch (browserName) {
case 'Mobile Chrome':
return <p>{userAgent.device.vendor === 'Apple' ? t('pwa.common:install.safari') : t('pwa.common:install.chrome')}</p>;
case 'Mobile Edge':
return <p>{t('pwa.common:install.edge')}</p>;
case 'Mobile Firefox':
return <p>{t('pwa.common:install.firefox')}</p>;
case 'Mobile Safari':
return <p>{t('pwa.common:install.safari')}</p>;
default:
return <p>{t('pwa.common:install.default')}</p>;
}
};
return (
<div className={classNames('text-center', className)} {...props}>
{t('pwa.common:install.intro')}
<div className="text-center p-5">
{installApp && (
<button className="btn mb-24" onClick={installApp}>
{subscribing ? <span className="loading"></span> : t('pwa.common:install.cta')}
</button>
)}
{renderInstructions()}
</div>
</div>
);
};
export default InstallPwaInstructions;
import React from 'react';
import SEO from 'modules/common/components/SEO';
import usePublicRuntimeConfig from 'modules/common/hooks/usePublicRuntimeConfig';
import Logo from 'modules/common/components/Logo';
import InstallPwaInstructions from '../components/InstallPwaInstructions';
import { LanguageSwitcher } from 'modules/i18n';
const InstallPwaPage = () => {
const { siteName } = usePublicRuntimeConfig();
return (
<>
<SEO />
<main className="bg-white dark:bg-slate-900 relative flex h-screen">
{/* Content */}
<div className="flex-1 flex flex-col items-center justify-center min-w-[50%]">
<Logo large shape="square" data-aos="zoom-y-out" data-aos-delay="50" />
<div className="mx-5 sm:mx-auto sm:w-full md:w-3/4 lg:w-2/3">
<h1 className="text-3xl text-slate-800 dark:text-slate-100 font-bold mb-6 text-center">{siteName}</h1>
<InstallPwaInstructions />
</div>
<LanguageSwitcher />
</div>
</main>
</>
);
};
export default InstallPwaPage;
import * as webPush from 'web-push';
class PushManager {
private static instance: PushManager;
private email: string;
private publicKey: string;
private privateKey: string;
constructor(email?: string, publicKey?: string, privateKey?: string) {
this.email = email || (process.env.WEB_PUSH_EMAIL as string);
this.publicKey = publicKey || (process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY as string);
this.privateKey = privateKey || (process.env.WEB_PUSH_PRIVATE_KEY as string);
webPush.setVapidDetails(`mailto:${this.email}`, this.publicKey, this.privateKey);
}
public static getInstance(email?: string, publicKey?: string, privateKey?: string): PushManager {
if (!PushManager.instance) {
PushManager.instance = new PushManager(email, publicKey, privateKey);
}
return PushManager.instance;
}
public async sendNotification(
subscription: webPush.PushSubscription,
payload: {
title?: string;
body?: string;
data?: {
url?: string;
[key: string]: any;
};
},
options: webPush.RequestOptions = {
TTL: 1 * 60 * 60, // Retry for X hours
}
): Promise<webPush.SendResult> {
const result = await webPush.sendNotification(subscription, JSON.stringify(payload), options);
return result;
}
}
export default PushManager;
import React from 'react';
import classNames from 'classnames';
import usePwa from '../hooks/usePwa';
import { useTranslation } from 'modules/i18n';
type PushSubscriptionFormProps = React.HTMLAttributes<HTMLDivElement> & {
// TODO
};
const PushSubscriptionForm: React.FC<PushSubscriptionFormProps> = ({ className, ...props }) => {
const { subscribed, subscribe, unsubscribe, subscribing } = usePwa();
const { t } = useTranslation();
return (
<div className={classNames('', className)} {...props}>
{subscribed ? (
<form>
<p>{t('pwa.common:subcribed.text')}</p>
<button type="button" onClick={unsubscribe} className="btn btn-error btn-outline mt-3">
{t('pwa.common:unsubscribe.cta')}
</button>
</form>
) : (
<form>
<p>{t('pwa.common:notsubcribed.text')}</p>
<button type="button" onClick={subscribe} className="btn btn-primary mt-3">
{subscribing && <span className="loading"></span>}
{t('pwa.common:subscribe.cta')}
</button>
</form>
)}
</div>
);
};
export default PushSubscriptionForm;
import { useUser } from 'modules/auth';
import useIsMobile from 'modules/common/hooks/useIsMobile';
import { useNotifier } from 'modules/notification';
import trpc from 'modules/trpc';
import type { AppProps } from 'next/app';
import PullToRefresh from 'pulltorefreshjs';
import React, { useEffect, useState } from 'react';
import ReactDOMServer from 'react-dom/server';
import { useTranslation } from 'modules/i18n';
import { MdArrowCircleUp } from 'react-icons/md';
import { UAParser } from 'ua-parser-js';
const parser = new UAParser();
declare global {
interface Window {
workbox: any; // Adjust according to the actual Workbox type if available
}
}
export type PwaContextValue = {
isInApp: boolean;
installApp?: () => void;
subscribed?: boolean;
subscribe: (event: React.MouseEvent<HTMLButtonElement>) => Promise<void>;
subscription?: PushSubscription | null;
subscribing: boolean;
unsubscribe: (event: React.MouseEvent<HTMLButtonElement>) => Promise<void>;
registerPush: ReturnType<typeof trpc.push.subscribe.useMutation>;
sendPush: (event: React.MouseEvent<HTMLButtonElement>) => Promise<void>;
userAgent: UAParser.IResult;
} & ReturnType<typeof useIsMobile>;
export const PwaContext = React.createContext<PwaContextValue | undefined>(undefined);
const base64ToUint8Array = (base64: string): Uint8Array => {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(b64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};
export default function PwaProvider({ children }: AppProps['pageProps'] & any) {
const [subscription, setSubscription] = useState<PushSubscription | null>();
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
const [subscribing, toggleSubscribing] = useState(true);
const [isInApp, toggleInApp] = useState(false);
const [installAppPromptEvent, setInstallAppPromptEvent] = useState<Event | null>();
const { notify } = useNotifier();
const { t } = useTranslation();
const mobileInfos = useIsMobile();
const userAgent = parser.getResult();
const { user } = useUser();
const { data: serverSubscription, isLoading: loadingServerSubscription } = trpc.push.getSubscription.useQuery(undefined, {
keepPreviousData: true,
});
const utils = trpc.useUtils();
const testNotification = trpc.push.testNotification.useMutation({});
const subscribeToPush = trpc.push.subscribe.useMutation({
async onSuccess(_data) {
notify('success', t('pwa.common:subscribeSuccess'), { id: 'subscribeToPush' });
utils.push.getSubscription.setData(undefined, () => subscription?.toJSON() as any);
toggleSubscribing(false);
},
async onError(error) {
notify('error', error.toString(), { id: 'subscribeToPushError' });
toggleSubscribing(false);
},
});
const unsubscribeFromPush = trpc.push.unsubscribe.useMutation({
async onSuccess(_data) {
notify('success', t('pwa.common:unsubscribeSuccess'), { id: 'unsubscribeFromPush' });
utils.push.getSubscription.setData(undefined, () => null);
toggleSubscribing(false);
},
async onError(error) {
notify('error', error.toString(), { id: 'unsubscribeFromPushError' });
toggleSubscribing(false);
},
});
const installApp = async () => {
if (!installAppPromptEvent) {
return;
}
(installAppPromptEvent as any).prompt();
const { outcome } = await (installAppPromptEvent as any).userChoice;
if (outcome === 'accepted') {
setInstallAppPromptEvent(null);
}
};
const registerServiceWorker = async () => {
const reg = await navigator.serviceWorker.ready;
setRegistration(reg);
const sub = await reg.pushManager.getSubscription();
if (sub && !(sub.expirationTime && Date.now() > sub.expirationTime - 5 * 60 * 1000)) {
setSubscription(sub);
} else {
setSubscription(null);
}
toggleSubscribing(false);
};
useEffect(() => {
if (!(typeof window !== 'undefined' && 'serviceWorker' in navigator && window.workbox !== undefined)) {
return;
}
const inApp =
window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone || document.referrer.includes('android-app://');
toggleInApp(inApp);
const wb = window.workbox;
// https://developers.google.com/web/tools/workbox/reference-docs/latest/module-workbox-window.Workbox#events
// wb.addEventListener('installed', (event: any) => console.log('pwa', event.type));
// wb.addEventListener('controlling', (event: any) => console.log('pwa', event.type));
// wb.addEventListener('activated', (event: any) => console.log('pwa', event.type));
// wb.addEventListener('waiting', (event: any) => console.log('pwa', event.type));
// wb.addEventListener('message', (event: any) => console.log('pwa', event.type));
// wb.addEventListener('redundant', (event: any) => console.log('pwa', event.type));
// wb.addEventListener('externalinstalled', (event: any) => console.log('pwa', event.type));
// wb.addEventListener('externalactivated', (event: any) => console.log('pwa', event.type));
registerServiceWorker();
window.addEventListener('beforeinstallprompt', setInstallAppPromptEvent);
if (inApp) {
PullToRefresh.init({
mainElement: 'body',
onRefresh() {
window.location.reload();
},
iconArrow: ReactDOMServer.renderToString(<MdArrowCircleUp className="size-8 h-8 w-8 mx-auto" />),
iconRefreshing: ReactDOMServer.renderToString(<span className="loading loading-ring" />),
shouldPullToRefresh: () => {
return !document.querySelector('.modal-overlay') && !window.scrollY;
},
});
}
if (!(navigator.serviceWorker as any)?.active) {
wb.register();
}
return () => {
window.removeEventListener('beforeinstallprompt', setInstallAppPromptEvent);
if (inApp) {
PullToRefresh.destroyAll();
}
};
}, []);
const subscribe = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
toggleSubscribing(true);
event.preventDefault();
if (Notification.permission !== 'granted') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
alert(t('pwa.common:enableNotifications'));
return;
}
}
try {
const sub = await registration?.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64ToUint8Array(process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY as string),
});
if (!sub) {
notify('error', t('pwa.common:subscribeError'));
unsubscribe(event);
return;
}
setSubscription(sub);
if (user) {
subscribeToPush.mutate({ subscription: sub.toJSON() as any });
} else {
notify('error', 'No user is connected');
unsubscribe(event);
}
} catch (e: any) {
console.log(e.toString());
if (e.toString().includes('permission denied')) {
notify('error', t('pwa.common:enableNotifications'));
} else {
notify('error', e.toString());
}
unsubscribe(event);
}
};
const unsubscribe = React.useCallback(
async (event?: React.MouseEvent<HTMLButtonElement>) => {
toggleSubscribing(true);
event?.preventDefault();
await subscription?.unsubscribe();
if (user) {
unsubscribeFromPush.mutate();
} else {
toggleSubscribing(false);
}
setSubscription(null);
},
[subscription, user, unsubscribeFromPush]
);
const sendPush = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
event.preventDefault();
if (subscription == null) {
alert(t('pwa.common:pushNotSubscribed'));
return;
}
testNotification.mutate({ subscription: subscription.toJSON() });
};
const subscribed = subscription === undefined ? true : !!subscription;
const hasServerSubscription = !!serverSubscription;
const localSubscriptionKey = subscription?.toJSON()?.keys?.p256dh;
const serverSubscriptionKey = (serverSubscription as any)?.keys?.p256dh;
const notSameSubscriptionKey = localSubscriptionKey !== serverSubscriptionKey;
React.useEffect(() => {
if (!loadingServerSubscription && (!hasServerSubscription || notSameSubscriptionKey) && subscribed && !subscribing) {
unsubscribe();
}
}, [loadingServerSubscription, subscribed, hasServerSubscription, subscribing, unsubscribe, notSameSubscriptionKey]);
return (
<PwaContext.Provider
value={{
isInApp,
installApp: installAppPromptEvent ? installApp : undefined,
subscribed,
subscribe,
subscription,
subscribing,
unsubscribe,
registerPush: subscribeToPush,
sendPush,
...mobileInfos,
userAgent,
}}
>
{children}
</PwaContext.Provider>
);
}
import z from 'modules/validation';
import { publicProcedure, router } from 'modules/trpc/server';
import PushManager from '../managers/PushManager';
import { UserManager } from 'modules/auth/server';
import { subscribeSchema } from '../validation/push';
export const pushRouter = router({
testNotification: publicProcedure.input(z.object({ subscription: z.any() })).mutation(async ({ input, ctx }) => {
const pushManager = new PushManager();
return pushManager.sendNotification(input.subscription as any, {
title: `Test push from server`,
body: 'Successful',
});
}),
getSubscription: publicProcedure.query(async ({ ctx }) => {
const user = await UserManager.getById(ctx.userId as number, { select: { web_push_subscription: true } });
return user?.web_push_subscription;
}),
subscribe: publicProcedure.input(subscribeSchema).mutation(async ({ input, ctx }) => {
return UserManager.update(ctx.userId as number, { web_push_subscription: input.subscription });
}),
unsubscribe: publicProcedure.mutation(async ({ ctx }) => {
return UserManager.update(ctx.userId as number, { web_push_subscription: null } as any);
}),
});
import { useContext, type Context } from 'react';
import { PwaContext, type PwaContextValue } from '../providers/PwaProvider';
const usePwa = () => useContext(PwaContext as Context<PwaContextValue>);
export default usePwa;
import z from 'modules/validation';
export const subscription_field = z.object({
endpoint: z.string(),
expirationTime: z.nullable(z.date()).optional(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
});
export const subscribeSchema = z.object({
subscription: subscription_field,
});
export type subscribeSchemaType = z.infer<typeof subscribeSchema>;
let worker = self as any as ServiceWorkerGlobalScope;
worker.addEventListener('install', () => {
worker.skipWaiting();
});
worker.addEventListener('push', (event) => {
let notifTitle: string = 'Push received';
let notificationOptions: NotificationOptions = {
body: 'Thanks for sending this push msg.',
icon: '/icons/android-chrome-192x192.png',
badge: '/icons/favicon-32x32.png',
vibrate: [100, 50, 100],
};
if (event.data) {
const eventDataAsString: string = event.data.text();
try {
const { title, ...notifOptions } = JSON.parse(eventDataAsString);
notifTitle = title;
notificationOptions = {
...notificationOptions,
...notifOptions,
};
} catch (e) {
console.info(e);
notificationOptions.body = eventDataAsString;
}
}
event.waitUntil(worker.registration.showNotification(notifTitle, notificationOptions));
});
worker.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close(); // Close the notification
// URL to navigate to
const notificationUrl = event.notification.data && event.notification.data.url ? event.notification.data.url : '/';
event.waitUntil(
worker.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Check if there's at least one client (browser tab) that is controlled by the service worker.
for (const client of clientList) {
if ('focus' in client) {
return client.focus().then((windowClient) => {
// If a URL is provided, navigate the client (browser tab) to the URL.
if (notificationUrl && 'navigate' in windowClient) {
windowClient.navigate(notificationUrl);
}
return windowClient;
});
}
}
// If no client is found that is controlled by the service worker, open a new client (browser tab).
if (worker.clients.openWindow) {
return worker.clients.openWindow(notificationUrl);
}
})
);
});
worker.addEventListener('notificationclose', (event: NotificationEvent) => {});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment