From e763878622648ead77c865ffdf9faf718e385414 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Sat, 16 May 2026 21:24:19 -0700 Subject: [PATCH] feat: PWA install instructions modal for mobile (#952) --- apps/web/app/(app)/layout.tsx | 2 + apps/web/components/pwa-install-prompt.tsx | 419 +++++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 apps/web/components/pwa-install-prompt.tsx diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx index 33c88e04..b2a27e07 100644 --- a/apps/web/app/(app)/layout.tsx +++ b/apps/web/app/(app)/layout.tsx @@ -2,12 +2,14 @@ import { EnsureWorkspace } from "@/components/ensure-workspace" import { NextAppResearchCta } from "@/components/next-app-research-cta" +import { PWAInstallPrompt } from "@/components/pwa-install-prompt" export default function AppLayout({ children }: { children: React.ReactNode }) { return ( <> {children} + ) } diff --git a/apps/web/components/pwa-install-prompt.tsx b/apps/web/components/pwa-install-prompt.tsx new file mode 100644 index 00000000..b7776825 --- /dev/null +++ b/apps/web/components/pwa-install-prompt.tsx @@ -0,0 +1,419 @@ +"use client" + +import { useState, useEffect, useCallback, useRef } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import { XIcon, Brain, Sparkles, Globe } from "lucide-react" +import { GradientLogo } from "@ui/assets/Logo" +import { AnimatePresence, motion } from "motion/react" + +const PWA_DISMISS_KEY = "pwa-install-dismissed" +const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +type DeviceInfo = { + isIOS: boolean + isAndroid: boolean + isSafari: boolean + isChrome: boolean +} + +/** In-memory fallback when localStorage is unavailable */ +let memoryDismissed = false + +function getDeviceInfo(): DeviceInfo { + if (typeof window === "undefined") + return { isIOS: false, isAndroid: false, isSafari: false, isChrome: false } + const ua = navigator.userAgent + const isIOS = + /iPad|iPhone|iPod/.test(ua) || + (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) + const isAndroid = /Android/.test(ua) + + // Safari: contains "Safari" but not "CriOS", "FxiOS", "Chrome", "Edg", etc. + const isSafari = + /Safari/.test(ua) && !/CriOS|FxiOS|Chrome|Chromium|Edg|OPR|Opera/.test(ua) + // Chrome on Android: contains "Chrome" but not "Edg", "OPR", "Opera" + const isChrome = /Chrome/.test(ua) && !/Edg|OPR|Opera/.test(ua) + + return { isIOS, isAndroid, isSafari, isChrome } +} + +function isStandalone() { + if (typeof window === "undefined") return false + return ( + window.matchMedia("(display-mode: standalone)").matches || + (window.navigator as unknown as { standalone?: boolean }).standalone === + true + ) +} + +function isDismissed() { + if (typeof window === "undefined") return true + if (memoryDismissed) return true + try { + const dismissed = localStorage.getItem(PWA_DISMISS_KEY) + if (!dismissed) return false + const timestamp = Number.parseInt(dismissed, 10) + if (Date.now() - timestamp < DISMISS_DURATION_MS) return true + localStorage.removeItem(PWA_DISMISS_KEY) + return false + } catch { + return false + } +} + +const FEATURES = [ + { icon: Brain, label: "AI-powered memory" }, + { icon: Sparkles, label: "Chat with Nova" }, + { icon: Globe, label: "Access anywhere" }, +] + +export function PWAInstallPrompt() { + const [show, setShow] = useState(false) + const [device, setDevice] = useState({ + isIOS: false, + isAndroid: false, + isSafari: false, + isChrome: false, + }) + const [nativePrompt, setNativePrompt] = + useState(null) + const panelRef = useRef(null) + + useEffect(() => { + const info = getDeviceInfo() + setDevice(info) + + // Listen for the native install prompt (Chromium browsers on Android) + const handleBeforeInstall = (e: Event) => { + e.preventDefault() + setNativePrompt(e as BeforeInstallPromptEvent) + } + window.addEventListener("beforeinstallprompt", handleBeforeInstall) + + const isMobile = info.isIOS || info.isAndroid + if (isMobile && !isStandalone() && !isDismissed()) { + const timer = setTimeout(() => setShow(true), 1500) + return () => { + clearTimeout(timer) + window.removeEventListener("beforeinstallprompt", handleBeforeInstall) + } + } + return () => { + window.removeEventListener("beforeinstallprompt", handleBeforeInstall) + } + }, []) + + const dismiss = useCallback(() => { + setShow(false) + memoryDismissed = true + try { + localStorage.setItem(PWA_DISMISS_KEY, Date.now().toString()) + } catch {} + }, []) + + // Escape key handler + useEffect(() => { + if (!show) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") dismiss() + } + document.addEventListener("keydown", handler) + return () => document.removeEventListener("keydown", handler) + }, [show, dismiss]) + + // Focus the panel when shown for accessibility + useEffect(() => { + if (show && panelRef.current) { + panelRef.current.focus() + } + }, [show]) + + const handleInstall = useCallback(async () => { + if (nativePrompt) { + nativePrompt.prompt() + const { outcome } = await nativePrompt.userChoice + if (outcome === "accepted") { + setShow(false) + } + setNativePrompt(null) + } + dismiss() + }, [nativePrompt, dismiss]) + + /** + * Determine which instructions to show: + * - iOS + Safari → standard iOS steps + * - iOS + non-Safari → "open in Safari" hint + * - Android + Chrome (with native prompt) → trigger native install + * - Android + Chrome (no native prompt) → manual Chrome steps + * - Android + non-Chrome → "open in Chrome" hint + */ + const renderSteps = () => { + if (device.isIOS) { + if (device.isSafari) return + return + } + // Android + if (device.isChrome) return + return + } + + return ( + + {show && ( + + e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="pwa-install-title" + tabIndex={-1} + className={cn( + "w-full max-w-lg bg-[#1B1F24] rounded-t-[22px] p-6 pb-8 flex flex-col gap-5 outline-none", + dmSansClassName(), + )} + style={{ + boxShadow: + "0 -4px 24px 0 rgba(0, 0, 0, 0.4), 0.5px 0.5px 0.5px 0 rgba(255, 255, 255, 0.08) inset", + }} + > + {/* Header */} +
+
+
+ +
+
+

+ Supermemory +

+

+ Your memories, wherever you are +

+
+
+ +
+ + {/* Feature pills */} +
+ {FEATURES.map((f) => ( +
+ + + {f.label} + +
+ ))} +
+ + {/* Install steps */} +
+

+ Install for a better experience +

+ {renderSteps()} +
+ + {/* Action */} +
+ +
+
+
+ )} +
+ ) +} + +function StepRow({ + step, + children, +}: { + step: number + children: React.ReactNode +}) { + return ( +
+ + {step} + + {children} +
+ ) +} + +function IOSSteps() { + return ( +
+ + Tap the share button{" "} + {" "} + in Safari + + + Scroll down and tap{" "} + Add to Home Screen + + + Tap Add to install + +
+ ) +} + +function IOSNonSafariSteps() { + return ( +
+ + Open this page in Safari + + + Tap the share button{" "} + + + + Tap Add to Home Screen + +
+ ) +} + +function AndroidSteps() { + return ( +
+ + Tap the menu button{" "} + {" "} + in Chrome + + + Tap Add to Home screen + + + Tap Install to confirm + +
+ ) +} + +function AndroidNonChromeSteps() { + return ( +
+ + Open this page in Chrome + + + Tap the menu button{" "} + + + + Tap Add to Home screen + +
+ ) +} + +function ShareIcon({ className }: { className?: string }) { + return ( + + ) +} + +function MoreVertIcon({ className }: { className?: string }) { + return ( + + ) +} + +/** + * Type for the `beforeinstallprompt` event (not yet in TS lib). + * @see https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent + */ +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise + userChoice: Promise<{ outcome: "accepted" | "dismissed" }> +} + +declare global { + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent + } +}