Created
October 21, 2023 16:13
-
-
Save rphlmr/5768a93ed82d0df31fa910ddbb1dfaaf to your computer and use it in GitHub Desktop.
PWA App install button handling native prompt or a fallback.
This file contains 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
/** | |
* App Install Manager | |
* | |
* Author: @rphlmr | |
*/ | |
/** | |
* You will be surprised by the code below. | |
* | |
* `beforeinstallprompt` is an event really hard to work with 😵💫 | |
* | |
* It has to be be **listened only once**, by a unique effect in root.tsx, otherwise it will work badly. | |
* | |
* https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent | |
*/ | |
import { | |
type ReactElement, | |
createContext, | |
useContext, | |
useSyncExternalStore, | |
type ReactNode, | |
} from "react"; | |
import { ArrowDownCircleIcon, PlusSquareIcon } from "lucide-react"; | |
import { | |
isMacOs, | |
osName, | |
isIOS, | |
isAndroid, | |
isWindows, | |
} from "react-device-detect"; | |
import { Button } from "~/components/ui/button"; | |
import { | |
Popover, | |
PopoverContent, | |
PopoverTrigger, | |
} from "~/components/ui/popover"; | |
import { Typography } from "~/components/ui/typography"; | |
import { cn } from "~/utils/cn"; | |
import { isBrowser } from "~/utils/is-browser"; | |
type AppInstallManager = | |
| { | |
prompt: null; | |
isNativePromptAvailable: false; | |
} | |
| { | |
prompt: () => Promise<UserChoice>; | |
isNativePromptAvailable: true; | |
}; | |
/** | |
* This is the most reliable way (I found) to work with the `BeforeInstallPromptEvent` on the browser. | |
* | |
* We will implement what I call the 'external store pattern'. | |
*/ | |
const appInstallManagerStore: AppInstallManager = { | |
prompt: null, | |
isNativePromptAvailable: false, | |
}; | |
const AppInstallManagerContext = createContext<AppInstallManager | null>(null); | |
/** | |
* Use `BeforeInstallPromptEvent.prompt` to prompt the user to install the PWA. | |
* If the PWA is already installed by the current browser, `available` will always be false and `prompt` will always be null. | |
* | |
* [21/10/2023] | |
* | |
* ❌ On Safari and Firefox, `available` will always be false and `prompt` will always be null. | |
* These the browser does not support prompt to install, `beforeinstallprompt` event is not fired. | |
* https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent#browser_compatibility | |
* | |
* 🤷♂️ Arc Browser, even if it's based on Chromium, doesn't support prompt to install. | |
* `prompt` never moves from pending to resolved. | |
* | |
* @returns the BeforeInstallPromptEvent if available | |
*/ | |
const useAppInstallManager = () => { | |
const context = useContext(AppInstallManagerContext); | |
if (isBrowser && context === undefined) { | |
throw new Error( | |
`useAppInstallManager must be used within a XProvider.`, | |
); | |
} | |
return context; | |
}; | |
function checkIsArcBrowser() { | |
// https://webmasters.stackexchange.com/a/142231/138612 | |
return window | |
.getComputedStyle(document.documentElement) | |
.getPropertyValue("--arc-palette-title") | |
? true | |
: false; | |
} | |
function subscribeToLoad(callback: () => void) { | |
async function delayCallback() { | |
// delay the callback to be sure everything is loaded | |
await new Promise((resolve) => setTimeout(resolve, 1000)); | |
callback(); | |
} | |
window.addEventListener("load", delayCallback); | |
return () => { | |
window.removeEventListener("load", delayCallback); | |
}; | |
} | |
function subscribeToBeforeInstallPrompt(callback: () => void) { | |
function saveInstallPrompt(event: Event) { | |
event.preventDefault(); | |
appInstallManagerStore.prompt = ( | |
event as BeforeInstallPromptEvent | |
).prompt.bind(event); | |
appInstallManagerStore.isNativePromptAvailable = true; | |
// FIXME: remove that | |
console.log("🔥 BEFORE INSTALL PROMPT 🔥"); | |
callback(); | |
} | |
window.addEventListener("beforeinstallprompt", saveInstallPrompt); | |
return () => { | |
window.removeEventListener("beforeinstallprompt", saveInstallPrompt); | |
}; | |
} | |
/** | |
* Use it in root.tsx | |
* Wrap `<Outlet />` with `<AppInstallManagerProvider>` | |
* | |
*/ | |
export const AppInstallManagerProvider = ({ | |
children, | |
}: { | |
children: ReactElement; | |
}) => { | |
const appInstallManager = useSyncExternalStore( | |
subscribeToBeforeInstallPrompt, | |
() => appInstallManagerStore, | |
() => null, | |
); | |
// 🚨 Some chrome based browsers don't support prompt to install even if they support the event. | |
// [21/10/2023] : ❌ Arc Browser | |
const isArcBrowser = useSyncExternalStore( | |
subscribeToLoad, | |
() => checkIsArcBrowser(), | |
() => false, | |
); | |
if (appInstallManager) { | |
if (isArcBrowser) { | |
appInstallManager.prompt = null; | |
appInstallManager.isNativePromptAvailable = false; | |
} | |
} | |
return ( | |
<AppInstallManagerContext.Provider value={appInstallManager}> | |
{children} | |
</AppInstallManagerContext.Provider> | |
); | |
}; | |
type Instructions = Record< | |
"macOS" | "iOS" | "android" | "linux" | "windows", | |
InstructionStep[] | |
>; | |
type InstructionStep = { | |
index: string; | |
step: ReactNode; | |
}; | |
const instructions = { | |
android: [{ index: "1️⃣", step: "Open this page in Chrome" }], | |
windows: [{ index: "1️⃣", step: "Open this page in Edge" }], | |
linux: [{ index: "1️⃣", step: "Open this page in Chromium or Chrome" }], | |
iOS: [ | |
{ index: "1️⃣", step: "Open this page in Safari" }, | |
{ | |
index: "2️⃣", | |
step: ( | |
<> | |
Click the Share button | |
<img | |
src="/static/misc/ios-share-icon.png" | |
alt="apple-share-icon" | |
className="h-5" | |
/> | |
in the Safari toolbar, then choose | |
<Typography | |
variant="inlineCode" | |
className="inline-flex items-center" | |
> | |
<PlusSquareIcon className="mr-2 h-5 w-5" /> | |
Add to home screen | |
</Typography> | |
</> | |
), | |
}, | |
{ | |
index: "3️⃣", | |
step: "Type the name that you want to use for the web app, then click Add.", | |
}, | |
], | |
macOS: [ | |
{ index: "1️⃣", step: "Open this page in Safari" }, | |
{ | |
index: "2️⃣", | |
step: ( | |
<> | |
From the menu bar, choose | |
<Typography variant="inlineCode"> | |
File > Add to Dock | |
</Typography> | |
. Or click the Share button | |
<img | |
src="/static/misc/macos-share-icon.png" | |
alt="apple-share-icon" | |
className="h-5 w-5" | |
/> | |
in the Safari toolbar, then choose | |
<Typography variant="inlineCode">Add to Dock</Typography> | |
</> | |
), | |
}, | |
{ | |
index: "3️⃣", | |
step: ( | |
<> | |
Type the name that you want to use for the web app, then | |
click <Typography variant="inlineCode">Add</Typography>. | |
</> | |
), | |
}, | |
], | |
} satisfies Instructions; | |
function getInstructions() { | |
if (isMacOs) { | |
return instructions.macOS; | |
} | |
if (isIOS) { | |
return instructions.iOS; | |
} | |
if (isAndroid) { | |
return instructions.android; | |
} | |
if (osName === "Linux") { | |
return instructions.linux; | |
} | |
if (isWindows) { | |
return instructions.windows; | |
} | |
return []; | |
} | |
export function InstallAppButton({ animate = true }: { animate?: boolean }) { | |
const appInstallManager = useAppInstallManager(); | |
if (!appInstallManager) { | |
return null; | |
} | |
if (appInstallManager.isNativePromptAvailable) { | |
return ( | |
<Button | |
variant="outline" | |
className="truncate" | |
onClick={async () => { | |
await appInstallManager.prompt(); | |
}} | |
> | |
<ArrowDownCircleIcon | |
className={cn("mr-2 h-4 w-4", animate && "animate-bounce")} | |
/> | |
Install me | |
</Button> | |
); | |
} | |
const osInstructions = getInstructions(); | |
if (osInstructions.length === 0) { | |
return null; | |
} | |
return ( | |
<Popover> | |
<PopoverTrigger asChild> | |
<Button variant="outline" className="truncate"> | |
<ArrowDownCircleIcon | |
className={cn( | |
"mr-2 h-4 w-4", | |
animate && "animate-bounce", | |
)} | |
/> | |
Install me | |
</Button> | |
</PopoverTrigger> | |
<PopoverContent className="w-fit md:max-w-xl" side="right"> | |
<div className="flex flex-col gap-2"> | |
<Typography variant="mutedText"> | |
To install this app on your device, follow the | |
instructions below. | |
</Typography> | |
<div className="flex flex-col gap-2"> | |
{getInstructions().map(({ index, step }) => ( | |
<span | |
key={index} | |
className="inline-flex flex-wrap items-center gap-x-2 gap-y-1" | |
> | |
{index} {step} | |
</span> | |
))} | |
</div> | |
</div> | |
</PopoverContent> | |
</Popover> | |
); | |
} | |
/** | |
* This interface is experimental. | |
* | |
* https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent/BeforeInstallPromptEvent | |
* | |
*/ | |
interface BeforeInstallPromptEvent extends Event { | |
/** | |
* Allows a developer to show the install prompt at a time of their own choosing. | |
* This method returns a Promise. | |
*/ | |
prompt(): Promise<UserChoice>; | |
} | |
type UserChoice = { outcome: "accepted" | "dismissed"; platform: string }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
cool! really useful!
tks😘