diff --git a/app/ClientOnly.tsx b/app/ClientOnly.tsx new file mode 100644 index 0000000..b436933 --- /dev/null +++ b/app/ClientOnly.tsx @@ -0,0 +1,19 @@ +import { ReactNode, useEffect, useState } from "react" + +export default function ClientOnly({ + fallback, + children, +}: { + fallback: ReactNode + children(): ReactNode +}) { + const isClient = typeof window !== "undefined" + + const [rendered, setRendered] = useState(false) + + useEffect(() => { + setRendered(true) + }, []) + + return isClient && rendered ? children() : fallback +} diff --git a/app/install/EnableNotifications.tsx b/app/install/EnableNotifications.tsx index ee6112c..aa5a09a 100644 --- a/app/install/EnableNotifications.tsx +++ b/app/install/EnableNotifications.tsx @@ -1,6 +1,6 @@ import React from "react" import { useEffect } from "react" -import { usePush } from "remix-pwa-monorepo/packages/push/client/hook" +import { usePush } from "../usePush" export default function EnableNotifications({ onSubscribe, diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index cef4d1c..439eb00 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -10,6 +10,7 @@ import versionAtLeast from "semver/functions/gte" import UAParser from "ua-parser-js" import InstallPrompts from "../install/InstallPrompts" import useInstallState from "../useInstallState" +import ClientOnly from "../ClientOnly" export const meta: MetaFunction = () => { return [ @@ -22,16 +23,24 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const userAgent = request.headers.get("user-agent") const parsedUserAgent = new UAParser(userAgent ?? "") const os = parsedUserAgent.getOS() - const isMobileSafari = parsedUserAgent.getBrowser().name !== "Mobile Safari" + const isMobileSafari = parsedUserAgent.getBrowser().name === "Mobile Safari" const isSupported = os.name !== "iOS" || versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0") - return json({ isSupported, isMobileSafari }) + return json({ + isSupported, + isMobileSafari, + name: os.name, + version: os.version, + }) } export default function Index() { - const { isSupported, isMobileSafari } = useLoaderData() + const { isSupported, isMobileSafari, name, version } = + useLoaderData() + + console.log(name, version) return (
@@ -54,23 +63,22 @@ function LandingMessage({ }) { const { installed } = useInstallState({ isSupported, isMobileSafari }) - const [rendered, setRendered] = useState(false) const [isInstalled, setIsInstalled] = useState(installed) - useEffect(() => { - setRendered(true) - }, []) - - return !rendered ? ( -
Loading
- ) : isInstalled ? ( -
Your Notifications
- ) : ( - setIsInstalled(true)} - /> + return ( + Loading
}> + {() => + isInstalled ? ( +
Your Notifications
+ ) : ( + setIsInstalled(true)} + /> + ) + } + ) } diff --git a/app/useInstallState.ts b/app/useInstallState.ts index 68a61cb..19f9961 100644 --- a/app/useInstallState.ts +++ b/app/useInstallState.ts @@ -1,4 +1,4 @@ -import { usePush } from "remix-pwa-monorepo/packages/push/client/hook" +import { usePush } from "./usePush" type IOSInstallStep = | "loading" @@ -14,7 +14,7 @@ export default function useInstallState({ isSupported: boolean isMobileSafari: boolean }) { - const isClient = typeof window !== undefined + const isClient = typeof window !== "undefined" if (!isClient) return { @@ -34,13 +34,15 @@ export default function useInstallState({ matchMedia("(dislay-mode: standalone)").matches return { - step: !isMobileSafari - ? "open safari" - : !isRunningPWA && isMobileSafari - ? "install" - : !notificationsEnabled - ? "enable notifications" - : (null as IOSInstallStep | null), + step: !isSupported + ? "unsupported" + : !isMobileSafari + ? "open safari" + : !isRunningPWA && isMobileSafari + ? "install" + : !notificationsEnabled + ? "enable notifications" + : (null as IOSInstallStep | null), installed: notificationsEnabled, } } diff --git a/app/usePush.ts b/app/usePush.ts new file mode 100644 index 0000000..d08dd9b --- /dev/null +++ b/app/usePush.ts @@ -0,0 +1,183 @@ +import { useEffect, useState } from "react" + +export type PushObject = { + /** + * Boolean state indicating whether the user is subscribed to push notifications or not. + */ + isSubscribed: boolean + /** + * The push subscription object + */ + pushSubscription: PushSubscription | null + /** + * Request permission for push notifications + * @returns The permission status of the push notifications + */ + requestPermission: () => NotificationPermission + /** + * Utility to subscribe to push notifications + * @param publicKey the public vapid key + * @param callback a callback function to be called when the subscription is successful + * @param errorCallback a callback function to be called if the subscription fails + */ + subscribeToPush: ( + publicKey: string, + callback?: (subscription: PushSubscription) => void, + errorCallback?: (error: any) => void + ) => void + /** + * Utility to unsubscribe from push notifications + * @param callback a callback function to be called when the unsubscription is successful + * @param errorCallback a callback function to be called if the unsubscription fails + */ + unsubscribeFromPush: ( + callback?: () => void, + errorCallback?: (error: any) => void + ) => void + /** + * Boolean state indicating whether the user has allowed sending of push notifications or not. + */ + canSendPush: boolean +} + +/** + * Push API hook - contains all your necessities for handling push notifications in the client + */ +export const usePush = (): PushObject => { + const [swRegistration, setSWRegistration] = + useState(null) + const [isSubscribed, setIsSubscribed] = useState(false) + const [pushSubscription, setPushSubscription] = + useState(null) + const [canSendPush, setCanSendPush] = useState(false) + + const requestPermission = () => { + if (canSendPush) return "granted" + + Notification.requestPermission().then((permission) => { + if (permission === "granted") { + setCanSendPush(true) + return permission + } else { + setCanSendPush(false) + return permission + } + }) + + return "default" + } + + const subscribeToPush = ( + publicKey: string, + callback?: (subscription: PushSubscription) => void, + errorCallback?: (error: any) => void + ) => { + if (swRegistration === null || swRegistration.pushManager === undefined) + return + + swRegistration.pushManager + .subscribe({ + userVisibleOnly: true, + applicationServerKey: publicKey, + }) + .then( + (subscription) => { + setIsSubscribed(true) + setPushSubscription(subscription) + callback && callback(subscription) + }, + (error) => { + errorCallback && errorCallback(error) + } + ) + } + + const unsubscribeFromPush = ( + callback?: () => void, + errorCallback?: (error: any) => void + ) => { + if (swRegistration === null || swRegistration.pushManager === undefined) + return + + swRegistration.pushManager + .getSubscription() + .then((subscription) => { + if (subscription) { + subscription.unsubscribe().then( + () => { + setIsSubscribed(false) + setPushSubscription(null) + callback && callback() + }, + (error) => { + errorCallback && errorCallback(error) + } + ) + } + }) + .catch((error) => { + errorCallback && errorCallback(error) + }) + } + + useEffect(() => { + if (typeof window === "undefined") return + + const getRegistration = async () => { + if ("serviceWorker" in navigator) { + try { + const _registration = await navigator.serviceWorker.getRegistration() + setSWRegistration(_registration ?? null) + } catch (err) { + console.error("Error getting service worker registration:", err) + } + } else { + console.warn("Service Workers are not supported in this browser.") + } + } + + const handleControllerChange = () => { + getRegistration() + } + + if ("serviceWorker" in navigator) { + navigator.serviceWorker.addEventListener( + "controllerchange", + handleControllerChange + ) + } + + getRegistration() + + return () => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker.removeEventListener( + "controllerchange", + handleControllerChange + ) + } + } + }, []) + + useEffect(() => { + if (swRegistration && swRegistration.pushManager !== undefined) { + swRegistration.pushManager.getSubscription().then((subscription) => { + setIsSubscribed(!!subscription) + setPushSubscription(subscription) + }) + + Notification.permission === "granted" + ? setCanSendPush(true) + : setCanSendPush(false) + } + }, [swRegistration]) + + return { + isSubscribed, + pushSubscription, + requestPermission, + subscribeToPush, + unsubscribeFromPush, + canSendPush, + } +} diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts index 42ef9db..b295ad8 100644 --- a/e2e/install.spec.ts +++ b/e2e/install.spec.ts @@ -1,5 +1,4 @@ import { test, expect, Page } from "@playwright/test" -import { before } from "node:test" const { describe, beforeEach, skip, use } = test @@ -21,7 +20,7 @@ describe("when user is on iOS", () => { describe("and the browser is not safari", () => { use({ userAgent: - "Mozilla/5.0 (iPhone; U; CPU iPhone OS 5_1_1 like Mac OS X; en-gb) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3", + "Mozilla/5.0 (iPhone; U; CPU iPhone OS 16_4 like Mac OS X; en-gb) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3", }) test("the user is told they need to install through Safari, because reasons, I guess", async ({ @@ -29,9 +28,9 @@ describe("when user is on iOS", () => { }) => { await page.goto("/") - const installText = await page.getByText(/Safari/) + const safariText = await page.getByText(/Open tackupnow.com in Safari/) - await expect(installText).toBeAttached() + await expect(safariText).toBeAttached() }) }) @@ -58,7 +57,7 @@ describe("when user is on iOS", () => { await stubNotifications(page, { permission: "default" }) await page.goto("/") - const enableButton = await page.getByText(/Enable notifications/) + const enableButton = await page.getByText(/Enable Notifications/) await expect(enableButton).toBeAttached() }) @@ -80,7 +79,7 @@ describe("when user is on iOS", () => { await page.goto("/") const requestPromise = page.waitForRequest("/api/subscribe") - await page.getByText(/Enable notifications/).click() + await page.getByText(/Enable Notifications/).click() const request = await requestPromise await expect(request.postDataJSON()).toEqual({ hi: "subscription" }) @@ -92,7 +91,7 @@ describe("when user is on iOS", () => { await page.goto("/") const requestPromise = page.waitForRequest("/api/subscribe") - await page.getByText(/Enable notifications/).click() + await page.getByText(/Enable Notifications/).click() await requestPromise const yourNotificationsHeading = @@ -112,7 +111,7 @@ describe("when user is on iOS", () => { await page.goto("/") await page.evaluate(async () => await navigator.serviceWorker.ready) - const notificationText = await page.getByText(/Enable notifications/) + const notificationText = await page.getByText(/Enable Notifications/) await expect(notificationText).not.toBeAttached() }) diff --git a/package-lock.json b/package-lock.json index 5730915..e0c8b51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "site", + "hasInstallScript": true, "dependencies": { "@remix-pwa/sw": "^3.0.9", "@remix-pwa/worker-runtime": "^2.1.4", @@ -17,8 +18,6 @@ "isbot": "^4.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "remix-pwa-monorepo": "github:remix-pwa/monorepo#main", - "remix-utils": "^7.6.0", "semver": "^7.6.3", "ua-parser-js": "^1.0.39" }, @@ -14298,91 +14297,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/remix-pwa-monorepo": { - "resolved": "git+ssh://git@github.com/remix-pwa/monorepo.git#dda9d68b1c69642679d6ff17658f21fe24c668d6", - "license": "MIT", - "workspaces": [ - "packages/cli", - "packages/client", - "packages/dev", - "packages/eslint-config", - "packages/lint-staged-config", - "packages/push", - "packages/sw", - "packages/sync", - "packages/worker-runtime", - "playground" - ], - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/remix-utils": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-7.6.0.tgz", - "integrity": "sha512-BPhCUEy+nwrhDDDg2v3+LFSszV6tluMbeSkbffj2o4tqZxt5Kn69Y9sNpGxYLAj8gjqeYDuxjv55of+gYnnykA==", - "dependencies": { - "type-fest": "^4.3.3" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@remix-run/cloudflare": "^2.0.0", - "@remix-run/deno": "^2.0.0", - "@remix-run/node": "^2.0.0", - "@remix-run/react": "^2.0.0", - "@remix-run/router": "^1.7.2", - "crypto-js": "^4.1.1", - "intl-parse-accept-language": "^1.0.0", - "is-ip": "^5.0.1", - "react": "^18.0.0", - "zod": "^3.22.4" - }, - "peerDependenciesMeta": { - "@remix-run/cloudflare": { - "optional": true - }, - "@remix-run/deno": { - "optional": true - }, - "@remix-run/node": { - "optional": true - }, - "@remix-run/react": { - "optional": true - }, - "@remix-run/router": { - "optional": true - }, - "crypto-js": { - "optional": true - }, - "intl-parse-accept-language": { - "optional": true - }, - "is-ip": { - "optional": true - }, - "react": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/remix-utils/node_modules/type-fest": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz", - "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 5f58637..d9ad324 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "start": "remix-serve ./build/server/index.js", "typecheck": "tsc", "watch": "jest --watch --config=jest.config.ts", - "test": "jest --config=jest.config.ts" + "test": "jest --config=jest.config.ts", + "postinstall": "patch-package" }, "dependencies": { "@remix-pwa/sw": "^3.0.9", @@ -24,8 +25,6 @@ "isbot": "^4.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "remix-pwa-monorepo": "github:remix-pwa/monorepo#main", - "remix-utils": "^7.6.0", "semver": "^7.6.3", "ua-parser-js": "^1.0.39" }, @@ -76,4 +75,4 @@ "engines": { "node": ">=18.0.0" } -} +} \ No newline at end of file