Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"deploy": "npm run test:rules && npm run build && firebase deploy --only hosting,firestore,storage",
"dev": "vite",
"build": "tsc -b && vite build",
"build": "tsc -b && vite build && npm run prerender",
"prerender": "vite build --ssr src/prerender.tsx --outDir node_modules/.prerender && node scripts/prerender.mjs",
"build:chrome": "vite build -c vite.chrome.config.ts",
"lint": "eslint .",
"preview": "vite preview",
Expand Down
76 changes: 76 additions & 0 deletions apps/web/scripts/prerender.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Writes the static pages (src/staticPages.tsx) into the built site as HTML, so they read
// with JavaScript off. Runs after `vite build` (the site) and `vite build --ssr` (the
// renderer, into node_modules/.prerender), from `npm run prerender`.
//
// Each page is the built `index.html` with its body filled in and its title, og:title and
// og:url set, so it loads the same bundle and stylesheet as every other page, and a shared
// link's card names the page. The descriptions stay the product's: they say what the page
// belongs to, which is what a card for the terms needs. The bundle then renders
// over it with `createRoot` (src/main.tsx), and a signed-in visitor's nav appears as it
// does today.
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

const web = (p) => fileURLToPath(new URL(`../${p}`, import.meta.url));

const bundle = new URL('../node_modules/.prerender/prerender.js', import.meta.url);

// Firebase Auth, once started in Node, holds the process open: the script writes every page
// and then never exits, and the build waits with it. The static pages import none of
// Firebase (src/contexts/AuthContext.tsx, src/config/password.ts); this keeps it that way
// with a message instead of a hang.
const firebaseImport = (await readFile(bundle, 'utf8')).match(/from "firebase\/[^"]*"|import "firebase\/[^"]*"/);
if (firebaseImport) {
throw new Error(`prerender: a static page imports Firebase (${firebaseImport[0]}); see src/staticPages.tsx`);
}

const { render, STATIC_PAGES } = await import(bundle);
const template = await readFile(web('dist/index.html'), 'utf8');
const firebase = JSON.parse(await readFile(web('../../firebase.json'), 'utf8'));

/**
* `html` with `from` (a string or a pattern) replaced once, failing the build when `from` is
* not there: a change to index.html that moved it would otherwise ship pages with nothing
* filled in. A function replacement, so a `$` in the page's text is not read as a pattern.
*/
function fill(html, from, to) {
if (!(typeof from === 'string' ? html.includes(from) : from.test(html))) {
throw new Error(`prerender: ${from} is not in dist/index.html`);
}
return html.replace(from, () => to);
}

const rewrites = firebase.hosting.rewrites;
for (const { path } of STATIC_PAGES) {
const file = `${path.slice(1)}.html`;

// Hosting answers a path with the file of that exact name, and otherwise with the first
// rewrite that matches, which ends in `**` → index.html. So `/terms` reaches terms.html
// only through a rewrite of its own, ahead of `**`. Not `cleanUrls`, which would do it for
// every file: it also redirects `/index.html` to `/`, and the site's image page is
// `/index.html?view=…` (src/services/viewable.ts).
const own = rewrites.findIndex((r) => r.source === path && r.destination === `/${file}`);
const catchAll = rewrites.findIndex((r) => r.source === '**');
if (own === -1 || own > catchAll) {
throw new Error(`prerender: firebase.json needs { "source": "${path}", "destination": "/${file}" } before "**"`);
}

const { html, title } = render(path);
let page = template;
page = fill(page, '<title>PushThat</title>', title);
// Helmet's tag holds the text already escaped for HTML, so it goes into the attribute as is.
const titleText = title.replace(/^<title[^>]*>|<\/title>$/g, '');
page = fill(
page,
/<meta property="og:title" content="[^"]*" \/>/,
`<meta property="og:title" content="${titleText}" />`
);
page = fill(
page,
'<meta property="og:url" content="https://pushthat.link/" />',
`<meta property="og:url" content="https://pushthat.link${path}" />`
);
page = fill(page, '<div id="root"></div>', `<div id="root">${html}</div>`);
await writeFile(web(`dist/${file}`), page);
console.log(`prerender: ${path} → dist/${file}`);
}
14 changes: 5 additions & 9 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,20 @@ import { Navigate, Outlet, Route, Routes, useSearchParams } from 'react-router';
import Layout from './Layout';
import ProtectedRoute from './components/ProtectedRoute';
import { ScrollToTop } from './components/ScrollToTop';
import { AuthProvider } from './contexts/AuthContext';
import { useAuth } from './contexts/AuthContext';
import { AuthProvider } from './contexts/AuthProvider';
import { KeysProvider } from './contexts/KeysContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { IndexPage } from './pages/Index';
import Links from './pages/Links';
import Login from './pages/Login';
import NotFound from './pages/NotFound';
import Plan from './pages/Plan';
import Privacy from './pages/Privacy';
import Push from './pages/Push';
import { ResetPassword } from './pages/ResetPassword';
import { Security } from './pages/Security';
import Support from './pages/Support';
import Terms from './pages/Terms';
import { ViewPush } from './pages/ViewPush';
import { VIEW_PARAM } from './services/viewable';
import { STATIC_PAGES } from './staticPages';

function IndexOrRedirect() {
const { isAuthenticated, loading } = useAuth();
Expand Down Expand Up @@ -71,10 +68,9 @@ function App() {
>
<Route path="/login" element={<Login />} />
<Route path="/" element={<IndexOrRedirect />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
<Route path="/support" element={<Support />} />
<Route path="/security" element={<Security />} />
{STATIC_PAGES.map(({ path, Page }) => (
<Route key={path} path={path} element={<Page />} />
))}
<Route path="/reset" element={<ResetPassword />} />
<Route element={<ProtectedRoute />}>
<Route path="/links" element={<Links />} />
Expand Down
20 changes: 13 additions & 7 deletions apps/web/src/components/ScrollToTop.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';

/**
* Scrolls to the top when the app moves to another page. Not on the page it mounted on: that
* one is where the browser put it, at the top, at a `#fragment`, or (on a prerendered page,
* src/main.tsx) wherever the reader had got to before the bundle loaded. The location is kept
* in a ref rather than a "first run" flag because StrictMode runs the effect twice on mount
* and keeps refs between the two runs.
*/
export function ScrollToTop() {
const { pathname, search } = useLocation();
const scrolledFor = useRef(pathname + search);

useEffect(() => {
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
document.getElementById('layout-border')?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
if (scrolledFor.current === pathname + search) return;
scrolledFor.current = pathname + search;
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
document.getElementById('layout-border')?.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}, [pathname, search]);

return null;
}


6 changes: 6 additions & 0 deletions apps/web/src/config/password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* What docs/e2ee.md asks of a new password, since the password is the key. Here rather than
* in services/signIn.ts so the Security page can quote it without importing Firebase, which
* the build cannot run when it prerenders that page (src/staticPages.tsx).
*/
export const MIN_PASSWORD_LENGTH = 10;
51 changes: 11 additions & 40 deletions apps/web/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,24 @@
import { User, signOut as firebaseSignOut, getAuth, onAuthStateChanged } from 'firebase/auth';
import { createContext, useContext, useEffect, useState } from 'react';

import { auth } from '@/config/firebase';
import { sendToExtension } from '@/services/extension';

interface AuthContextType {
import type { User } from 'firebase/auth';
import { createContext, useContext } from 'react';

/**
* Who is signed in, for anything that reads it. The provider that fills it in is
* AuthProvider.tsx, kept apart so that reading this imports no Firebase: the static pages
* render through `Layout`, which reads it, in Node at build time (src/prerender.tsx), and
* Firebase Auth started there keeps Node from exiting.
*/
export interface AuthContextType {
isAuthenticated: boolean;
loading: boolean;
user: User | null;
signOut: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType>({
export const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
loading: true,
user: null,
signOut: async () => {},
});

export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
console.debug('onAuthStateChanged', user?.uid);

setUser(user);
setLoading(false);

sendToExtension({ type: 'ON_AUTH_STATE_CHANGED', user: user });
});

return unsubscribe;
}, []);

const signOut = async () => {
const auth = getAuth();
await firebaseSignOut(auth);
};

const value = {
isAuthenticated: !!user,
loading,
user,
signOut,
};

return <AuthContext.Provider value={value}>{!loading && children}</AuthContext.Provider>;
}

export const useAuth = () => useContext(AuthContext);
56 changes: 56 additions & 0 deletions apps/web/src/contexts/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { User, signOut as firebaseSignOut, getAuth, onAuthStateChanged } from 'firebase/auth';
import { useEffect, useState } from 'react';

import { auth } from '@/config/firebase';
import { AuthContext } from '@/contexts/AuthContext';
import { sendToExtension } from '@/services/extension';

/**
* Whether Firebase has said who is signed in yet. On a prerendered page src/main.tsx waits
* for that before mounting, so the provider can start from the answer: starting from
* `loading` would render nothing first, and that empty render replaces the prerendered page
* for the moment before the listener below reports. Everywhere else the app mounts at once,
* this is still false, and the provider waits for the listener as it always has.
*/
let authKnown = false;

/**
* Settles once `authKnown` is set. main.tsx mounts a prerendered page after this rather than
* after its own `authStateReady()`, so the flag is set by then whatever order the modules
* load in; two callbacks on the same promise would only be ordered by which module ran first.
*/
export const authKnownReady: Promise<void> = auth.authStateReady().then(() => {
authKnown = true;
});

export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(() => (authKnown ? auth.currentUser : null));
const [loading, setLoading] = useState(() => !authKnown);

useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
console.debug('onAuthStateChanged', user?.uid);

setUser(user);
setLoading(false);

sendToExtension({ type: 'ON_AUTH_STATE_CHANGED', user: user });
});

return unsubscribe;
}, []);

const signOut = async () => {
const auth = getAuth();
await firebaseSignOut(auth);
};

const value = {
isAuthenticated: !!user,
loading,
user,
signOut,
};

return <AuthContext.Provider value={value}>{!loading && children}</AuthContext.Provider>;
}
50 changes: 43 additions & 7 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,51 @@
import './index.css';

import { StrictMode } from 'react';
import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';

import App from './App.tsx';
import { authKnownReady } from './contexts/AuthProvider';

createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
const root = document.getElementById('root')!;

/**
* Mounts the app. On a prerendered page the reader may already have scrolled, or the browser
* may have jumped to a `#fragment`, and the page scrolls inside `#layout-border`, which
* `createRoot` replaces with a new element at the top. So the offset is read first, the
* first render is committed synchronously, and the offset is put back on the new element.
* Keyboard focus is not carried over: it would mean finding the same element in the new
* tree, for someone who tabbed in before the bundle loaded.
*/
function mount() {
const scroller = () => document.getElementById('layout-border');
const reading = { inner: scroller()?.scrollTop ?? 0, window: window.scrollY };
flushSync(() =>
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)
);
if (reading.inner) scroller()?.scrollTo({ top: reading.inner });
if (reading.window) window.scrollTo({ top: reading.window });
}

// A prerendered page (src/staticPages.tsx) arrives with `#root` already holding it, and
// `createRoot` replaces that markup with its first render. `AuthProvider` renders nothing
// until Firebase has said who is signed in, so mounting at once would blank the page for as
// long as that takes. Waiting for it first keeps the page on screen until React has the same
// page to show. Elsewhere `#root` is empty and there is nothing to keep, so nothing waits.
//
// Not `hydrateRoot`. A signed-out visitor's first render does match the markup, but a
// signed-in one's has the nav and the menu, which the prerender never has: hydration fails
// for them, React reports an error and renders from scratch anyway. `createRoot` renders
// from scratch for everyone without the error, at the cost of the DOM the reader was in.
if (root.hasChildNodes()) {
authKnownReady.then(mount, mount);
} else {
mount();
}
3 changes: 2 additions & 1 deletion apps/web/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import { Navigate, useNavigate } from 'react-router';
import Button from '@/components/Button';
import GlowButton from '@/components/GlowButton';
import { auth } from '@/config/firebase';
import { MIN_PASSWORD_LENGTH } from '@/config/password';
import { useAuth } from '@/contexts/AuthContext';
import { useKeys } from '@/contexts/KeysContext';
import { resetActionCodeSettings } from '@/services/passwordReset';
import { MIN_PASSWORD_LENGTH, WrongPasswordError } from '@/services/signIn';
import { WrongPasswordError } from '@/services/signIn';

import styles from './Login.module.css';

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/ResetPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Button from '@/components/Button';
import GlowButton from '@/components/GlowButton';
import styles from '@/components/KeyStep.module.css';
import { RecoveryKey } from '@/components/RecoveryKey';
import { MIN_PASSWORD_LENGTH } from '@/config/password';
import {
KeysChangedError,
PasswordChangedError,
Expand All @@ -19,7 +20,6 @@ import {
setNewPassword,
startNewKeys,
} from '@/services/passwordReset';
import { MIN_PASSWORD_LENGTH } from '@/services/signIn';

/*
* A new recovery key is stored on the server before it is shown, and the reset link is
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/Security.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Helmet } from 'react-helmet';
import { Link } from 'react-router';

import LegalContainer from '@/components/LegalContainer';
import { MIN_PASSWORD_LENGTH } from '@/config/password';
import { LIMITS, SUPPORT_EMAIL } from '@/config/plan';
import { MIN_PASSWORD_LENGTH } from '@/services/signIn';

/**
* Security, `/security`: the gist of docs/e2ee.md for the people it protects. What is
Expand Down
Loading
Loading