Compare commits

..

No commits in common. "fd351360f12777cda1b75b0e404d423da3c7a94e" and "fc6a5c4528da87bede1244ed809416976215f184" have entirely different histories.

14 changed files with 412 additions and 1307 deletions

View File

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

52
.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": { "transform": {
"jest": true "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
} }
},
"module": {
"type": "commonjs",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
}
} }

View File

@ -4,10 +4,9 @@
* 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 React from "react" import { startTransition, StrictMode } from "react";
import { startTransition, StrictMode } from "react" import { hydrateRoot } from "react-dom/client";
import { hydrateRoot } from "react-dom/client"
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(
@ -15,5 +14,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);
}) });
} }

View File

@ -1,17 +0,0 @@
/// <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,47 +1,45 @@
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(() => { afterEach(() => {
matchMedia.clear() matchMedia.clear();
}) })
describe("when user is on iOS", () => { describe("when user is on iOS", () => {
describe("version 16.4 or higher", () => { describe("version 16.4 or higher", () => {
describe("and tack up now is not already installed on their device", () => { describe("and tack up now is not already installed on their device", () => {
test("they are instructed to install tack up now", async () => { test("they are instructed to install tack up now", async () => {
render(<RemixStub />) render(<RemixStub/>)
await waitFor(() => screen.findByText<HTMLElement>(/Install/), { await waitFor(() => screen.findByText<HTMLElement>(/Install/), { timeout: 2000 })
timeout: 2000,
}) expect(true).toBe(true)
})
expect(true).toBe(true) })
}) })
})
}) })
})
}) })

View File

@ -1,30 +1,71 @@
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 from "react" import React, { useEffect } 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 name="viewport" content="width=device-width, initial-scale=1" /> <meta
<Meta /> name="viewport"
<Links /> content="width=device-width, initial-scale=1"
</head> />
<body> <Meta />
{children} <Links />
<ScrollRestoration /> </head>
<Scripts /> <body>
</body> {children}
</html> <ScrollRestoration />
) <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() {
return <Outlet /> const { isSupported } = useLoaderData<typeof loader>()
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,82 +1,18 @@
import { import type { MetaFunction } from "@remix-run/node";
json, import React from "react";
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" }}>
<LandingMessage isSupported={isSupported} /> <h1>WHOAH! A WEBSITE</h1>
<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,186 +1,77 @@
import { test, expect, Page } from "@playwright/test" import { test, expect } 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: 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",
"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)
)
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()
}) })
describe("and then the user enables notifications", () => { test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({ page }) => {
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 requestPromise = page.waitForRequest("/subscribe") const installText = await page.getByText(/Install Tack Up Now!/)
await page.getByText(/Enable notifications/).click()
const request = await requestPromise
await expect(request.postDataJSON()).toEqual({ hi: "subscription" }) await expect(installText).toBeAttached()
}) })
test("users see tack up now after enabling notifications", async ({ describe("and tack up now is running as a PWA", () => {
page,
}) => { 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", () => {
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("/") await page.goto("/")
const requestPromise = page.waitForRequest("/subscribe") const sorryText = await page.getByText("Sorry, your device doesn't support Tack Up Now! :(")
await page.getByText(/Enable notifications/).click()
await requestPromise
const yourNotificationsHeading = await expect(sorryText).toBeVisible()
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,7 +13,6 @@
"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",
@ -23,7 +22,6 @@
"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"
@ -36,7 +34,6 @@
"@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",
@ -75,4 +72,4 @@
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
} }
} }

View File

@ -11,6 +11,7 @@ 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,11 +1,10 @@
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(), remixPWA()], plugins: [remix(), tsconfigPaths()],
}) });