Skip to content

Instantly share code, notes, and snippets.

@rphlmr
Created October 21, 2023 16:13
Show Gist options
  • Save rphlmr/5768a93ed82d0df31fa910ddbb1dfaaf to your computer and use it in GitHub Desktop.
Save rphlmr/5768a93ed82d0df31fa910ddbb1dfaaf to your computer and use it in GitHub Desktop.
PWA App install button handling native prompt or a fallback.
/**
* 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 &gt; 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 };
@yenche123
Copy link

cool! really useful!
tks😘

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment