reactuse icon indicating copy to clipboard operation
reactuse copied to clipboard

FEAT: PWA relevant hook

Open childrentime opened this issue 9 months ago • 4 comments

childrentime avatar Apr 01 '25 08:04 childrentime

soon before may.

childrentime avatar Apr 01 '25 08:04 childrentime

That would be sooooo good 💯💯💯

Maybe you can team up with @DuCanhGH from https://serwist.pages.dev/

FleetAdmiralJakob avatar Apr 08 '25 01:04 FleetAdmiralJakob

I previously used it like this. Directly getting the service worker information to do some things. @DuCanhGH do you have any suggestions? I might not be so familiar with service workers right now, should I encapsulate @serwist/window?

import { useState, useEffect } from "react";

interface PwaStatus {
  isPwa: boolean;
  isStandalone: boolean;
  isInstallable: boolean;
  displayMode: string;
  installPromptEvent: any | null;
  promptInstall: () => Promise<boolean>;
}

interface ServiceWorkerStatus {
  isSupported: boolean;
  isRegistered: boolean;
  registration: ServiceWorkerRegistration | null;
  workerState: string | null;
  error: string | null;
}

interface UsePwaStatusResult {
  pwa: PwaStatus;
  serviceWorker: ServiceWorkerStatus;
}

interface ExtendedNavigator extends Navigator {
  standalone?: boolean;
}

/**
 * Hook to detect current PWA and Service Worker status
 * @returns PWA and Service Worker status information
 */
export const usePwaStatus = (): UsePwaStatusResult => {
  const [pwaStatus, setPwaStatus] = useState<Omit<PwaStatus, "promptInstall">>({
    isPwa: false,
    isStandalone: false,
    isInstallable: false,
    displayMode: "browser",
    installPromptEvent: null,
  });

  const [swStatus, setSwStatus] = useState<ServiceWorkerStatus>({
    isSupported: false,
    isRegistered: false,
    registration: null,
    workerState: null,
    error: null,
  });

  useEffect(() => {
    const isHttps = window.location.protocol === "https:";
    
    const checkPwaStatus = (): void => {
      const detectDisplayMode = (): string => {
        const modes = ["standalone", "fullscreen", "minimal-ui", "browser"];

        let currentMode = "browser";
        for (const mode of modes) {
          if (window.matchMedia(`(display-mode: ${mode})`).matches) {
            currentMode = mode;
            break;
          }
        }

        const extendedNavigator = navigator as ExtendedNavigator;
        if (extendedNavigator.standalone) {
          currentMode = "standalone";
        }

        return currentMode;
      };

      const displayMode = detectDisplayMode();

      const extendedNavigator = navigator as ExtendedNavigator;
      const isStandalone =
        displayMode === "standalone" ||
        displayMode === "fullscreen" ||
        extendedNavigator.standalone === true;

      const hasServiceWorker = "serviceWorker" in navigator;
      const hasManifest = Array.from(document.querySelectorAll("link")).some(
        (link) => link.rel === "manifest"
      );

      const isPwa = hasServiceWorker && hasManifest && isHttps;

      const isInstallable = "BeforeInstallPromptEvent" in window;

      setPwaStatus({
        isPwa,
        isStandalone,
        isInstallable,
        displayMode,
        installPromptEvent: null,
      });
    };

    const checkServiceWorker = async (): Promise<void> => {
      const updatePwaStatus = (isRegistered: boolean): void => {
        setPwaStatus((prev) => {
          const hasManifest = Array.from(
            document.querySelectorAll("link")
          ).some((link) => link.rel === "manifest");

          const isPwa = isRegistered && hasManifest && isHttps;

          return {
            ...prev,
            isPwa,
          };
        });
      };

      if (!("serviceWorker" in navigator)) {
        setSwStatus((prev) => ({ ...prev, isSupported: false }));
        return;
      }

      setSwStatus((prev) => ({ ...prev, isSupported: true }));

      try {
        const registrations = await navigator.serviceWorker.getRegistrations();
        const isRegistered = registrations.length > 0;
        const registration = isRegistered ? registrations[0] : null;

        let workerState: string | null = null;
        if (registration) {
          if (registration.installing) workerState = "installing";
          else if (registration.waiting) workerState = "waiting";
          else if (registration.active) workerState = "active";
        }

        setSwStatus((prev) => ({
          ...prev,
          isRegistered,
          registration,
          workerState,
        }));

        updatePwaStatus(isRegistered);

        if (registration) {
          registration.addEventListener("updatefound", () => {
            const newWorker = registration.installing;

            if (newWorker) {
              newWorker.addEventListener("statechange", () => {
                let updatedState: string | null = null;
                if (registration.installing) updatedState = "installing";
                else if (registration.waiting) updatedState = "waiting";
                else if (registration.active) updatedState = "active";

                setSwStatus((prev) => ({
                  ...prev,
                  workerState: updatedState,
                }));
              });
            }
          });
        }
      } catch (error) {
        setSwStatus((prev) => ({
          ...prev,
          error:
            error instanceof Error
              ? error.message
              : "Error checking Service Worker status",
        }));
      }
    };

    checkPwaStatus();
    checkServiceWorker();

    const displayModeHandler = (): void => {
      checkPwaStatus();
    };

    const modeMediaQueries = [
      "standalone",
      "fullscreen",
      "minimal-ui",
      "browser",
    ].map((mode) => {
      const mq = window.matchMedia(`(display-mode: ${mode})`);
      mq.addEventListener("change", displayModeHandler);
      return mq;
    });

    interface BeforeInstallPromptEvent extends Event {
      prompt: () => Promise<void>;
      userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
    }

    const beforeInstallPromptHandler = (e: Event): void => {
      e.preventDefault();
      setPwaStatus((prev) => ({
        ...prev,
        installPromptEvent: e as BeforeInstallPromptEvent,
        isInstallable: true,
      }));
    };

    window.addEventListener("beforeinstallprompt", beforeInstallPromptHandler);

    const appInstalledHandler = (): void => {
      setPwaStatus((prev) => ({
        ...prev,
        isPwa: true,
        isStandalone: true,
        installPromptEvent: null,
      }));
    };

    window.addEventListener("appinstalled", appInstalledHandler);

    return () => {
      modeMediaQueries.forEach((mq) => {
        mq.removeEventListener("change", displayModeHandler);
      });
      window.removeEventListener(
        "beforeinstallprompt",
        beforeInstallPromptHandler
      );
      window.removeEventListener("appinstalled", appInstalledHandler);
    };
  }, []);

  /**
   * Trigger PWA installation prompt
   * @returns Promise indicating whether installation was successful
   */
  const promptInstall = async (): Promise<boolean> => {
    const { installPromptEvent } = pwaStatus;
    if (!installPromptEvent) {
      return false;
    }

    interface BeforeInstallPromptEvent extends Event {
      prompt: () => Promise<void>;
      userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
    }

    const event = installPromptEvent as BeforeInstallPromptEvent;

    await event.prompt();

    const choiceResult = await event.userChoice;

    setPwaStatus((prev) => ({
      ...prev,
      installPromptEvent: null,
    }));

    return choiceResult.outcome === "accepted";
  };

  return {
    pwa: {
      ...pwaStatus,
      promptInstall,
    },
    serviceWorker: swStatus,
  };
};

childrentime avatar Apr 08 '25 07:04 childrentime

@childrentime I'd say that Serwist won't be of much help here, since @serwist/window doesn't have much utility besides being used with service workers written using Serwist. This looks to me a great React hook though!

DuCanhGH avatar Apr 18 '25 20:04 DuCanhGH