Compare commits

...

2 Commits

Author SHA1 Message Date
Jeff fd351360f1 iOS install instructions
Test / test (push) Failing after 42s Details
2024-09-21 18:21:45 -04:00
Jeff 16015491e4 Add service worker, add minimal instructions for enabling notifications
Test / test (push) Failing after 32s Details
2024-09-21 14:51:08 -04:00
14 changed files with 1304 additions and 409 deletions

View File

@ -1,6 +1,6 @@
{ {
"trailingComma": "es5", "trailingComma": "es5",
"tabWidth": 4, "tabWidth": 2,
"semi": false, "semi": false,
"singleQuote": false, "singleQuote": false,
"overrides": [ "overrides": [

54
.swcrc
View File

@ -1,31 +1,31 @@
{ {
"jsc": { "jsc": {
"target": "es2022", "target": "es2022",
"parser": { "parser": {
"syntax": "typescript", "syntax": "typescript",
"tsx": true, "tsx": true,
"decorators": false, "decorators": false,
"dynamicImport": false "dynamicImport": false
},
"transform": {
"react": {
"pragma": "React.createElement",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": false,
"runtime": "automatic"
},
"hidden": {
"jest": true
}
}
}, },
"module": { "transform": {
"type": "commonjs", "react": {
"strict": false, "pragma": "React.createElement",
"strictMode": true, "pragmaFrag": "React.Fragment",
"lazy": false, "throwIfNamespace": true,
"noInterop": false "development": false,
"useBuiltins": false,
"runtime": "automatic"
},
"hidden": {
"jest": true
}
} }
},
"module": {
"type": "commonjs",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
}
} }

View File

@ -4,9 +4,10 @@
* For more information, see https://remix.run/file-conventions/entry.client * For more information, see https://remix.run/file-conventions/entry.client
*/ */
import { RemixBrowser } from "@remix-run/react"; import { RemixBrowser } from "@remix-run/react"
import { startTransition, StrictMode } from "react"; import React from "react"
import { hydrateRoot } from "react-dom/client"; import { startTransition, StrictMode } from "react"
import { hydrateRoot } from "react-dom/client"
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(
@ -14,5 +15,5 @@ startTransition(() => {
<StrictMode> <StrictMode>
<RemixBrowser /> <RemixBrowser />
</StrictMode> </StrictMode>
); )
}); })

View File

@ -4,16 +4,16 @@
* For more information, see https://remix.run/file-conventions/entry.server * 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 type { AppLoadContext, EntryContext } from "@remix-run/node"
import { createReadableStreamFromReadable } from "@remix-run/node"; import { createReadableStreamFromReadable } from "@remix-run/node"
import { RemixServer } from "@remix-run/react"; import { RemixServer } from "@remix-run/react"
import { isbot } from "isbot"; import { isbot } from "isbot"
import { renderToPipeableStream } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server"
import React from "react"; import React from "react"
const ABORT_DELAY = 5_000; const ABORT_DELAY = 5_000
export default function handleRequest( export default function handleRequest(
request: Request, request: Request,
@ -37,7 +37,7 @@ export default function handleRequest(
responseStatusCode, responseStatusCode,
responseHeaders, responseHeaders,
remixContext remixContext
); )
} }
function handleBotRequest( function handleBotRequest(
@ -47,7 +47,7 @@ function handleBotRequest(
remixContext: EntryContext remixContext: EntryContext
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<RemixServer <RemixServer
context={remixContext} context={remixContext}
@ -56,38 +56,38 @@ function handleBotRequest(
/>, />,
{ {
onAllReady() { onAllReady() {
shellRendered = true; shellRendered = true
const body = new PassThrough(); const body = new PassThrough()
const stream = createReadableStreamFromReadable(body); const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html"); responseHeaders.set("Content-Type", "text/html")
resolve( resolve(
new Response(stream, { new Response(stream, {
headers: responseHeaders, headers: responseHeaders,
status: responseStatusCode, status: responseStatusCode,
}) })
); )
pipe(body); pipe(body)
}, },
onShellError(error: unknown) { onShellError(error: unknown) {
reject(error); reject(error)
}, },
onError(error: unknown) { onError(error: unknown) {
responseStatusCode = 500; responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log // Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll // errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest. // reject and get logged in handleDocumentRequest.
if (shellRendered) { if (shellRendered) {
console.error(error); console.error(error)
} }
}, },
} }
); )
setTimeout(abort, ABORT_DELAY); setTimeout(abort, ABORT_DELAY)
}); })
} }
function handleBrowserRequest( function handleBrowserRequest(
@ -97,7 +97,7 @@ function handleBrowserRequest(
remixContext: EntryContext remixContext: EntryContext
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<RemixServer <RemixServer
context={remixContext} context={remixContext}
@ -106,36 +106,36 @@ function handleBrowserRequest(
/>, />,
{ {
onShellReady() { onShellReady() {
shellRendered = true; shellRendered = true
const body = new PassThrough(); const body = new PassThrough()
const stream = createReadableStreamFromReadable(body); const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html"); responseHeaders.set("Content-Type", "text/html")
resolve( resolve(
new Response(stream, { new Response(stream, {
headers: responseHeaders, headers: responseHeaders,
status: responseStatusCode, status: responseStatusCode,
}) })
); )
pipe(body); pipe(body)
}, },
onShellError(error: unknown) { onShellError(error: unknown) {
reject(error); reject(error)
}, },
onError(error: unknown) { onError(error: unknown) {
responseStatusCode = 500; responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log // Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll // errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest. // reject and get logged in handleDocumentRequest.
if (shellRendered) { if (shellRendered) {
console.error(error); console.error(error)
} }
}, },
} }
); )
setTimeout(abort, ABORT_DELAY); setTimeout(abort, ABORT_DELAY)
}); })
} }

