diff --git a/.env.example b/.env.example
index eb7d6bb..f63df61 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,11 @@ SERVER_ADDR=127.0.0.1:8787
# The transparency log's public key, base64, 32 bytes — as printed by the server on first boot.
#
+# **No longer compiled into the web client**, and the variable is kept only for the desktop build
+# and for whoever verifies a log head by hand. `apps/web/src/lib/pinning.ts` argues why: on the web
+# the server ships the pin along with the code it constrains, so it was never a defence there, and
+# taking it out is what makes every deployment's bundle byte-identical.
+#
# Optional, and empty here on purpose. Set it and the client refuses any log head signed by a
# different key, which is the only check that works on a first contact with a server: everything
# else compares the server against its own past. It closes that hole in the **desktop binary**,
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..734e506
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,115 @@
+# Publishes the manifest of the web client, attested to this repository.
+#
+# # Why this workflow is the point, and not the script it runs
+#
+# `scripts/release-web.sh` can be run by anybody, which is what makes the build verifiable. But a
+# manifest produced on a laptop says only "somebody hashed some files"; the reader has no way to
+# tell it from a manifest describing a hostile build. What closes that is `attest-build-provenance`:
+# it binds the artefact to **this commit** and to **this workflow**, signed through Sigstore, and
+# nobody — the maintainer included — can produce that binding outside GitHub Actions.
+#
+# That is the whole reason the check means anything. The repository already carries an Ed25519 key
+# for the desktop release, and `verify-release.sh` says in as many words what it is worth: the key
+# lives in the repository, so whoever controls the repository can replace it. For a manifest whose
+# job is to be independent of the party serving the code, a key the same party carries is not
+# independence.
+#
+# # Why a tag and not every push
+#
+# A manifest is a claim about a build somebody can install. `dev` moves several times a day and
+# nothing deploys from it; a manifest per commit would be a list of hashes nobody could act on,
+# and would make the released ones harder to find.
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+ # For rehearsing the workflow before there is a tag to rehearse it on. It publishes nothing:
+ # `gh release` only runs on a tag ref.
+ workflow_dispatch:
+
+# Absent from every other workflow in this repository, and required by two of the steps below.
+# `id-token` is what lets the runner prove to Sigstore which workflow it is; `attestations` is what
+# lets it record the result. `contents: write` is for creating the release itself.
+permissions:
+ contents: write
+ id-token: write
+ attestations: write
+
+jobs:
+ web:
+ name: Web client manifest
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v5
+
+ # **The exact version, from `apps/web/.nvmrc`, and this is the line the manifest rests on.**
+ #
+ # Measured: node 22.21.0 and node 22.23.2 produce different bytes for two of the fourteen
+ # generated files — `index-*.js` and `PdfViewer-*.js` — while the CSS and the other chunks
+ # match. A manifest built here and a deployment built elsewhere would therefore disagree on
+ # two files, and the disagreement would look exactly like an attack.
+ #
+ # `node-version: 22` resolves to whatever 22.x the runner has that week. It happened to
+ # match `deploy/Dockerfile.web` on the day this was written, which is not a property anybody
+ # should rely on — it is the kind of agreement that holds until it silently stops.
+ - uses: actions/setup-node@v5
+ with:
+ node-version-file: apps/web/.nvmrc
+ # The cache keys on a lockfile path this repository does not have at the root, and a
+ # half-hit cache is a slower build with a confusing log.
+ package-manager-cache: false
+
+ # Pinned rather than left to corepack's default, the same statement `deploy/Dockerfile.web`
+ # makes: `apps/web/package.json` declares no `packageManager`, so nothing else in the tree
+ # records which pnpm produced `pnpm-lock.yaml`.
+ #
+ # It matters more here than anywhere else. A manifest is a claim that a given commit
+ # produces given bytes; if the tool that produces them is whatever version happened to be
+ # current that day, the claim is about a build nobody can reproduce.
+ - name: Enable pnpm
+ run: corepack enable && corepack prepare pnpm@11.22.0 --activate
+
+ - name: Build and hash
+ run: scripts/release-web.sh
+
+ # Everything worth reading in the log, because a mismatch reported weeks later is
+ # investigated from here.
+ - name: What was built
+ run: |
+ cat release/web/BUILD-INFO
+ echo
+ head -5 release/web/WEB-SHA256SUMS
+
+ - uses: actions/attest-build-provenance@v3
+ with:
+ subject-path: release/web/WEB-SHA256SUMS
+
+ # The manifest and nothing else. The bundle itself is not published: a reader does not need
+ # our copy of the files, they need the hashes to compare against the copy their own browser
+ # was served — and publishing the bundle would invite verifying the wrong thing.
+ - name: Publish
+ if: startsWith(github.ref, 'refs/tags/')
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release create "${GITHUB_REF_NAME}" \
+ --title "${GITHUB_REF_NAME}" \
+ --notes "Manifest of the web client for \`${GITHUB_SHA}\`.
+
+ Check a deployment against it:
+
+ \`\`\`sh
+ gh release download ${GITHUB_REF_NAME} --pattern WEB-SHA256SUMS
+ gh attestation verify WEB-SHA256SUMS --repo ${GITHUB_REPOSITORY}
+ scripts/verify-web.sh https://your.deployment WEB-SHA256SUMS
+ \`\`\`
+
+ The second line is the one that matters: it establishes that this manifest came out of
+ this repository's workflow rather than out of somebody's laptop. Verifying the hashes
+ without it checks that a server is consistent with a file you were handed, which is not
+ the same claim." \
+ release/web/WEB-SHA256SUMS \
+ release/web/BUILD-INFO
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index cc9812c..f25ad46 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -284,6 +284,16 @@ jobs:
working-directory: apps/web
run: pnpm test
+ # The extension's comparison, which is the part of it that can be checked without a browser.
+ #
+ # In this job rather than one of its own: it needs node and nothing else, and a job that
+ # spends thirty seconds installing a runtime to run nine tests would cost more than it
+ # reports. It runs unconditionally for the same reason the rest of this job does — the
+ # extension is what makes the published manifest mean anything, and a silent regression in
+ # it would turn a green icon into a claim nobody checked.
+ - name: Extension
+ run: node --test extension/*.test.js
+
# The bundle, and it is not decoration.
#
# `typecheck`, `lint` and `test` all read the source; none of them resolve an import,
diff --git a/Cargo.lock b/Cargo.lock
index 2adf975..54a8819 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4939,6 +4939,7 @@ dependencies = [
"futures-util",
"hex",
"hmac",
+ "p256",
"rand_core 0.6.4",
"reqwest 0.12.28",
"serde",
diff --git a/README.md b/README.md
index 692ebec..0ac87c5 100644
--- a/README.md
+++ b/README.md
@@ -43,10 +43,11 @@ not their equal and does not try to be.
## What does not work
-- **Push notifications are half-built.** The server records tokens and decides who to wake,
- and then sends nothing. There is no FCM or APNs provider, no configuration, no device-side
- token registration and no user-facing setting. It is inert without configuration, and a
- self-hosted deployment that talks to neither Apple nor Google stays fully functional.
+- **Push reaches browsers, not the packaged mobile app.** Web Push works end to end — a browser
+ subscribes, the server signs a VAPID token, a notification arrives with the tab closed — and it
+ is off until a deployment sets `VAPID_SUBJECT`. FCM and APNs are not written: device-side
+ registration needs a Tauri plugin that does not exist, so the Tauri build is only notified while
+ it is open. The wake-up carries no text, no sender and no group id.
- **Biometric unlock has never been executed.** The code exists; not one line of it has run.
There is no Android NDK and no physical device on the development machine, so even the
compilation of its dependency is unconfirmed.
@@ -70,8 +71,11 @@ docker compose up -d
# 2. Configuration. The committed defaults point at that container.
cp .env.example .env
-# 3. Server — listens on 127.0.0.1:8787. The script loads .env, which the
-# server does not do itself, and gives the branch its own database and port.
+# 3. Server — listens on 127.0.0.1:8787. The script passes .env through, which
+# the server does not read itself, and gives the branch its own database and
+# port. Everything the file defines reaches the server: the two values the
+# script computes — the database and the address — are the only ones it
+# overrides.
./scripts/dev-server.sh
# 4. Client, in a second terminal. `wasm` builds crypto-core to WebAssembly
diff --git a/apps/web/.nvmrc b/apps/web/.nvmrc
new file mode 100644
index 0000000..c947119
--- /dev/null
+++ b/apps/web/.nvmrc
@@ -0,0 +1 @@
+22.23.2
diff --git a/apps/web/package.json b/apps/web/package.json
index d76a498..6d5d9ce 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -19,6 +19,7 @@
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
+ "@radix-ui/react-toast": "^1.2.23",
"@radix-ui/react-tooltip": "^1.2.16",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-opener": "^2.5.4",
diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml
index e8c5604..a289da0 100644
--- a/apps/web/pnpm-lock.yaml
+++ b/apps/web/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
'@radix-ui/react-switch':
specifier: ^1.3.7
version: 1.3.7(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-toast':
+ specifier: ^1.2.23
+ version: 1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-tooltip':
specifier: ^1.2.16
version: 1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -795,6 +798,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-toast@1.2.23':
+ resolution: {integrity: sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-tooltip@1.2.16':
resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==}
peerDependencies:
@@ -3004,6 +3020,26 @@ snapshots:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
+ '@radix-ui/react-toast@1.2.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.7
+ '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
+ '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.2.18
+ '@types/react-dom': 19.2.4(@types/react@19.2.18)
+
'@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
new file mode 100644
index 0000000..74733df
--- /dev/null
+++ b/apps/web/public/sw.js
@@ -0,0 +1,82 @@
+/**
+ * The service worker, and what it deliberately is not.
+ *
+ * # Why this file exists at all, when the project refused one
+ *
+ * `src/lib/notifications.ts` refuses a service worker in as many words: "one would be a cache of
+ * the application shell served by the same server the desktop build exists to stop trusting". That
+ * objection is about **caching**. A worker that caches the shell keeps a copy of the application
+ * alive across visits, so a server that served a hostile bundle once keeps its victim even after
+ * it is fixed — which is a real and serious thing to refuse.
+ *
+ * This worker caches nothing. It registers no `fetch` handler, opens no `Cache`, keeps no
+ * precache manifest, and intercepts no request. Every load of the page comes from the network
+ * exactly as it did before this file existed, and deleting it changes nothing except that
+ * notifications stop arriving. It cannot serve a stale application because it cannot serve an
+ * application.
+ *
+ * The reason a worker is needed at all is that the Push API has no other delivery point: a push
+ * message wakes the *worker*, not the page, and there is no version of Web Push that reaches a
+ * document directly.
+ *
+ * # Why the text is a constant
+ *
+ * The worker cannot decrypt. The MLS keys live in a WASM module inside the page, in memory the
+ * worker has no access to, and moving them here would mean handing the decryption keys to a
+ * context that outlives every tab. So the notification says that something arrived, and nothing
+ * about what: the same answer iOS forces on every messenger, arrived at here on purpose rather
+ * than by constraint.
+ *
+ * That is also the third of the three limits in `migrations/0011_push.sql`: the wake-up carries
+ * no text, no sender and no group id. There is nothing here to display even if this file wanted
+ * to.
+ */
+
+// Kept in step with `NOTICE_TITLE` and `NOTICE_BODY_ONE` in `src/lib/notifications.ts`. Duplicated
+// rather than imported: a service worker is its own module graph, served as a plain file so that
+// what is deployed is what can be read, and a build step to share two strings would cost more
+// clarity than it saves. `push.test.ts` pins them against their source.
+const TITLE = "Whispee";
+const BODY = "New message";
+
+self.addEventListener("push", (event) => {
+ // `waitUntil` or the worker may be killed before the notification is shown. Browsers also
+ // require that a push handler show *something*: staying silent gets the subscription revoked
+ // after a few offences, and on some browsers displays a "this site was updated in the
+ // background" notice instead — worse than ours, and not ours to write.
+ event.waitUntil(
+ self.registration.showNotification(TITLE, {
+ body: BODY,
+ // The collapse key. Ten messages while the phone is in a pocket are one notification, not
+ // ten — the page does the same with `tag: conversation`, except this side does not know
+ // which conversation, so everything collapses into one.
+ tag: "whispee-wake",
+ // No `renotify`: the point of collapsing is not to buzz again for each one.
+ silent: false,
+ }),
+ );
+});
+
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close();
+
+ // Focus a tab that is already open before opening another. Somebody who clicks a notification
+ // wants the conversation they were already in, not a second copy of the application signing in
+ // from scratch.
+ event.waitUntil(
+ (async () => {
+ const clients = await self.clients.matchAll({
+ type: "window",
+ includeUncontrolled: true,
+ });
+
+ for (const client of clients) {
+ if ("focus" in client) return client.focus();
+ }
+
+ // No deep link, and it is not an oversight: the wake-up does not say which conversation,
+ // so the honest destination is the application's front door.
+ return self.clients.openWindow("/");
+ })(),
+ );
+});
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 8c4f22b..af5bfaa 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -238,7 +238,16 @@ function Boot() {
}
if (!store) {
- return ;
+ // The sentence, not the message object: onboarding is a full screen shown before a session
+ // exists, so the toast viewport is not mounted behind it and its own `Banner` is the only
+ // surface there is. Nothing here floats over anything.
+ return (
+
+ );
}
return (
@@ -353,6 +362,13 @@ function Frame({
// timer: retrying on a schedule keeps hammering a server that is down, and the two moments
// that actually change the answer — a reconnection, a resume — are reported right here.
void session.flushOutbox().then(bump);
+
+ // And the wake address, on the same two moments and for a related reason: a push
+ // subscription rotates without warning, and a browser that renewed its own would otherwise
+ // be reachable at an address this server does not have — a phone that stops waking, with
+ // nothing anywhere to say why. Does nothing when this browser is not subscribed, so it
+ // never turns the feature on by itself.
+ void session.replayWaking();
});
const lost = () => setOffline(true);
@@ -440,7 +456,19 @@ function Frame({
dismissError();
bump();
} catch (e) {
- if (!cancelled) report.error(e instanceof Error ? e.message : String(e));
+ // The one place an action earns its keep. A poll that failed will be retried in thirty
+ // seconds anyway, and thirty seconds is a long time to sit in front of a sentence saying
+ // the connection is gone — especially when the cause was a laptop lid, and the fix is to
+ // ask again. The button does exactly what the timer would have done, sooner.
+ //
+ // Retrying calls `tick` again, so a second failure replaces this message with a new one
+ // and a success clears it through `dismissError` above. Nothing accumulates.
+ if (!cancelled) {
+ report.error(e instanceof Error ? e.message : String(e), {
+ label: "Retry",
+ run: () => void tick(),
+ });
+ }
}
};
@@ -472,6 +500,11 @@ function Frame({
});
title.current ??= countUnreadInTitle();
+ // Once per session, beside the notifier that handles the other half of the same job: this one
+ // covers the tab being closed, that one covers it being open. The address is only re-sent, so
+ // a browser that never subscribed stays unsubscribed.
+ void session.replayWaking();
+
const notices = notifier.current;
const counter = title.current;
@@ -668,17 +701,15 @@ function Frame({
)}
- {/* Always dismissible: an error you cannot wave away ends up part of the scenery. */}
- {reported.error && (
-
- {reported.error}
-
- )}
-
+ {/*
+ * The error used to be a fourth full-bleed banner here, and it was the one that did not
+ * belong: the three above describe a state the reader is *in* — no network, a log that
+ * disagrees with itself, a session that would not restore — while an error describes
+ * something that just happened. A standing condition earns room in the layout; an event
+ * does not, and taking it left the conversation an inch shorter for as long as the error
+ * stood. It floats now, out of `ui/Toast.tsx`, and is still dismissible for the reason
+ * that always applied: an error you cannot wave away ends up part of the scenery.
+ */}
);
diff --git a/apps/web/src/app/SettingsScreen.tsx b/apps/web/src/app/SettingsScreen.tsx
index df792d4..a12e236 100644
--- a/apps/web/src/app/SettingsScreen.tsx
+++ b/apps/web/src/app/SettingsScreen.tsx
@@ -12,6 +12,7 @@ import { useTheme } from "@/lib/theme";
import { useOcclusion } from "@/lib/viewport";
import { Icon } from "@/ui/Icon";
import { IconButton } from "@/ui/IconButton";
+import { Dialog } from "@/ui/Dialog";
import { Panel } from "@/ui/Panel";
import { cn } from "@/ui/cn";
import type { SettingsSection } from "@/routes/route";
@@ -207,6 +208,44 @@ function Navigation({ section }: { section: SettingsSection | null }) {
);
}
+/**
+ * Settings, over whatever was on screen.
+ *
+ * # Why a modal and not a route that replaces the centre
+ *
+ * Because settings are not a place in the application, they are an interruption of it. Rendering
+ * them into the centre column made the whole window claim you had gone somewhere — the
+ * conversation you were reading disappeared to show you a theme picker, and coming back meant
+ * navigating rather than closing.
+ *
+ * # The URL is kept, and that is the part worth defending
+ *
+ * `#/settings/notifications` still works, still deep-links, and the back gesture still steps out
+ * of a section into the list before leaving. A modal driven by local state would have been
+ * simpler and would have thrown all of that away: the route is what makes a setting something you
+ * can send somebody, and what makes `RouteAnnouncer` able to say where the reader has arrived.
+ *
+ * So `open` is derived from the route rather than held here, and closing navigates rather than
+ * flipping a boolean. There is exactly one source of truth for whether settings are showing.
+ */
+export function SettingsDialog({ section }: { section: SettingsSection | null }) {
+ return (
+
+ );
+}
+
export function SettingsScreen({ section }: { section: SettingsSection | null }) {
const duo = useDuo();
const occlusion = useOcclusion();
@@ -216,7 +255,7 @@ export function SettingsScreen({ section }: { section: SettingsSection | null })
const showNavigation = duo || section === null;
return (
-
+
{showNavigation && (
// No `border-r`. The shell no longer divides anything with a hairline — panes are
// separated by the gutter of ground between them — and a rule drawn here would be the
diff --git a/apps/web/src/app/Shell.tsx b/apps/web/src/app/Shell.tsx
index ae5a0ab..5379062 100644
--- a/apps/web/src/app/Shell.tsx
+++ b/apps/web/src/app/Shell.tsx
@@ -13,7 +13,7 @@ import { DetailPanel } from "./DetailPanel";
import { EmptyCenter } from "./EmptyCenter";
import { NewConversation } from "./NewConversation";
import { Rail } from "./Rail";
-import { TITLES, SettingsScreen } from "./SettingsScreen";
+import { TITLES, SettingsDialog } from "./SettingsScreen";
import { RouteAnnouncer } from "./RouteAnnouncer";
import { useBinding } from "./Shortcuts";
import { ShortcutsHelp } from "./ShortcutsHelp";
@@ -223,7 +223,10 @@ export function Shell({ onLock, onForget }: { onLock: () => void; onForget: () =
case "new":
return ;
case "settings":
- return ;
+ // Settings are an overlay, so the centre keeps showing what settings were opened *over*.
+ // Arriving straight from a bookmark there is nothing behind, and the empty centre is the
+ // honest answer — the same thing `#/` shows.
+ return ;
case "conversation":
// A well-formed key that names nothing — a stale bookmark, or a thread this device has
// not discovered yet. `parse` deliberately does not check existence, and redirecting
@@ -272,6 +275,7 @@ export function Shell({ onLock, onForget }: { onLock: () => void; onForget: () =
+ {route.kind === "settings" && }
);
}
@@ -377,6 +381,7 @@ export function Shell({ onLock, onForget }: { onLock: () => void; onForget: () =
+ {route.kind === "settings" && }
);
}
diff --git a/apps/web/src/app/WebClientWarning.tsx b/apps/web/src/app/WebClientWarning.tsx
index 80e946a..d793d9f 100644
--- a/apps/web/src/app/WebClientWarning.tsx
+++ b/apps/web/src/app/WebClientWarning.tsx
@@ -15,6 +15,17 @@ import { isTauri } from "@/lib/platform";
* past: the empty centre, which is the first thing seen on a cold start, and the top of the
* settings screen, which is where someone goes when they are deciding how much to rely on this.
*
+ * # Two claims, and only one of them is about delivery
+ *
+ * The banner used to make both in one paragraph: that this code arrives from a server on every
+ * load, and that the project is unaudited. They are unrelated, they have different remedies, and
+ * merging them meant the desktop build — where the first is false — silently dropped the second
+ * as well. Somebody who installed the signed binary was told nothing about the audit, which is
+ * the half that still applies to them.
+ *
+ * So there are two banners now. [`DeliveryWarning`] is about the web target and answerable;
+ * [`AuditWarning`] is about the project and is not.
+ *
* # Why it is silent under Tauri
*
* The argument is specifically about **delivery**: a web server hands over this code on every
@@ -29,12 +40,56 @@ import { isTauri } from "@/lib/platform";
* not in a banner.
*/
export function WebClientWarning({ className }: { className?: string }) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+/**
+ * The delivery problem, and what can now be done about it.
+ *
+ * The sentence changed when the answer became true rather than because it read better. Every
+ * release publishes a manifest of the bundle's hashes, attested by GitHub to the commit and the
+ * workflow that produced it — so the claim "this is the published build" is checkable by somebody
+ * other than the party making it. `scripts/verify-web.sh` does it by hand; the extension under
+ * `extension/` does it continuously.
+ *
+ * **What did not change is that this banner stays.** The verdict cannot live in this page:
+ * everything here is drawn by the server being checked, so a badge that went green on a
+ * "verified" signal would be forged by exactly the server it was meant to catch. The check
+ * belongs in the extension's own icon, and this paragraph can do no more than say so.
+ */
+function DeliveryWarning({ className }: { className?: string }) {
if (isTauri()) return null;
return (
The server delivers this code on every load, and could deliver a version that exfiltrates
- your keys. No browser API fixes that. This is a learning project, unaudited — for genuinely
+ your keys. No browser API fixes that — but every release publishes the hashes of this
+ bundle, so what you were served can be compared against what the source produced. The
+ answer has to come from outside this page: see the repository’s{" "}
+ extension/ and scripts/verify-web.sh.
+
+ );
+}
+
+/**
+ * The audit, which no amount of build verification touches.
+ *
+ * Shown on **every** target, desktop included. A reproducible signed binary establishes that the
+ * bytes match the source; it says nothing about whether the source is right, and this project's
+ * README is explicit that no external review has happened or will. Hiding that from the people
+ * who took the trouble to install the packaged build would be telling the least worried users the
+ * least.
+ */
+function AuditWarning({ className }: { className?: string }) {
+ return (
+
+ This is a learning project. Its cryptography has had no external review, and a protocol that
+ is correct on paper fails in practice on details only an audit finds. For genuinely
sensitive conversations, use Signal.
);
diff --git a/apps/web/src/components/Notices.tsx b/apps/web/src/components/Notices.tsx
index fc95095..b1e2c7e 100644
--- a/apps/web/src/components/Notices.tsx
+++ b/apps/web/src/components/Notices.tsx
@@ -25,15 +25,17 @@
* What that does not solve is the switch staying live while permission is denied — it is still
* a recorded preference, and hiding it would lose the setting rather than explain it.
*/
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import {
DISCLOSE_NAME_COPY,
notificationPermission,
requestNotificationPermission,
} from "@/lib/notifications";
+import { PUSH_DISCLOSURE_COPY, pushEnabled, pushSupported } from "@/lib/push";
import { useReport } from "@/state/report";
import { useBump, useSession } from "@/state/SessionProvider";
+import { Banner } from "@/ui/Banner";
import { Button } from "@/ui/Button";
import { Field } from "@/ui/Field";
import { Panel } from "@/ui/Panel";
@@ -45,6 +47,16 @@ export function NoticeSettings() {
const report = useReport();
const [permission, setPermission] = useState(notificationPermission());
const [named, setNamed] = useState(session.discloseConversationName);
+ // Read from the browser rather than from the session, the way `Recovery.tsx` re-reads its
+ // factors from the server: the subscription is the state, and nothing of ours records it. It
+ // can also have gone away without this application being told — a browser drops a subscription
+ // when site data is cleared.
+ const [waking, setWaking] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ void pushEnabled().then(setWaking);
+ }, []);
// Called from a click and from nowhere else. Nothing in this component runs it on mount, and
// that is the whole reason the request lives behind a button rather than in an effect.
@@ -67,6 +79,63 @@ export function NoticeSettings() {
});
};
+ // Async, unlike the switch above it, because both directions talk to the browser and to the
+ // server. `busy` rather than an optimistic flip: a subscription that failed to register would
+ // otherwise leave a switch saying the phone will wake when it will not.
+ /**
+ * The tail of the chain of toggles, so that only one is ever in flight.
+ *
+ * `busy` disables the switch while one runs, and that is not enough on its own: subscribing and
+ * unsubscribing both reach the browser's push service, and two of them started a second apart
+ * finish in an order nobody chose. Observed, both ways round — a switch left saying this browser
+ * would be woken with nothing subscribed, and the reverse. Chaining makes the order the one the
+ * clicks were in, which is the only order a person can reason about.
+ */
+ const chain = useRef>(Promise.resolve());
+
+ const toggleWaking = (value: boolean) => {
+ setBusy(true);
+
+ const change = chain.current
+ // A failed toggle must not stop the next one: the chain is about ordering, not about
+ // carrying an error forward. The `.catch` below still reports this one.
+ .catch(() => undefined)
+ .then(() => (value ? session.enableWaking() : session.disableWaking().then(() => true)));
+
+ chain.current = change;
+
+ change
+ .then(async (done) => {
+ if (value && !done) {
+ // Not a failure: `Api.vapidPublicKey` answers null on a 503, which is this deployment
+ // saying it does not do push. Saying so is better than a switch that flips back with no
+ // explanation.
+ report.error("This server does not send wake-ups.");
+ }
+
+ // **Read back rather than trust the call.** Turning it off and on again inside a second
+ // leaves the browser's own unsubscribe still running while the new subscription is being
+ // made, and the second can lose to the first: the switch then says this browser will be
+ // woken while nothing is subscribed. Asking the browser what is true costs one call and
+ // makes that class of lie impossible — the subscription is the state, so it is the only
+ // thing worth displaying.
+ const actual = await pushEnabled();
+ setWaking(actual);
+
+ // Only claimed when it is true. A report that says "this browser will be woken" while the
+ // read-back disagrees would be the same lie one line further down.
+ if (actual === value) {
+ report.done(value ? "This browser will be woken." : "This browser will not be woken.");
+ }
+ })
+ .catch((e: unknown) => {
+ report.error(e instanceof Error ? e.message : String(e));
+ })
+ .finally(() => {
+ setBusy(false);
+ });
+ };
+
return (
Allow notifications
)}
+ {/*
+ * Below the permission cascade, because a wake-up that cannot show a notification is a
+ * wake-up for nothing — and above the disclosure switch, because that one refines what a
+ * notification says while this one decides whether there is one at all.
+ *
+ * The banner comes before the control, as on the recovery and vault screens: what this
+ * gives up is stated in the present tense, where somebody deciding will read it, and not
+ * in a hint under a switch they have already flipped.
+ */}
+ {pushSupported() ? (
+ <>
+
+ {PUSH_DISCLOSURE_COPY}
+
+
+
+ {(control) => (
+
+ )}
+
+ >
+ ) : null}
+
{(control) => (
{
+ return this.request("POST", "/v1/push/token", { provider, token });
+ }
+
+ /**
+ * Drops this device's wake address.
+ *
+ * The row goes rather than gaining a disabled flag: what is not stored cannot leak with a
+ * database later.
+ */
+ forgetPushToken(): Promise {
+ return this.request("POST", "/v1/push/forget", {});
+ }
+
+ /**
+ * The key a browser must subscribe against, or `null` when this deployment does not do push.
+ *
+ * Unsigned, and it has to be: a client asks before it has anything to subscribe. `null` on 503
+ * rather than a throw — a deployment without push is not an error, it is a deployment offering
+ * one fewer thing, and the screen hides the control the way it does for calls.
+ */
+ static async vapidPublicKey(): Promise {
+ const response = await fetch(`${BASE_URL}/v1/push/vapid`);
+
+ if (response.status === 503) return null;
+ if (!response.ok) throw new ApiError(response.status, await response.text());
+
+ const body = (await response.json()) as { key: string };
+ return body.key;
+ }
+
/** Stops or resumes broadcasting presence. Reciprocal: opting out means ceasing to see. */
setPresenceOptout(optout: boolean): Promise {
return this.request("POST", "/v1/presence/optout", { optout });
diff --git a/apps/web/src/lib/csp.test.ts b/apps/web/src/lib/csp.test.ts
index 8d06b3c..d2db5e5 100644
--- a/apps/web/src/lib/csp.test.ts
+++ b/apps/web/src/lib/csp.test.ts
@@ -14,7 +14,14 @@ import { csp } from "./csp.ts";
* be loud, and the only place it can be made loud is here.
*/
-/** The API origin the desktop configuration is pinned to, so both sides describe the same server. */
+/**
+ * The API origin the desktop configuration is pinned to.
+ *
+ * It is a **desktop-only** source now. The web policy names no origin at all — the API is reached
+ * on the page's own origin, so `'self'` covers it — while the packaged shell is loaded from
+ * `tauri://` and has to be told where the server is. That difference is a transport difference,
+ * exactly like `ipc:`, which is why it belongs in the list below rather than in both policies.
+ */
const DESKTOP_API = "http://127.0.0.1:8787";
/**
@@ -25,7 +32,14 @@ const DESKTOP_API = "http://127.0.0.1:8787";
* bundle. Adding to this list is a deliberate act; that is why it is a list and not a filter.
*/
const DESKTOP_ONLY: Record = {
- "connect-src": ["ipc:", "http://ipc.localhost"],
+ "connect-src": [
+ "ipc:",
+ "http://ipc.localhost",
+ // The two forms of the API origin. The web build stopped naming them when the client began
+ // asking its own origin; the desktop cannot, because its own origin is `tauri://`.
+ DESKTOP_API,
+ DESKTOP_API.replace(/^http/, "ws"),
+ ],
"img-src": ["asset:", "http://asset.localhost"],
};
@@ -53,7 +67,7 @@ function desktopPolicy(): string {
}
test("both targets declare exactly the same set of directives", () => {
- const web = [...parse(csp(DESKTOP_API)).keys()].sort();
+ const web = [...parse(csp()).keys()].sort();
const desktop = [...parse(desktopPolicy()).keys()].sort();
assert.deepEqual(
@@ -64,7 +78,7 @@ test("both targets declare exactly the same set of directives", () => {
});
test("every directive allows the same sources, apart from the declared desktop transports", () => {
- const web = parse(csp(DESKTOP_API));
+ const web = parse(csp());
const desktop = parse(desktopPolicy());
for (const [name, webSources] of web) {
@@ -93,7 +107,7 @@ test("the desktop policy allows the blob urls the image previews are made of", (
test("neither target ever allows a script source beyond this origin", () => {
for (const [target, policy] of [
- ["web", csp(DESKTOP_API)],
+ ["web", csp()],
["desktop", desktopPolicy()],
] as const) {
assert.deepEqual(
@@ -113,7 +127,7 @@ test("neither target ever allows a script source beyond this origin", () => {
*/
test("media-src is blob: and nothing else, on both targets", () => {
for (const [target, policy] of [
- ["web", csp(DESKTOP_API)],
+ ["web", csp()],
["desktop", desktopPolicy()],
] as const) {
assert.deepEqual(
@@ -132,7 +146,7 @@ test("media-src is blob: and nothing else, on both targets", () => {
*/
test("workers come from this origin and nowhere else, on both targets", () => {
for (const [target, policy] of [
- ["web", csp(DESKTOP_API)],
+ ["web", csp()],
["desktop", desktopPolicy()],
] as const) {
assert.deepEqual(
@@ -157,8 +171,8 @@ test("workers come from this origin and nowhere else, on both targets", () => {
* once because it would live in the default.
*/
test("the media origin appears only when a build asks for one", () => {
- const without = parse(csp(DESKTOP_API)).get("connect-src") ?? new Set();
- const with_ = parse(csp(DESKTOP_API, "https://media.example")).get("connect-src") ?? new Set();
+ const without = parse(csp()).get("connect-src") ?? new Set();
+ const with_ = parse(csp("https://media.example")).get("connect-src") ?? new Set();
assert.ok(with_.has("wss://media.example"), "the signalling socket has no origin to reach");
// The HTTP form is not redundant, and this assertion is here because the first version of this
@@ -172,6 +186,37 @@ test("the media origin appears only when a build asks for one", () => {
);
});
+/**
+ * **The regression that made `bothSchemes` exist.**
+ *
+ * The pair was derived with `media.replace(/^http/, "ws")`, which only works on a variable spelled
+ * `http://`. `.env.example` recommends `ws://127.0.0.1:7880` for a local media server, and a
+ * LiveKit URL is written that way everywhere — given one, the replacement matched nothing, the
+ * policy listed the same origin twice, and the HTTP form the test above insists on was absent.
+ *
+ * The test above did not catch it because it only ever passed `https://`. This one passes the
+ * other spelling, which is the one a deployment is most likely to write.
+ */
+test("both forms are derived however the media origin is spelled", () => {
+ for (const [written, expected] of [
+ ["ws://127.0.0.1:7880", ["http://127.0.0.1:7880", "ws://127.0.0.1:7880"]],
+ ["wss://media.example", ["https://media.example", "wss://media.example"]],
+ ["http://127.0.0.1:7880", ["http://127.0.0.1:7880", "ws://127.0.0.1:7880"]],
+ ["https://media.example", ["https://media.example", "wss://media.example"]],
+ ] as const) {
+ const sources = parse(csp(written)).get("connect-src") ?? new Set();
+
+ for (const origin of expected) {
+ assert.ok(sources.has(origin), `${written} does not allow ${origin}`);
+ }
+
+ // A `Set` would hide a duplicate, so the raw directive is what is counted.
+ const directive = csp(written).split("; ").find((part) => part.startsWith("connect-src")) ?? "";
+ const occurrences = directive.split(" ").filter((source) => source === written).length;
+ assert.equal(occurrences, 1, `${written} appears ${occurrences} times in connect-src`);
+ }
+});
+
test("media-src is not among the differences the desktop target is allowed", () => {
assert.equal(
DESKTOP_ONLY["media-src"],
diff --git a/apps/web/src/lib/csp.ts b/apps/web/src/lib/csp.ts
index 01d8282..d4eb4a7 100644
--- a/apps/web/src/lib/csp.ts
+++ b/apps/web/src/lib/csp.ts
@@ -33,11 +33,16 @@
* sees nothing, and the message does not name the cause. Same trap as the one documented on the
* CORS header list, server side.
*
- * # Why `connect-src` carries two origins
+ * # Why `connect-src` names no origin any more
*
- * `connect-src` does **not** infer the `ws://` origin from the matching `http://` one. Either one
- * alone would cut half the client — requests or the real-time session — without the other
- * signalling it.
+ * It used to carry two — the API's `http://` form and its `ws://` form, because `connect-src` does
+ * not infer one from the other. Both are gone: the API is now reached relatively, on the page's own
+ * origin, so `'self'` covers it. CSP level 3 extends `'self'` to `wss:` under an `https:` document,
+ * which is what the second origin was for.
+ *
+ * That is tighter than what it replaces, and it is also what lets one build serve every deployment:
+ * the policy no longer depends on `VITE_API_URL`, which was substituted into `index.html` at build
+ * time and made every instance's bytes different.
*
* # No nonce, and that is hardening
*
@@ -48,8 +53,21 @@
* No browser policy stands in the way — only the desktop app, whose code is packaged into the
* installed binary, closes that path.
*/
-export function csp(api: string, media?: string): string {
- const websocket = api.replace(/^http/, "ws");
+/**
+ * One origin, spelled both ways.
+ *
+ * A `connect-src` source matches on scheme, so `wss://host` and `https://host` are two sources
+ * and a policy needs whichever the code will actually use. Normalising to HTTP first makes the
+ * function total: it accepts `ws`, `wss`, `http` or `https` and returns the pair, rather than
+ * quietly returning its input for two of the four.
+ */
+function bothSchemes(origin: string): [string, string] {
+ const http = origin.replace(/^ws/, "http");
+
+ return [http, http.replace(/^http/, "ws")];
+}
+
+export function csp(media?: string): string {
// The media server is a second origin, and it is absent from most deployments: a build with no
// media server must not widen its policy for a host it will never contact. Empty rather than a
// default, so the directive is exactly as wide as the deployment is.
@@ -64,7 +82,15 @@ export function csp(api: string, media?: string): string {
//
// The audio itself travels over WebRTC, which no directive here can constrain — see
// `lib/call.ts` for what does.
- const relay = media ? ` ${media} ${media.replace(/^http/, "ws")}` : "";
+ //
+ // **Both forms are derived, whichever one the deployment wrote.** This used to be
+ // `media.replace(/^http/, "ws")`, which assumed the variable was spelled `http://`. Given the
+ // `ws://` form — which is what `.env.example` recommends for a local media server, and what a
+ // LiveKit URL looks like everywhere — the replacement matched nothing and returned its input.
+ // The policy then listed the same origin twice and **omitted the HTTP one entirely**: the
+ // paragraph above says why that costs a broken call its explanation. The test only ever passed
+ // `https://`, so it agreed.
+ const relay = media ? ` ${bothSchemes(media).join(" ")}` : "";
return [
"default-src 'self'",
@@ -74,7 +100,19 @@ export function csp(api: string, media?: string): string {
// Tailwind injects its styles at runtime. The residual risk of a CSS injection is nowhere
// near that of a script.
"style-src 'self' 'unsafe-inline'",
- `connect-src 'self' ${api} ${websocket}${relay}`,
+ // **`'self'` and no origin, which is both tighter and the reason one build serves every
+ // deployment.** The API shares this page's origin — `deploy/` puts Caddy in front of both, and
+ // the development server proxies `/v1` — so naming a host would be naming the host we are
+ // already on.
+ //
+ // It covers the WebSocket too: CSP level 3 matches `wss:` under `'self'` when the document is
+ // `https:`, and `ws:` when it is `http:`. That is what the two spelled-out origins used to be
+ // for, and it is why they are not missed.
+ //
+ // What this buys beyond tightness: the policy no longer depends on `VITE_API_URL`, which used
+ // to be substituted into `index.html` at build time and made every deployment's bytes
+ // different. See `api.ts` on why that mattered.
+ `connect-src 'self'${relay}`,
// `blob:` is for image previews, and it is not a hole reopening.
//
// What a received image gets displayed as is a canvas re-encoding of what an image decoder
diff --git a/apps/web/src/lib/gateway.ts b/apps/web/src/lib/gateway.ts
index 56db3f9..6b4b20d 100644
--- a/apps/web/src/lib/gateway.ts
+++ b/apps/web/src/lib/gateway.ts
@@ -29,7 +29,7 @@
* discovered along the way is added with a `subscribe` frame, without reopening the connection or
* signing another challenge.
*/
-import { BASE_URL, type Api, type GatewayChallenge } from "./api";
+import { socketUrl, type Api, type GatewayChallenge } from "./api";
import { fromBase64, fromHex, toBase64, toHex } from "./keys";
export interface GatewayHandlers {
@@ -179,7 +179,7 @@ export class Gateway {
/** One session, from open to close. Resolves on close, rejects on error. */
private session(): Promise {
return new Promise((resolve, reject) => {
- const url = `${BASE_URL.replace(/^http/, "ws")}/v1/gateway`;
+ const url = socketUrl("/v1/gateway");
const socket = new WebSocket(url);
socket.binaryType = "arraybuffer";
this.socket = socket;
diff --git a/apps/web/src/lib/notifications.ts b/apps/web/src/lib/notifications.ts
index b50c646..72de834 100644
--- a/apps/web/src/lib/notifications.ts
+++ b/apps/web/src/lib/notifications.ts
@@ -311,12 +311,23 @@ export function createNotifier({
});
} catch {
// `new Notification()` throws `TypeError: Illegal constructor` in the Android Chrome tab,
- // where notifications exist only through a service worker's registration. There is no
- // service worker here — one would be a cache of the application shell served by the same
- // server the desktop build exists to stop trusting — so on that browser this feature is
- // simply absent. Swallowed rather than reported: the caller has no repair to offer, and
- // an error banner for "your browser cannot do this" on every message would be worse than
- // the silence.
+ // where notifications exist only through a service worker's registration. Swallowed
+ // rather than reported: the caller has no repair to offer, and an error banner for "your
+ // browser cannot do this" on every message would be worse than the silence.
+ //
+ // **This used to say there was no service worker here, and that a worker would be a cache
+ // of the application shell served by the same server the desktop build exists to stop
+ // trusting.** There is one now — `public/sw.js` — and the sentence needed amending rather
+ // than deleting, because the objection it made is still right about the thing it names.
+ // That worker caches nothing: no `fetch` handler, no `Cache`, no precache manifest, and
+ // `push.test.ts` asserts the absence rather than trusting the comment. It exists because
+ // the Push API has no other delivery point — a push message wakes the worker, never a
+ // document — and it cannot serve a stale application because it cannot serve one at all.
+ //
+ // What that does **not** fix is the path this `catch` is on: the worker only runs for a
+ // push, so a tab open on Android Chrome still has no notification to show. The feature is
+ // absent there exactly as before, and `lib/push.ts` covers the other case — the tab
+ // closed — on the browsers that offer Web Push.
return;
}
diff --git a/apps/web/src/lib/pinning.ts b/apps/web/src/lib/pinning.ts
index b4b72f7..0d890a6 100644
--- a/apps/web/src/lib/pinning.ts
+++ b/apps/web/src/lib/pinning.ts
@@ -34,22 +34,41 @@
import { fromBase64 } from "./keys";
/**
- * The pinned key, or `undefined` when this build was compiled without one.
+ * The pinned key, or `undefined` when nothing pinned one.
*
- * Read once. A malformed value is a build-time mistake and it is loud: refusing to start beats
- * running with a pin that silently checks nothing, which is the failure this whole module exists
- * to avoid — a check that looks present and is not.
+ * Read once. A malformed value is loud: refusing to start beats running with a pin that silently
+ * checks nothing, which is the failure this whole module exists to avoid — a check that looks
+ * present and is not.
*/
export const PINNED_LOG_KEY: Uint8Array | undefined = readPin();
+/**
+ * # Why this is injected and no longer compiled in
+ *
+ * It used to be `import.meta.env.VITE_LOG_PUBKEY`, substituted into the bundle by Vite. That meant
+ * every deployment produced different bytes, and a published manifest of file hashes could
+ * therefore describe only one of them — which is what stood between this project and a client
+ * anybody can check against the source it claims to be built from.
+ *
+ * Taking it out of the **web** bundle costs less than it looks, and the paragraphs above say why:
+ * there the server ships the pin along with the code the pin constrains, so it was never a defence
+ * against the party building the bundle. What it did buy — turning a silent substitution into one
+ * that breaks every client at once — is exactly what a verifiable build provides, and provides
+ * better: a mismatch becomes something a reader can detect deliberately rather than something they
+ * notice because the application stopped working.
+ *
+ * On the **desktop** the pin keeps its full value, and keeps it for the same reason it had it: the
+ * interface lives inside a signed, reproducible artefact. The native side sets the global before
+ * the webview runs, so the value is in the binary rather than in these bytes.
+ */
function readPin(): Uint8Array | undefined {
- const raw = import.meta.env.VITE_LOG_PUBKEY;
+ const raw = (globalThis as { __WHISPEE_LOG_KEY__?: unknown }).__WHISPEE_LOG_KEY__;
if (typeof raw !== "string" || raw === "") return undefined;
const key = fromBase64(raw);
if (key.length !== 32) {
throw new Error(
- `VITE_LOG_PUBKEY must be 32 bytes of base64 Ed25519 public key, got ${key.length}`,
+ `the pinned log key must be 32 bytes of base64 Ed25519 public key, got ${key.length}`,
);
}
return key;
diff --git a/apps/web/src/lib/push.test.ts b/apps/web/src/lib/push.test.ts
new file mode 100644
index 0000000..a96f0b1
--- /dev/null
+++ b/apps/web/src/lib/push.test.ts
@@ -0,0 +1,109 @@
+/**
+ * What can be tested about Web Push without a browser.
+ *
+ * `node --test` has no `navigator`, no service worker and no `PushManager`, so the subscription
+ * path itself is exercised by hand in a real browser — the procedure is in `docs/DEPLOY.md`, and
+ * it is what separates this feature from a hope. What is here is the part that is pure and that
+ * fails silently in production if it is wrong: the key decoding, the capability check, and the
+ * two strings the service worker cannot import.
+ */
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { test } from "node:test";
+
+import { NOTICE_BODY_ONE, NOTICE_TITLE } from "./notifications.ts";
+import { PROVIDER, decodeApplicationServerKey, pushSupported } from "./push.ts";
+
+/**
+ * The provider name is a wire value shared with the server.
+ *
+ * `push::WEB_PUSH` is the other half. They are two constants in two languages and nothing but this
+ * assertion connects them: a rename on one side alone produces a subscription the emitter skips —
+ * silently, because skipping an unknown provider is exactly what it is meant to do for a token
+ * whose provider has not landed yet.
+ */
+test("the provider name matches the one the server files subscriptions under", () => {
+ const source = readFileSync(new URL("../../../../crates/server/src/push.rs", import.meta.url), "utf8");
+
+ assert.match(
+ source,
+ new RegExp(`pub const WEB_PUSH: &str = "${PROVIDER}";`),
+ "the client and the server disagree on the provider name",
+ );
+});
+
+/**
+ * The worker's copy is duplicated rather than imported, and this is what keeps the copy honest.
+ *
+ * A service worker is its own module graph, served as a plain file so what is deployed is what can
+ * be read. That costs two literals. Left unchecked they drift, and the drift is invisible: the
+ * notification simply starts saying something the rest of the application does not.
+ */
+test("the service worker shows the same words the application does", () => {
+ const worker = readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8");
+
+ assert.match(worker, new RegExp(`const TITLE = "${NOTICE_TITLE}";`));
+ assert.match(worker, new RegExp(`const BODY = "${NOTICE_BODY_ONE}";`));
+});
+
+/**
+ * **The property that matters about the worker**, and it is an absence.
+ *
+ * `notifications.ts` refused a service worker because one would cache the application shell served
+ * by the server the desktop build exists to stop trusting. This worker is allowed to exist because
+ * it caches nothing. That is not a promise in a comment: a `fetch` handler or a `caches` call is
+ * what would turn it into the thing that was refused, so their absence is asserted.
+ */
+test("the service worker intercepts nothing and caches nothing", () => {
+ const worker = readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8");
+ const code = worker.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
+
+ assert.doesNotMatch(code, /addEventListener\(\s*["']fetch["']/, "it would serve a stale bundle");
+ assert.doesNotMatch(code, /caches\b/, "it would keep a copy of the application");
+});
+
+test("a base64url key decodes to the sixty-five bytes of an uncompressed point", () => {
+ // A real P-256 public key as the server advertises it: 0x04 then two 32-byte coordinates.
+ const raw = new Uint8Array(65);
+ raw[0] = 0x04;
+ for (let index = 1; index < raw.length; index += 1) raw[index] = index;
+
+ const base64url = Buffer.from(raw)
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+
+ assert.deepEqual([...decodeApplicationServerKey(base64url)], [...raw]);
+});
+
+/**
+ * The padding is restored before decoding, and that is the whole reason this function exists
+ * rather than a bare `atob`.
+ *
+ * base64url as this protocol writes it is unpadded, `atob` demands padding, and the failure is an
+ * `InvalidCharacterError` raised inside the browser that names neither the value nor the caller.
+ */
+test("an unpadded key is decoded rather than refused", () => {
+ // Three bytes encode to four characters with no padding; two bytes need one `=`, one needs two.
+ assert.deepEqual([...decodeApplicationServerKey("AQID")], [1, 2, 3]);
+ assert.deepEqual([...decodeApplicationServerKey("AQI")], [1, 2]);
+ assert.deepEqual([...decodeApplicationServerKey("AQ")], [1]);
+});
+
+/** The two characters base64url replaces are the ones a raw key is most likely to contain. */
+test("the url alphabet is translated back", () => {
+ assert.deepEqual([...decodeApplicationServerKey("-_8")], [251, 255]);
+});
+
+/**
+ * Under `node --test` there is no `navigator` and no `PushManager`, so the capability check must
+ * answer no rather than throw.
+ *
+ * That is not a concession to the harness: it is the same answer a browser without push gives,
+ * and the screen hides the control on it. A `ReferenceError` here would take the settings screen
+ * down on exactly those browsers.
+ */
+test("push reports itself unsupported where the browser offers nothing", () => {
+ assert.equal(pushSupported(), false);
+});
diff --git a/apps/web/src/lib/push.ts b/apps/web/src/lib/push.ts
new file mode 100644
index 0000000..9b5ac4c
--- /dev/null
+++ b/apps/web/src/lib/push.ts
@@ -0,0 +1,200 @@
+/**
+ * Web Push, from the browser's side.
+ *
+ * # What this gets you, and what it costs
+ *
+ * A message arriving while the tab is closed wakes the browser, which shows "New message" and
+ * nothing else. That is the whole feature. `notifications.ts` already handles the case where the
+ * tab is open, and keeps handling it — the two do not overlap, because a push handler only runs
+ * when no page is there to.
+ *
+ * The cost is two things, and both belong on the screen before the switch rather than in a
+ * document afterwards:
+ *
+ * 1. **The browser's push service learns the rhythm.** Chrome subscribes through Google, Firefox
+ * through Mozilla. That service sees a wake-up arrive for this browser every time a message
+ * does, and it can tie that to an IP address. The content stays encrypted; the timing does not.
+ * 2. **The server learns who to wake, which sealed sender was built to remove.** A server that
+ * chooses whom to wake gains a targeted activity trigger: ceasing to wake four members of five
+ * makes the next post attributable to the fifth. Nothing cryptographic answers this — see
+ * `docs/ROADMAP.md`, which says so at more length.
+ *
+ * # Why there is no stored setting
+ *
+ * The subscription itself is the state. `pushManager.getSubscription()` answers "is this browser
+ * subscribed" without anything of ours being written down, which is both one fewer thing to keep
+ * in step and the answer to something the roadmap asks for: a token has to be re-registered at
+ * every start, because it rotates without warning. Re-registering is just sending back whatever
+ * `getSubscription()` returns, so the replay and the read are the same operation.
+ *
+ * It is also per browser and not per account. `signal-sync.ts` gives the test — a fact about the
+ * *machine* rather than about the *account* — which is why this is never synchronised between
+ * devices, the same reason `locale` is not.
+ *
+ * # Why no payload
+ *
+ * Because the wake-up carries nothing, the whole content-encryption half of Web Push (RFC 8291)
+ * is unused: the `p256dh` and `auth` secrets a subscription carries are never read here and never
+ * sent anywhere. See `crates/server/src/vapid.rs` for the same observation from the other end.
+ */
+/**
+ * What this module needs from the server, and nothing more.
+ *
+ * A structural port rather than an import of `Api`, for the reason `notifications.ts` gives about
+ * every browser object it touches: `api.ts` uses constructor parameter properties, which
+ * `node --test` cannot strip, so importing it would make this module untestable. Naming the two
+ * methods used is also a shorter statement of what waking a browser can reach than a class with
+ * fifty.
+ */
+export interface PushApi {
+ setPushToken(provider: string, token: string): Promise;
+ forgetPushToken(): Promise;
+}
+
+/** The provider name the server files this subscription under. Must match `push::WEB_PUSH`. */
+export const PROVIDER = "webpush";
+
+/**
+ * Copy for the settings screen, stated before the choice — the same discipline as
+ * `DISCLOSE_NAME_COPY` and the vault screen, and exported from beside the behaviour so the
+ * sentence and the code cannot drift apart.
+ */
+export const PUSH_DISCLOSURE_COPY =
+ "Waking this browser means two things leave. Your browser's push service — Google for Chrome, " +
+ "Mozilla for Firefox — learns each time a message arrives for you, and can tie that to your " +
+ "address. And this server learns which devices to wake, which is exactly what it was arranged " +
+ "not to know: a server that stops waking four members of five can tell who wrote the next " +
+ "message. Nothing in the message itself is disclosed — the notification says a message " +
+ "arrived and never what it says or who sent it.";
+
+/**
+ * Is Web Push usable here at all?
+ *
+ * Three conditions, and the third is the one that surprises people: a secure context. Service
+ * workers and the Push API are both refused over plain http, `localhost` excepted.
+ */
+export function pushSupported(): boolean {
+ return (
+ typeof navigator !== "undefined" &&
+ "serviceWorker" in navigator &&
+ typeof window !== "undefined" &&
+ "PushManager" in window &&
+ window.isSecureContext
+ );
+}
+
+/**
+ * Decodes the server's key into the form `subscribe` demands.
+ *
+ * The key travels as base64url because that is how it is written everywhere in this protocol, and
+ * arrives as a `BufferSource` because that is what the browser takes. Exported for its test: an
+ * error here produces `InvalidCharacterError` from deep inside the browser, which names nothing.
+ */
+export function decodeApplicationServerKey(base64url: string): Uint8Array {
+ const padded = base64url.replace(/-/g, "+").replace(/_/g, "/");
+ const binary = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), "="));
+
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
+}
+
+/**
+ * Registers the worker, or `null` where it cannot be.
+ *
+ * Scoped to the root because that is where the file is served from and where the notification's
+ * click has to land. Failure is a `null`, not a throw: an unsupported browser and a blocked
+ * registration are the same thing to every caller here — this feature is absent — and neither is
+ * worth an error dialog on a path the user did not ask for.
+ */
+async function worker(): Promise {
+ if (!pushSupported()) return null;
+
+ try {
+ return await navigator.serviceWorker.register("/sw.js", { scope: "/" });
+ } catch (error) {
+ console.warn("service worker not registered", error);
+ return null;
+ }
+}
+
+/** Is this browser subscribed right now? The subscription is the state; nothing else is read. */
+export async function pushEnabled(): Promise {
+ const registration = await worker();
+ if (!registration) return false;
+
+ return (await registration.pushManager.getSubscription()) !== null;
+}
+
+/**
+ * Subscribes this browser and hands the endpoint to the server.
+ *
+ * Returns `false` when the deployment does not do push — `Api.vapidPublicKey` answers `null` on a
+ * 503 — so the caller can say "this server does not offer that" rather than "it failed".
+ *
+ * **Notification permission is not requested here.** It belongs to a click, and `Notices.tsx`
+ * already owns that; `subscribe` with `userVisibleOnly` would raise the prompt itself, from
+ * whatever code path happened to call it. Asked for from a settings screen it is a question;
+ * raised from a replay after a reconnection it is an ambush.
+ */
+export async function enablePush(api: PushApi, key: string | null): Promise {
+ const registration = await worker();
+ if (!registration) return false;
+
+ // `null` is this deployment answering 503 on the key route: it does not do push. Fetched by the
+ // caller rather than here, because the route is unsigned and `Api` exposes it as a static —
+ // and because this module stays free of `api.ts`, which it cannot import. See `PushApi`.
+ if (key === null) return false;
+
+ const subscription =
+ (await registration.pushManager.getSubscription()) ??
+ (await registration.pushManager.subscribe({
+ // Required by every browser that implements this, and it is not a formality: it is the
+ // promise that every wake-up produces something the user sees. A silent push is what a
+ // tracker would want, and the worker keeps that promise by always showing a notification.
+ userVisibleOnly: true,
+ applicationServerKey: decodeApplicationServerKey(key),
+ }));
+
+ await api.setPushToken(PROVIDER, subscription.endpoint);
+ return true;
+}
+
+/**
+ * Unsubscribes, and tells the server to forget the address.
+ *
+ * Both halves, in that order, and neither is enough alone: dropping the local subscription while
+ * the server keeps the endpoint leaves it pushing into a void until the service reports it gone,
+ * and forgetting it server-side while the browser stays subscribed leaves a live subscription
+ * nobody uses.
+ */
+export async function disablePush(api: PushApi): Promise {
+ const registration = await worker();
+ const subscription = await registration?.pushManager.getSubscription();
+
+ await subscription?.unsubscribe();
+ await api.forgetPushToken();
+}
+
+/**
+ * Re-sends the endpoint this browser already holds, if any.
+ *
+ * Called at every start and after every reconnection, which is what the roadmap asks for: a push
+ * address rotates without warning, and a browser that re-subscribes on its own would otherwise be
+ * reachable at an address the server does not have. Doing nothing when there is no subscription
+ * is the point — this must never turn the feature on by itself.
+ *
+ * Silent on failure. It runs on a path nobody asked for; a toast here would report a problem the
+ * user did not cause and cannot act on.
+ */
+export async function replayPushToken(api: PushApi): Promise {
+ try {
+ if (!pushSupported()) return;
+
+ const registration = await navigator.serviceWorker.getRegistration("/");
+ const subscription = await registration?.pushManager.getSubscription();
+ if (!subscription) return;
+
+ await api.setPushToken(PROVIDER, subscription.endpoint);
+ } catch (error) {
+ console.warn("wake address not re-registered", error);
+ }
+}
diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts
index 9135459..5ca1eda 100644
--- a/apps/web/src/lib/session.ts
+++ b/apps/web/src/lib/session.ts
@@ -17,6 +17,7 @@ import { type AttachmentRef, downloadAndDecrypt, encryptAndUpload } from "./atta
import * as content from "./content";
import * as envelope from "./envelope";
import { expiryOf, prune } from "./expiry.ts";
+import { disablePush, enablePush, replayPushToken } from "./push.ts";
import { type Cached, decodeHistory } from "./history";
import { PINNED_LOG_KEY } from "./pinning";
import * as derive from "./conversation-view.ts";
@@ -1180,6 +1181,40 @@ export class Session {
return enablePasskeyRecovery(this.api, this.accountId, this.handle, this.account.exportSeed());
}
+ /**
+ * Subscribes this browser to wake-ups, and hands the address to the server.
+ *
+ * `false` means the deployment does not do push, not that something failed — the screen says so
+ * rather than flipping a switch back with no explanation.
+ *
+ * Per browser, deliberately, and therefore not a preference: it is a fact about this machine,
+ * which is the test `signal-sync.ts` states for what does and does not sync between an account's
+ * devices. Nothing here is stored or announced.
+ */
+ async enableWaking(): Promise {
+ // The key is read here rather than inside `enablePush`: the route is unsigned, `Api` exposes
+ // it as a static, and `lib/push.ts` deliberately imports nothing from `api.ts` so that it can
+ // be tested without a browser.
+ return enablePush(this.api, await Api.vapidPublicKey());
+ }
+
+ /** Unsubscribes this browser and drops the address the server holds. */
+ disableWaking(): Promise {
+ return disablePush(this.api);
+ }
+
+ /**
+ * Re-sends the wake address this browser already holds.
+ *
+ * At every start and after every reconnection: a push address rotates without warning, and a
+ * browser that renewed its subscription on its own would otherwise be reachable at an address
+ * the server does not have. Does nothing when there is no subscription — this must never turn
+ * waking on by itself.
+ */
+ replayWaking(): Promise {
+ return replayPushToken(this.api);
+ }
+
/** Removes a recovery factor. Removing one that is not there is a success. */
async forgetRecovery(kind: RecoveryKind): Promise {
await this.api.forgetRecovery(kind);
diff --git a/apps/web/src/state/report.ts b/apps/web/src/state/report.ts
index cde7213..4a91eb3 100644
--- a/apps/web/src/state/report.ts
+++ b/apps/web/src/state/report.ts
@@ -12,10 +12,14 @@
*
* # Two surfaces, because the two have different lifetimes
*
- * **Errors go to a banner, and the banner is dismissible.** That rule is inherited verbatim from
- * `App.tsx` and it is worth restating: an error you cannot wave away ends up part of the scenery,
- * and scenery is not read. It stays until it is dismissed or replaced, because an error usually
- * means something still has to be decided.
+ * **Errors stay until dismissed or replaced, and they are always dismissible.** That rule is
+ * inherited verbatim from `App.tsx` and it is worth restating: an error you cannot wave away ends
+ * up part of the scenery, and scenery is not read. It stays because an error usually means
+ * something still has to be decided.
+ *
+ * It used to be a full-bleed banner mounted in the shell's flex column, which shrank the
+ * conversation for as long as it stood. It floats now, beside the confirmations — the lifetime
+ * did not move, only the rendering. `ui/Toast.tsx` draws both.
*
* **Successes go to a toast, one at a time, for four seconds.** A success has already happened;
* nothing is pending on the reader, so it expires on its own. One at a time rather than a stack:
@@ -27,8 +31,8 @@
*
* # What this module does not render
*
- * No DOM. It owns the state and the timer, and hands both out. `ui/Toast.tsx` and the shell draw
- * the banner and the toast from `useReported()`.
+ * No DOM. It owns the state and the timer, and hands both out. `ui/Toast.tsx` draws both from
+ * `useReported()`.
*
* The contract that matters for them: **the expiry lives here, not in the component.** A toast
* component that ran its own `setTimeout` would restart it on every re-render of its parent, and
@@ -52,29 +56,64 @@ import {
/** How long a confirmation stays up. Long enough to read a short sentence, short enough to ignore. */
export const TOAST_MS = 4000;
-export interface Toast {
- /**
- * Distinguishes two toasts carrying the same text — "Copied" twice in a row is the ordinary
- * case, and without this the second one would be indistinguishable from the first still hanging
- * around.
- */
+/**
+ * Something the reader can do about what just happened.
+ *
+ * Optional, and rare on purpose. A confirmation has nothing to offer — the thing already worked.
+ * A failure sometimes does: "Retry" on a send that did not go out is worth more than a sentence
+ * explaining that it did not.
+ *
+ * `run` is called on click and nothing else happens: dismissing afterwards is the caller's
+ * business, because only it knows whether the retry succeeded. An action that reported its own
+ * outcome would raise a second message on top of the first, and the first is what it replaced.
+ */
+export interface Action {
+ label: string;
+ run: () => void;
+}
+
+/**
+ * One thing to say, whichever surface it lands on.
+ *
+ * # Why the error has an id now
+ *
+ * It did not, and that was an asymmetry with a cost. The id is what a React `key` uses to tell a
+ * replacement from the same message still hanging around — "Copied" twice in a row is the ordinary
+ * case for a confirmation, and two identical failures in a row is the ordinary case for a poll
+ * against a server that is down. Without it the second one silently reuses the first one's node
+ * and the entrance never replays, so nothing on screen says a new thing happened.
+ */
+export interface Message {
id: number;
message: string;
+ action?: Action;
}
+/** Kept as the old name for what a confirmation is, because that is what the callers call it. */
+export type Toast = Message;
+
/** What the shell needs in order to draw. */
export interface Reported {
/** The standing error, or null. Survives until dismissed or replaced. */
- error: string | null;
+ error: Message | null;
/** The confirmation currently on screen, or null. Expires by itself. */
- toast: Toast | null;
+ toast: Message | null;
dismissError: () => void;
+ /**
+ * Takes a confirmation down before its time is up.
+ *
+ * It expires on its own, so nothing needs this to be correct — until the surface drawing it
+ * lets somebody swipe or close it. A dismissal the state does not hear about is a toast that
+ * reappears on the next render, which looks like a bug in the message rather than in the
+ * plumbing.
+ */
+ dismissToast: () => void;
}
/** What everybody else needs in order to speak. */
export interface Report {
- error: (message: string) => void;
- done: (message: string) => void;
+ error: (message: string, action?: Action) => void;
+ done: (message: string, action?: Action) => void;
}
/**
@@ -88,8 +127,8 @@ const ReportContext = createContext(null);
const ReportedContext = createContext(null);
export function ReportProvider({ children }: { children: ReactNode }) {
- const [error, setError] = useState(null);
- const [toast, setToast] = useState(null);
+ const [error, setError] = useState(null);
+ const [toast, setToast] = useState(null);
/**
* Monotonic, and never reset. It only has to be unique within one run of the application; the
* alternative — a timestamp — collides for two toasts raised in the same millisecond, which is
@@ -100,13 +139,18 @@ export function ReportProvider({ children }: { children: ReactNode }) {
const report = useMemo(
() => ({
- error: (message) => setError(message),
- done: (message) => {
+ // No timer, and that is the whole difference between the two. An error waits to be
+ // dismissed or replaced; see this module's header for why.
+ error: (message, action) => {
+ nextId.current += 1;
+ setError({ id: nextId.current, message, action });
+ },
+ done: (message, action) => {
// Cleared before rescheduling: without this, the first toast's timer would still be
// running and would take the replacement down early.
clearTimeout(expiry.current);
nextId.current += 1;
- setToast({ id: nextId.current, message });
+ setToast({ id: nextId.current, message, action });
expiry.current = setTimeout(() => setToast(null), TOAST_MS);
},
}),
@@ -118,9 +162,15 @@ export function ReportProvider({ children }: { children: ReactNode }) {
useEffect(() => () => clearTimeout(expiry.current), []);
const dismissError = useCallback(() => setError(null), []);
+ const dismissToast = useCallback(() => {
+ // The timer goes with it: left running it would fire against a toast that is already gone,
+ // which is harmless today and would not be if this ever cleared something newer.
+ clearTimeout(expiry.current);
+ setToast(null);
+ }, []);
const reported = useMemo(
- () => ({ error, toast, dismissError }),
- [error, toast, dismissError],
+ () => ({ error, toast, dismissError, dismissToast }),
+ [error, toast, dismissError, dismissToast],
);
return createElement(
diff --git a/apps/web/src/ui/Dialog.tsx b/apps/web/src/ui/Dialog.tsx
index 6c9fd5a..7d00c29 100644
--- a/apps/web/src/ui/Dialog.tsx
+++ b/apps/web/src/ui/Dialog.tsx
@@ -46,6 +46,20 @@ import { useOverlayContainer } from "./Overlays.tsx";
* `variant="destructive"` action by its caller, because only the caller knows which of its
* actions is the dangerous one.
*
+ * # `size="panel"` is for a screen, not a question
+ *
+ * The default is a prompt: narrow, padded, one thing to answer. Settings are neither — ten
+ * sections in three groups, a list beside the section it opens — and cramming that into `max-w-md`
+ * would produce a column of truncated labels.
+ *
+ * It is a size on this component rather than a second modal elsewhere, because the four absences
+ * above are the reason this file exists. A settings modal built beside it would omit the same
+ * four, invisibly, and nobody would notice until a keyboard user tabbed out of it into a
+ * conversation they could not see.
+ *
+ * What `panel` changes is layout only: wider, taller, and no padding of its own — the content owns
+ * its own scrolling regions, which a prompt never needs.
+ *
* What this does not solve: nothing here debounces. A dialog opened by a key that repeats, or by
* two components at once, is a call-site problem — `open` is controlled, and whoever owns it
* owns that.
@@ -59,6 +73,7 @@ export function Dialog({
actions,
children,
tone = "default",
+ size = "prompt",
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -72,6 +87,8 @@ export function Dialog({
actions?: ReactNode;
children?: ReactNode;
tone?: "default" | "danger";
+ /** `prompt` asks one question; `panel` holds a screen. See the note above. */
+ size?: "prompt" | "panel";
}): ReactElement {
const container = useOverlayContainer();
@@ -100,18 +117,25 @@ export function Dialog({
"fixed left-1/2 top-1/2 z-(--z-index-overlay) -translate-x-1/2 -translate-y-1/2",
// Never wider than the window minus a margin, never taller than it: a dialog that
// overflows the viewport puts its actions off-screen, where they cannot be reached.
- "w-[calc(100%-2rem)] max-w-md max-h-[calc(100dvh-2rem)] overflow-y-auto",
+ "w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)]",
+ size === "panel"
+ // Tall as well as wide, and a fixed height rather than a maximum: the content is two
+ // scrolling columns, and a box that shrinks to its shortest column would make the
+ // list jump every time a section with less in it is opened.
+ ? "max-w-3xl h-[calc(100dvh-2rem)] sm:h-[44rem] overflow-hidden flex flex-col"
+ : "max-w-md overflow-y-auto",
// The portal is outside the layout, so the shell's insets do not reach it.
"safe-sides",
- "rounded-control border bg-(--color-surface-raised) p-pane shadow-overlay",
+ "rounded-control border bg-(--color-surface-raised) shadow-overlay",
+ size === "panel" ? null : "p-pane",
tone === "danger" ? "border-(--color-danger)" : "border-(--color-border-strong)",
)}
>
-
+
{description}
@@ -135,13 +159,31 @@ export function Dialog({
{/* Escape and a click outside already close it; this is for the pointer user who
- looks for a cross, and for the touch user who has neither. */}
-
- } className="-mr-snug -mt-snug" />
-
+ looks for a cross, and for the touch user who has neither.
+
+ Not in a panel: the content carries its own close control there, and a second one
+ hidden off-screen would still be in the tab order — a button a keyboard user
+ reaches and cannot see, which is the mirror image of the hover-only control this
+ project refuses everywhere. */}
+ {size === "panel" ? null : (
+
+ } className="-mr-snug -mt-snug" />
+
+ )}
- {children === undefined ? null :
{children}
}
+ {children === undefined ? null : (
+
+ {children}
+
+ )}
{actions === undefined ? null : (
{actions}
diff --git a/apps/web/src/ui/Toast.tsx b/apps/web/src/ui/Toast.tsx
index 0023362..572ae7e 100644
--- a/apps/web/src/ui/Toast.tsx
+++ b/apps/web/src/ui/Toast.tsx
@@ -1,48 +1,57 @@
+import * as RadixToast from "@radix-ui/react-toast";
import { createPortal } from "react-dom";
import type { ReactElement } from "react";
+import type { Message } from "../state/report.ts";
import { useReported } from "../state/report.ts";
+import { Button } from "./Button.tsx";
import { cn } from "./cn.ts";
-import { useEntered, useOverlayContainer } from "./Overlays.tsx";
+import { Icon } from "./Icon.tsx";
+import { IconButton } from "./IconButton.tsx";
+import { useOverlayContainer } from "./Overlays.tsx";
/**
- * The confirmation that an action worked.
+ * What just happened, said in one place.
*
- * # This file owns no state and no timer, and that is the contract
+ * # Both halves come here now, and that is the change
*
- * `state/report.ts` holds both. It says so in its own header and the reason is worth repeating
- * here, at the place that would get it wrong: a component running its own `setTimeout` restarts
- * it on every re-render of its parent. In a thread receiving messages that means the timer never
- * expires and a four-second confirmation stays on screen until the conversation goes quiet.
+ * A confirmation always did. An error went to a `Banner` mounted as a flex child of the shell —
+ * full-bleed, corners squared off through a `className`, and **shrinking the conversation to make
+ * room for itself**. Up to four of those could stack. Errors float here instead; the three
+ * remaining banners in `App.tsx` stay where they are because they describe standing conditions
+ * (offline, an inconsistent key log) rather than events, and a standing condition belongs in the
+ * layout.
*
- * So this reads `useReported().toast` and draws it. When the field turns null, the toast is over.
- * Nothing here schedules anything.
+ * # This file still owns no state and no timer
*
- * # `key={toast.id}`
+ * `state/report.ts` holds both, and the reason is worth repeating at the place that would get it
+ * wrong: a component running its own `setTimeout` restarts it on every re-render of its parent. In
+ * a thread receiving messages that means the timer never expires and a four-second confirmation
+ * stays until the conversation goes quiet.
*
- * One toast at a time, and a new one replaces the old. Without the key React sees the same
- * component in the same position and merely swaps the text — the entrance never replays, and two
- * confirmations in a row look like one that changed its mind. The id exists in `report.ts`
- * precisely so that "Copied" following "Copied" is still visibly a second event.
+ * Radix has a `duration` of its own, so it is set to `Infinity` on both roots — not because
+ * nothing should expire, but because **two owners of one expiry is one owner too many**. What
+ * closes a toast is `report.ts` letting go of it.
*
- * # The live region outlives the toast
+ * # Why Radix rather than the portal this file used to be
*
- * `role="status"` with `aria-live="polite"` is on the *container*, which is mounted for as long
- * as the shell is, empty or not. A live region that appears at the same moment as its content is
- * unreliable — several screen readers only announce changes to a region they were already
- * observing, so a region that mounts with its message announces nothing. Mounting it empty and
- * filling it later is what makes the announcement happen.
+ * One reason: a toast can now carry a button, and a button that appears unbidden has to be
+ * reachable by keyboard **without stealing focus**. That is not a `