freezmo/lib/install.ts
Martin Dvorak 26e36bc0c0 Add the Install the app bottom sheet for mobile browsers
- New InstallSheet mounted in the root layout: appears ~1s after load on
  iOS/Android phones and tablets, over any screen including the key gate,
  with a dimmed backdrop. Never on desktop, never in standalone mode, and
  at most once per 7 days after a dismissal (localStorage).
- Platform/browser detection (lib/install.ts): iPhone/iPad incl. iPadOS
  reporting as Mac with touch, Android, Safari vs Chrome vs other vs
  in-app browsers, with step 1 adapted accordingly.
- iOS: numbered steps with chips (Safari, Share, ... , More, Add to Home
  Screen) and the accent Add pill. Android: captured beforeinstallprompt
  turns into a native Install button; fallback steps use Install app /
  Add to Home screen chips and an accent Install pill.
- Close via X, backdrop, Escape or swipe-down on the handle; the site is
  untouched afterwards. Anchored to 100dvh so browser toolbars never hide
  the last step; pb-safe respects the home indicator.
- Dark sheet by default; light only when the app theme was explicitly set
  to light. Settings gains a mobile-only Install app row that reopens it.
- The inline head script captures beforeinstallprompt before hydration and
  records appinstalled. Old InstallHint banner removed.
- Tests: new install-sheet suite (45 checks across iPhone Safari/Chrome/
  in-app, Android Chrome/Samsung, desktop, prompt path, dismissal, 7-day
  expiry, standalone, Settings row, both themes); pwa suite updated.
2026-10-07 08:27:43 +00:00

165 lines
5.4 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
/**
* "Install the app" helpers: platform/browser detection plus the small amount
* of persistence the bottom sheet needs.
*
* Everything here is browser-only and side-effect free until called. The
* install prompt is never shown on desktop, never inside an installed app, and
* at most once per 7 days after a dismissal.
*/
export type InstallPlatform = "ios" | "android" | "other";
export type InstallBrowser = "safari" | "chrome" | "other" | "in-app";
/** Chrome's `beforeinstallprompt` event (not in the DOM typings). */
export type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
};
declare global {
interface Window {
__freezmoInstallPrompt?: BeforeInstallPromptEvent;
}
}
const DISMISS_KEY = "freezmo.install.dismissedAt";
const INSTALLED_KEY = "freezmo.install.installed";
const THEME_KEY = "freezmo.theme";
/** How long a dismissal silences the automatic prompt. */
export const INSTALL_REMIND_MS = 7 * 24 * 60 * 60 * 1000;
/** Fired by Settings to open the sheet manually (ignores the dismissal). */
export const OPEN_INSTALL_EVENT = "freezmo:open-install";
/** Fired by the inline head script when Chrome offers an install prompt. */
export const INSTALL_PROMPT_EVENT = "freezmo:installprompt";
/** Fired by the inline head script when the app gets installed. */
export const INSTALLED_EVENT = "freezmo:installed";
/**
* iPhone/iPad (including iPadOS 13+, which reports itself as a Mac — a Mac
* with more than one touch point is really an iPad) or Android. Everything
* else is desktop/unknown and never sees the prompt.
*/
export function detectPlatform(
ua: string = typeof navigator === "undefined" ? "" : navigator.userAgent,
maxTouchPoints: number = typeof navigator === "undefined" ? 0 : navigator.maxTouchPoints || 0,
): InstallPlatform {
if (/iPhone|iPad|iPod/i.test(ua)) return "ios";
if (/Macintosh/i.test(ua) && maxTouchPoints > 1) return "ios";
if (/Android/i.test(ua)) return "android";
return "other";
}
/** Embedded webviews (Instagram, Facebook, …) can't install anything. */
const IN_APP_PATTERN =
/(FBAN|FBAV|FB_IAB|Instagram|Line\/|Twitter|TikTok|SnapChat|Pinterest|MicroMessenger|GSA\/|; wv\))/i;
/**
* Which browser is this? Used to adapt step 1 of the instructions:
* iOS outside Safari (and Android outside Chrome) has to switch browsers
* first, and in-app browsers can't install at all.
*/
export function detectBrowser(
platform: InstallPlatform,
ua: string = typeof navigator === "undefined" ? "" : navigator.userAgent,
): InstallBrowser {
if (IN_APP_PATTERN.test(ua)) return "in-app";
if (platform === "ios") {
const otherIos = /(CriOS|FxiOS|EdgiOS|OPiOS|DuckDuckGo)/.test(ua);
return /Safari\//.test(ua) && !otherIos ? "safari" : "other";
}
if (platform === "android") {
const otherAndroid = /(SamsungBrowser|OPR\/|Firefox|EdgA|DuckDuckGo|YaBrowser|Vivaldi)/.test(ua);
return /Chrome\//.test(ua) && !otherAndroid ? "chrome" : "other";
}
return "other";
}
/** True when the app is already running from the home screen / app switcher. */
export function isStandalone(): boolean {
if (typeof window === "undefined") return false;
try {
const displayMode = window.matchMedia("(display-mode: standalone)").matches;
const iosStandalone =
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
return displayMode || iosStandalone;
} catch {
return false;
}
}
export function markInstalled(): void {
try {
window.localStorage.setItem(INSTALLED_KEY, "1");
} catch {
/* private mode */
}
}
export function isMarkedInstalled(): boolean {
try {
return window.localStorage.getItem(INSTALLED_KEY) === "1";
} catch {
return false;
}
}
/** Installed now, or recorded as installed earlier in this browser. */
export function isInstalled(): boolean {
return isStandalone() || isMarkedInstalled();
}
/** True while a previous dismissal is still within its 7-day window. */
export function isDismissed(now: number = Date.now()): boolean {
try {
const raw = window.localStorage.getItem(DISMISS_KEY);
if (!raw) return false;
const at = Number.parseInt(raw, 10);
return Number.isFinite(at) && now - at < INSTALL_REMIND_MS;
} catch {
return false;
}
}
export function dismissInstallPrompt(now: number = Date.now()): void {
try {
window.localStorage.setItem(DISMISS_KEY, String(now));
} catch {
/* private mode */
}
}
/** Opens the sheet manually (used by the Settings row). */
export function openInstallSheet(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(OPEN_INSTALL_EVENT));
}
/**
* The sheet follows the app's *explicit* theme choice: dark unless the user
* picked light in the app (a system preference alone doesn't count).
*/
export function installSheetTheme(): "dark" | "light" {
try {
return window.localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark";
} catch {
return "dark";
}
}
/**
* Settings helper: is the "Install app" row relevant here? (Mobile platform
* and not installed yet.) Starts false so server rendering never mismatches.
*/
export function useInstallAvailable(): boolean {
const [available, setAvailable] = useState(false);
useEffect(() => {
setAvailable(detectPlatform() !== "other" && !isInstalled());
}, []);
return available;
}