17
app/entry.worker.ts Normal file
View File

@ -0,0 +1,17 @@
/// <reference lib="WebWorker" />
export {}
declare let self: ServiceWorkerGlobalScope
self.addEventListener("install", (event) => {
console.log("Service worker installed")
event.waitUntil(self.skipWaiting())
})
self.addEventListener("activate", (event) => {
console.log("Service worker activated")
event.waitUntil(self.clients.claim())
})

View File

@ -1,45 +1,47 @@
import { act, render, screen, waitFor } from '@testing-library/react' import { act, render, screen, waitFor } from "@testing-library/react"
import MatchMediaMock from 'jest-matchmedia-mock'; import MatchMediaMock from "jest-matchmedia-mock"
import { createRemixStub, RemixStubProps } from "@remix-run/testing" import { createRemixStub, RemixStubProps } from "@remix-run/testing"
import App from './root' import App from "./root"
import React from 'react' import React from "react"
let RemixStub: (props: RemixStubProps) => React.JSX.Element let RemixStub: (props: RemixStubProps) => React.JSX.Element
describe("root", () => { describe("root", () => {
beforeEach(() => { beforeEach(() => {
RemixStub = createRemixStub([ RemixStub = createRemixStub([
{ {
path: "/", path: "/",
meta: () => ([]), meta: () => [],
links: () => ([]), links: () => [],
loader: () => ({ isSupported: true}), loader: () => ({ isSupported: true }),
Component: App Component: App,
} },
]) ])
}) })
let matchMedia: MatchMediaMock let matchMedia: MatchMediaMock
beforeAll(() => { beforeAll(() => {
matchMedia = new MatchMediaMock(); matchMedia = new MatchMediaMock()
}) })
afterEach(() => {
matchMedia.clear();
})
describe("when user is on iOS", () => { afterEach(() => {
describe("version 16.4 or higher", () => { matchMedia.clear()
describe("and tack up now is not already installed on their device", () => { })
test("they are instructed to install tack up now", async () => {
render(<RemixStub/>)
await waitFor(() => screen.findByText<HTMLElement>(/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(<RemixStub />)
expect(true).toBe(true) await waitFor(() => screen.findByText<HTMLElement>(/Install/), {
}) timeout: 2000,
}) })
expect(true).toBe(true)
}) })
})
}) })
})
}) })

