diff --git a/.prettierrc b/.prettierrc index 70c334c..b9a8520 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { "trailingComma": "es5", - "tabWidth": 4, + "tabWidth": 2, "semi": false, "singleQuote": false, "overrides": [ diff --git a/.swcrc b/.swcrc index 8c48af1..5b29ac2 100644 --- a/.swcrc +++ b/.swcrc @@ -1,31 +1,31 @@ { - "jsc": { - "target": "es2022", - "parser": { - "syntax": "typescript", - "tsx": true, - "decorators": false, - "dynamicImport": false - }, - "transform": { - "react": { - "pragma": "React.createElement", - "pragmaFrag": "React.Fragment", - "throwIfNamespace": true, - "development": false, - "useBuiltins": false, - "runtime": "automatic" - }, - "hidden": { - "jest": true - } - } + "jsc": { + "target": "es2022", + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false }, - "module": { - "type": "commonjs", - "strict": false, - "strictMode": true, - "lazy": false, - "noInterop": false + "transform": { + "react": { + "pragma": "React.createElement", + "pragmaFrag": "React.Fragment", + "throwIfNamespace": true, + "development": false, + "useBuiltins": false, + "runtime": "automatic" + }, + "hidden": { + "jest": true + } } + }, + "module": { + "type": "commonjs", + "strict": false, + "strictMode": true, + "lazy": false, + "noInterop": false + } } \ No newline at end of file diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 94d5dc0..8f13e78 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -4,9 +4,10 @@ * For more information, see https://remix.run/file-conventions/entry.client */ -import { RemixBrowser } from "@remix-run/react"; -import { startTransition, StrictMode } from "react"; -import { hydrateRoot } from "react-dom/client"; +import { RemixBrowser } from "@remix-run/react" +import React from "react" +import { startTransition, StrictMode } from "react" +import { hydrateRoot } from "react-dom/client" startTransition(() => { hydrateRoot( @@ -14,5 +15,5 @@ startTransition(() => { - ); -}); + ) +}) diff --git a/app/entry.server.tsx b/app/entry.server.tsx index c2fd369..a119799 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -4,16 +4,16 @@ * For more information, see https://remix.run/file-conventions/entry.server */ -import { PassThrough } from "node:stream"; +import { PassThrough } from "node:stream" -import type { AppLoadContext, EntryContext } from "@remix-run/node"; -import { createReadableStreamFromReadable } from "@remix-run/node"; -import { RemixServer } from "@remix-run/react"; -import { isbot } from "isbot"; -import { renderToPipeableStream } from "react-dom/server"; -import React from "react"; +import type { AppLoadContext, EntryContext } from "@remix-run/node" +import { createReadableStreamFromReadable } from "@remix-run/node" +import { RemixServer } from "@remix-run/react" +import { isbot } from "isbot" +import { renderToPipeableStream } from "react-dom/server" +import React from "react" -const ABORT_DELAY = 5_000; +const ABORT_DELAY = 5_000 export default function handleRequest( request: Request, @@ -37,7 +37,7 @@ export default function handleRequest( responseStatusCode, responseHeaders, remixContext - ); + ) } function handleBotRequest( @@ -47,7 +47,7 @@ function handleBotRequest( remixContext: EntryContext ) { return new Promise((resolve, reject) => { - let shellRendered = false; + let shellRendered = false const { pipe, abort } = renderToPipeableStream( , { onAllReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); + shellRendered = true + const body = new PassThrough() + const stream = createReadableStreamFromReadable(body) - responseHeaders.set("Content-Type", "text/html"); + responseHeaders.set("Content-Type", "text/html") resolve( new Response(stream, { headers: responseHeaders, status: responseStatusCode, }) - ); + ) - pipe(body); + pipe(body) }, onShellError(error: unknown) { - reject(error); + reject(error) }, onError(error: unknown) { - responseStatusCode = 500; + responseStatusCode = 500 // Log streaming rendering errors from inside the shell. Don't log // errors encountered during initial shell rendering since they'll // reject and get logged in handleDocumentRequest. if (shellRendered) { - console.error(error); + console.error(error) } }, } - ); + ) - setTimeout(abort, ABORT_DELAY); - }); + setTimeout(abort, ABORT_DELAY) + }) } function handleBrowserRequest( @@ -97,7 +97,7 @@ function handleBrowserRequest( remixContext: EntryContext ) { return new Promise((resolve, reject) => { - let shellRendered = false; + let shellRendered = false const { pipe, abort } = renderToPipeableStream( , { onShellReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); + shellRendered = true + const body = new PassThrough() + const stream = createReadableStreamFromReadable(body) - responseHeaders.set("Content-Type", "text/html"); + responseHeaders.set("Content-Type", "text/html") resolve( new Response(stream, { headers: responseHeaders, status: responseStatusCode, }) - ); + ) - pipe(body); + pipe(body) }, onShellError(error: unknown) { - reject(error); + reject(error) }, onError(error: unknown) { - responseStatusCode = 500; + responseStatusCode = 500 // Log streaming rendering errors from inside the shell. Don't log // errors encountered during initial shell rendering since they'll // reject and get logged in handleDocumentRequest. if (shellRendered) { - console.error(error); + console.error(error) } }, } - ); + ) - setTimeout(abort, ABORT_DELAY); - }); + setTimeout(abort, ABORT_DELAY) + }) } diff --git a/app/entry.worker.ts b/app/entry.worker.ts index c6e7d9a..10fa180 100644 --- a/app/entry.worker.ts +++ b/app/entry.worker.ts @@ -1,17 +1,17 @@ /// -export {}; +export {} -declare let self: ServiceWorkerGlobalScope; +declare let self: ServiceWorkerGlobalScope -self.addEventListener('install', event => { - console.log('Service worker installed'); +self.addEventListener("install", (event) => { + console.log("Service worker installed") - event.waitUntil(self.skipWaiting()); -}); + event.waitUntil(self.skipWaiting()) +}) -self.addEventListener('activate', event => { - console.log('Service worker activated'); +self.addEventListener("activate", (event) => { + console.log("Service worker activated") - event.waitUntil(self.clients.claim()); -}); + event.waitUntil(self.clients.claim()) +}) diff --git a/app/root.test.tsx b/app/root.test.tsx index a148c7c..f9ccd48 100644 --- a/app/root.test.tsx +++ b/app/root.test.tsx @@ -1,45 +1,47 @@ -import { act, render, screen, waitFor } from '@testing-library/react' -import MatchMediaMock from 'jest-matchmedia-mock'; +import { act, render, screen, waitFor } from "@testing-library/react" +import MatchMediaMock from "jest-matchmedia-mock" import { createRemixStub, RemixStubProps } from "@remix-run/testing" -import App from './root' -import React from 'react' +import App from "./root" +import React from "react" let RemixStub: (props: RemixStubProps) => React.JSX.Element describe("root", () => { - beforeEach(() => { - RemixStub = createRemixStub([ - { - path: "/", - meta: () => ([]), - links: () => ([]), - loader: () => ({ isSupported: true}), - Component: App - } - ]) - }) + beforeEach(() => { + RemixStub = createRemixStub([ + { + path: "/", + meta: () => [], + links: () => [], + loader: () => ({ isSupported: true }), + Component: App, + }, + ]) + }) - let matchMedia: MatchMediaMock + let matchMedia: MatchMediaMock - beforeAll(() => { - matchMedia = new MatchMediaMock(); - }) - - afterEach(() => { - matchMedia.clear(); - }) + beforeAll(() => { + matchMedia = new MatchMediaMock() + }) - describe("when user is on iOS", () => { - describe("version 16.4 or higher", () => { - describe("and tack up now is not already installed on their device", () => { - test("they are instructed to install tack up now", async () => { - render() + afterEach(() => { + matchMedia.clear() + }) - await waitFor(() => screen.findByText(/Install/), { timeout: 2000 }) + describe("when user is on iOS", () => { + describe("version 16.4 or higher", () => { + describe("and tack up now is not already installed on their device", () => { + test("they are instructed to install tack up now", async () => { + render() - expect(true).toBe(true) - }) - }) + await waitFor(() => screen.findByText(/Install/), { + timeout: 2000, + }) + + expect(true).toBe(true) }) + }) }) + }) }) diff --git a/app/root.tsx b/app/root.tsx index 5e2f692..edb223f 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,33 +1,30 @@ import { - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, } from "@remix-run/react" import React from "react" export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - {children} - - - - - ) + return ( + + + + + + + + + {children} + + + + + ) } export default function App() { - return + return } diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index aebee19..0f153da 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -1,52 +1,82 @@ -import { json, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; -import { useLoaderData } from "@remix-run/react"; -import React, { Suspense, useEffect } from "react"; -import UAParser from "ua-parser-js" -import versionAtLeast from "semver/functions/gte" +import { + json, + type LoaderFunctionArgs, + type MetaFunction, +} from "@remix-run/node" +import { useLoaderData } from "@remix-run/react" +import React, { useState } from "react" import coerceSemver from "semver/functions/coerce" +import versionAtLeast from "semver/functions/gte" +import UAParser from "ua-parser-js" +import { usePush } from "remix-pwa-monorepo/packages/push/client/hook" export const meta: MetaFunction = () => { return [ { title: "Tack Up Now!" }, { name: "description", content: "Get equinelive notifications" }, - ]; -}; + ] +} export const loader = async ({ request }: LoaderFunctionArgs) => { const userAgent = request.headers.get("user-agent") const os = new UAParser(userAgent ?? "").getOS() const isSupported = - os.name !== "iOS" || - versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0") + os.name !== "iOS" || + versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0") return json({ isSupported }) } export default function Index() { - const { isSupported } = useLoaderData() return (
- - - +
- ); + ) } - function LandingMessage({ isSupported }: { isSupported: boolean }) { + const isClient = typeof window !== "undefined" + const notificationsEnabled = + isClient && + "Notification" in window && + window.Notification.permission === "granted" - if (typeof window === "undefined") return
Loading
+ const isRunningPWA = + isClient && + (("standalone" in navigator && (navigator.standalone as boolean)) || + matchMedia("(dislay-mode: standalone)").matches) - const isRunningPWA = ("standalone" in navigator && navigator.standalone as boolean) || matchMedia("(dislay-mode: standalone)").matches - const notificationsEnabled = "Notification" in window && window.Notification.permission === "granted" - const message = isRunningPWA && !notificationsEnabled - ? "Enable notifications" - : isSupported - ? "Install Tack Up Now!" - : "Sorry, your device doesn't support Tack Up Now! :(" + const [isInstalled, setIsInstalled] = useState(notificationsEnabled) - return
{message}
+ return !isClient ? ( +
Loading
+ ) : isInstalled ? ( +
Your Notifications
+ ) : isRunningPWA && !notificationsEnabled ? ( + setIsInstalled(true)} /> + ) : isSupported ? ( + "Install Tack Up Now!" + ) : ( + "Sorry, your device doesn't support Tack Up Now! :(" + ) +} + +function EnableButton({ onSubscribe }: { onSubscribe: () => void }) { + const { subscribeToPush, requestPermission } = usePush() + + return + + async function subscribe() { + console.log("Hey the thing was clicked wow") + subscribeToPush("Derpderp", (subscription) => { + fetch("/subscribe", { + method: "POST", + body: JSON.stringify(subscription), + }) + onSubscribe() + }) + } } diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts index 305e265..3a86133 100644 --- a/e2e/install.spec.ts +++ b/e2e/install.spec.ts @@ -1,76 +1,186 @@ -import { test, expect } from "@playwright/test" +import { test, expect, Page } from "@playwright/test" +import { before } from "node:test" const { describe, beforeEach, skip, use } = test function webkitOnly() { - beforeEach(async ({ browserName }) => { - if (browserName !== "webkit") skip() - }) + beforeEach(async ({ browserName }) => { + if (browserName !== "webkit") skip() + }) } describe("when user is on iOS", () => { - webkitOnly() + webkitOnly() - describe("version 16.4 or higher", () => { - use({ - userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", - }) - - test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({ page }) => { - await page.goto("/") - - const installText = await page.getByText(/Install Tack Up Now!/) - - await expect(installText).toBeAttached() - }) - - describe("and tack up now is running as a PWA", () => { - - beforeEach(async ({ page }) => { - await page.addInitScript(() => (window.navigator as any)["standalone"] = true) - }) - - test("and notifications aren't enabled, they are asked to enable notifications", async ({ page, browser }) => { - await page.addInitScript(() => (Object.defineProperty(window.Notification, "permission", {value: "default", writable: true }))) - await page.goto("/") - - const notificationText = await page.getByText(/Enable notifications/) - - await expect(notificationText).toBeAttached() - }) - - describe("and notifications are enabled", () => { - - beforeEach(async ({ page }) => { - await page.addInitScript(() => { - window.Notification = window.Notification ?? {} - Object.defineProperty(window.Notification, "permission", {value: "granted", writable: true }) - }) - }) - - test("they aren't asked to enable notifications", async ({ page }) => { - await page.goto("/") - await page.evaluate(async () => await navigator.serviceWorker.ready) - - const notificationText = await page.getByText(/Enable notifications/) - - await expect(notificationText).not.toBeAttached() - }) - }) - }) + describe("version 16.4 or higher", () => { + use({ + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", }) - describe("version 16.3 and under", () => { - use({ - userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({ + page, + }) => { + await page.goto("/") + + const installText = await page.getByText(/Install Tack Up Now!/) + + await expect(installText).toBeAttached() + }) + + describe("and tack up now is running as a PWA", () => { + beforeEach(async ({ page }) => { + await page.addInitScript( + () => ((window.navigator as any)["standalone"] = true) + ) + await stubServiceWorker(page) + }) + + describe("and notifications aren't enabled", () => { + test("they are asked to enable notifications", async ({ page }) => { + await stubNotifications(page, { permission: "default" }) + await page.goto("/") + + const enableButton = await page.getByText(/Enable notifications/) + + await expect(enableButton).toBeAttached() }) - test("version 16.3 and under they are informed that their iOS version isn't supported", async ({ page }) => { + describe("and then the user enables notifications", () => { + beforeEach(async ({ page }) => { + await stubNotifications(page, { + permission: "default", + requestPermissionResult: "granted", + }) + await page.route("/subscribe", async (route) => { + await route.fulfill() + }) + }) + + test("their push token is submitted after notifications are enabled", async ({ + page, + }) => { await page.goto("/") - const sorryText = await page.getByText(/Sorry/) + const requestPromise = page.waitForRequest("/subscribe") + await page.getByText(/Enable notifications/).click() + const request = await requestPromise - await expect(sorryText).toBeVisible() + await expect(request.postDataJSON()).toEqual({ hi: "subscription" }) + }) + + test("users see tack up now after enabling notifications", async ({ + page, + }) => { + await page.goto("/") + + const requestPromise = page.waitForRequest("/subscribe") + await page.getByText(/Enable notifications/).click() + await requestPromise + + const yourNotificationsHeading = + await page.getByText(/Your Notifications/) + + await expect(yourNotificationsHeading).toBeVisible() + }) }) + }) + + describe("and notifications are enabled", () => { + beforeEach(async ({ page }) => { + await stubNotifications(page, { permission: "granted" }) + }) + + test("they aren't asked to enable notifications", async ({ page }) => { + await page.goto("/") + await page.evaluate(async () => await navigator.serviceWorker.ready) + + const notificationText = await page.getByText(/Enable notifications/) + + await expect(notificationText).not.toBeAttached() + }) + + test("users see tack up now", async ({ page }) => { + await page.goto("/") + + const yourNotificationsHeading = + await page.getByText(/Your Notifications/) + + await expect(yourNotificationsHeading).toBeVisible() + }) + }) }) + }) + + describe("version 16.3 and under", () => { + use({ + userAgent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", + }) + + test("version 16.3 and under they are informed that their iOS version isn't supported", async ({ + page, + }) => { + await page.goto("/") + + const sorryText = await page.getByText(/Sorry/) + + await expect(sorryText).toBeVisible() + }) + }) }) + +function stubNotifications( + page: Page, + args: { + permission: NotificationPermission + requestPermissionResult?: NotificationPermission + } +) { + return page.addInitScript( + (args: { + permission: NotificationPermission + requestPermissionResult?: NotificationPermission + }) => { + window.Notification = window.Notification ?? {} + Object.defineProperty(window.Notification, "permission", { + value: args.permission, + writable: true, + }) + Object.defineProperty(window.Notification, "requestPermission", { + value: () => + Promise.resolve(args.requestPermissionResult ?? args.permission), + }) + }, + args + ) +} + +function stubServiceWorker(page: Page) { + return page.addInitScript(() => { + const registration = { + pushManager: { + getSubscription() { + return Promise.resolve({ hi: "subscription" }) + }, + subscribe(args: Parameters[0]) { + return Promise.resolve({ hi: "subscription" }) + }, + }, + } + + Object.defineProperty(navigator, "serviceWorker", { + value: { + register() { + return Promise.resolve(registration) + }, + getRegistration() { + return Promise.resolve(registration) + }, + addEventListener() {}, + removeEventListener() {}, + }, + writable: false, + }) + }) +} diff --git a/package-lock.json b/package-lock.json index af9ffee..dd9b890 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "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" @@ -14603,6 +14604,25 @@ "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", diff --git a/package.json b/package.json index da54dc4..059663d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "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" @@ -74,4 +75,4 @@ "engines": { "node": ">=18.0.0" } -} +} \ No newline at end of file diff --git a/server.ts b/server.ts index ac711c1..f368ead 100644 --- a/server.ts +++ b/server.ts @@ -11,10 +11,6 @@ app.get("/api", (req, res) => res.send("HI")) // and your app is "just a request handler" app.all("*", createRequestHandler({ build })) -import { register } from "register-service-worker"; - -register(`/service-worker.js`) - app.listen(3000, () => { console.log("App listening on http://localhost:3000") }) diff --git a/transform.js b/transform.js index b60b3c8..7a87bb0 100644 --- a/transform.js +++ b/transform.js @@ -12,13 +12,13 @@ import baseConfig from "./thebabel.config.cjs" let metaPlugin = ({ types: t }) => ({ visitor: { MetaProperty: (path) => { - path.replaceWith(t.identifier("undefined")); + path.replaceWith(t.identifier("undefined")) }, }, -}); +}) export default babelJest.createTransformer({ babelrc: false, ...baseConfig, plugins: [...baseConfig.plugins, metaPlugin], -}); \ No newline at end of file +}) diff --git a/vite.config.ts b/vite.config.ts index 53183cb..3715143 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,14 +2,10 @@ import { vitePlugin as remix } from "@remix-run/dev" import { installGlobals } from "@remix-run/node" import { defineConfig } from "vite" import tsconfigPaths from "vite-tsconfig-paths" -import { remixPWA } from '@remix-pwa/dev' +import { remixPWA } from "@remix-pwa/dev" installGlobals() export default defineConfig({ - plugins: [ - remix(), - tsconfigPaths(), - remixPWA() - ], + plugins: [remix(), tsconfigPaths(), remixPWA()], })