Last active
May 2, 2024 05:52
-
-
Save martinratinaud/d029f028d8088ef2c522bb6b226cee7e to your computer and use it in GitHub Desktop.
Make PWA installable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}), | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useContext, type Context } from 'react'; | |
import { PwaContext, type PwaContextValue } from '../providers/PwaProvider'; | |
const usePwa = () => useContext(PwaContext as Context<PwaContextValue>); | |
export default usePwa; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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