View File

@ -1,71 +1,30 @@
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { import {
Links, Links,
Meta, Meta,
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
useLoaderData,
} from "@remix-run/react" } from "@remix-run/react"
import React, { useEffect } from "react" import React from "react"
import UAParser from "ua-parser-js"
import versionAtLeast from "semver/functions/gte"
import coerceSemver from "semver/functions/coerce"
// import { LandingMessage } from "./root.client"
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1" />
name="viewport" <Meta />
content="width=device-width, initial-scale=1" <Links />
/> </head>
<Meta /> <body>
<Links /> {children}
</head> <ScrollRestoration />
<body> <Scripts />
{children} </body>
<ScrollRestoration /> </html>
<Scripts /> )
</body>
</html>
)
}
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")
return json({ isSupported })
} }
export default function App() { export default function App() {
const { isSupported } = useLoaderData<typeof loader>() return <Outlet />
return <><Outlet/><LandingMessage isSupported={isSupported}/></>
}
function LandingMessage({ isSupported }: { isSupported: boolean }) {
useEffect(() => {
console.log("WTF")
}, [])
if (typeof window === "undefined") return <div>WHY</div>
const isRunningPWA = "standalone" in navigator && navigator.standalone || matchMedia("(dislay-mode: standalone)").matches
const message = isRunningPWA
? "Enable notifications"
: isSupported
? "Install Tack Up Now!"
: "Sorry, your device doesn't support Tack Up Now! :("
return <div>{message}</div>
} }

View File

@ -1,18 +1,82 @@
import type { MetaFunction } from "@remix-run/node"; import {
import React from "react"; 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 = () => { export const meta: MetaFunction = () => {
return [ return [
{ title: "Tack Up Now!" }, { title: "Tack Up Now!" },
{ name: "description", content: "Get equinelive notifications" }, { 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")
return json({ isSupported })
}
export default function Index() { export default function Index() {
const { isSupported } = useLoaderData<typeof loader>()
return ( return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}> <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>WHOAH! A WEBSITE</h1> <LandingMessage isSupported={isSupported} />
<div>Hey check it out!</div>
</div> </div>
); )
}
function LandingMessage({ isSupported }: { isSupported: boolean }) {
const isClient = typeof window !== "undefined"
const notificationsEnabled =
isClient &&
"Notification" in window &&
window.Notification.permission === "granted"
const isRunningPWA =
isClient &&
(("standalone" in navigator && (navigator.standalone as boolean)) ||
matchMedia("(dislay-mode: standalone)").matches)
const [isInstalled, setIsInstalled] = useState(notificationsEnabled)
return !isClient ? (
<div>Loading</div>
) : isInstalled ? (
<div>Your Notifications</div>
) : isRunningPWA && !notificationsEnabled ? (
<EnableButton onSubscribe={() => 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 <button onClick={subscribe}>Enable notifications</button>
async function subscribe() {
console.log("Hey the thing was clicked wow")
subscribeToPush("Derpderp", (subscription) => {
fetch("/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
})
onSubscribe()
})
}
} }

View File

@ -1,77 +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 const { describe, beforeEach, skip, use } = test
function webkitOnly() { function webkitOnly() {
beforeEach(async ({ browserName }) => { beforeEach(async ({ browserName }) => {
if (browserName !== "webkit") skip() if (browserName !== "webkit") skip()
}) })
} }
describe("when user is on iOS", () => { describe("when user is on iOS", () => {
webkitOnly() webkitOnly()
describe("version 16.4 or higher", () => { describe("version 16.4 or higher", () => {
use({ 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", 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.goto("/")
const notificationText = await page.getByText(/Enable notifications/)
await expect(notificationText).toBeAttached()
})
// describe("and notifications are enabled", () => {
// beforeEach(async ({ page }) => {
// await page.addInitScript(() => (window.persmis))
// })
// test("they aren't asked to enable notifications", async ({ browser }) => {
// const context = await browser.newContext({
// permissions: ['notification'],
// });
// const page = await context.newPage()
// await page.goto("/")
// const notificationText = await page.getByText(/Enable notifications/)
// await expect(notificationText).not.toBeAttached()
// })
// })
})
}) })
describe("version 16.3 and under", () => { test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({
use({ page,
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", }) => {
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("/") await page.goto("/")
const sorryText = await page.getByText("Sorry, your device doesn't support Tack Up Now! :(") 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<PushManager["subscribe"]>[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,
})
})
}

1076
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
"test": "jest --config=jest.config.ts" "test": "jest --config=jest.config.ts"
}, },
"dependencies": { "dependencies": {
"@remix-pwa/worker-runtime": "^2.1.4",
"@remix-run/express": "^2.9.1", "@remix-run/express": "^2.9.1",
"@remix-run/node": "^2.9.0", "@remix-run/node": "^2.9.0",
"@remix-run/react": "^2.9.0", "@remix-run/react": "^2.9.0",
@ -22,6 +23,7 @@
"isbot": "^4.1.0", "isbot": "^4.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remix-pwa-monorepo": "github:remix-pwa/monorepo#main",
"remix-utils": "^7.6.0", "remix-utils": "^7.6.0",
"semver": "^7.6.3", "semver": "^7.6.3",
"ua-parser-js": "^1.0.39" "ua-parser-js": "^1.0.39"
@ -34,6 +36,7 @@
"@babel/preset-react": "^7.24.1", "@babel/preset-react": "^7.24.1",
"@babel/preset-typescript": "^7.24.1", "@babel/preset-typescript": "^7.24.1",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@remix-pwa/dev": "^3.1.0",
"@remix-run/dev": "^2.9.0", "@remix-run/dev": "^2.9.0",
"@remix-run/testing": "^2.9.1", "@remix-run/testing": "^2.9.1",
"@swc/core": "^1.5.7", "@swc/core": "^1.5.7",
@ -72,4 +75,4 @@
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
} }
} }

View File

@ -11,7 +11,6 @@ app.get("/api", (req, res) => res.send("HI"))
// and your app is "just a request handler" // and your app is "just a request handler"
app.all("*", createRequestHandler({ build })) app.all("*", createRequestHandler({ build }))
app.listen(3000, () => { app.listen(3000, () => {
console.log("App listening on http://localhost:3000") console.log("App listening on http://localhost:3000")
}) })

View File

@ -12,13 +12,13 @@ import baseConfig from "./thebabel.config.cjs"
let metaPlugin = ({ types: t }) => ({ let metaPlugin = ({ types: t }) => ({
visitor: { visitor: {
MetaProperty: (path) => { MetaProperty: (path) => {
path.replaceWith(t.identifier("undefined")); path.replaceWith(t.identifier("undefined"))
}, },
}, },
}); })
export default babelJest.createTransformer({ export default babelJest.createTransformer({
babelrc: false, babelrc: false,
...baseConfig, ...baseConfig,
plugins: [...baseConfig.plugins, metaPlugin], plugins: [...baseConfig.plugins, metaPlugin],
}); })

View File

@ -1,10 +1,11 @@
import { vitePlugin as remix } from "@remix-run/dev"; import { vitePlugin as remix } from "@remix-run/dev"
import { installGlobals } from "@remix-run/node"; import { installGlobals } from "@remix-run/node"
import { defineConfig } from "vite"; import { defineConfig } from "vite"
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths"
import { remixPWA } from "@remix-pwa/dev"
installGlobals(); installGlobals()
export default defineConfig({ export default defineConfig({
plugins: [remix(), tsconfigPaths()], plugins: [remix(), tsconfigPaths(), remixPWA()],
}); })