diff --git a/config.json b/config.json index fea9a59e..f98575d9 100644 --- a/config.json +++ b/config.json @@ -28,11 +28,6 @@ "fileZoneShare": { "enabled": false }, - "push": { - "vapidPublicKey": "", - "gatewayUrl": "", - "appId": "" - }, "registration": { "enabled": false, "url": "" diff --git a/docs/file-zone.md b/docs/file-zone.md index 7990485d..7f380d69 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -114,7 +114,6 @@ Matrix options checked: (a) a thread, chosen: stock, rendered by every client, a Client, `src/app/features/albums`: - `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`). -- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests. - `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only). - Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`. diff --git a/docs/gamecult/chat-shell-compression.md b/docs/gamecult/chat-shell-compression.md deleted file mode 100644 index c8ac66cd..00000000 --- a/docs/gamecult/chat-shell-compression.md +++ /dev/null @@ -1,108 +0,0 @@ -# chat.gamecult.org: shell compression and caching - -Follow-up `ratatoskr:follow_up:chat-shell-compression`. Measured 2026-11-12 against the live -host, which serves release `ratatoskr-nemesis-9b9b495d` (the `index-CAataSJe.js` bundle; the offline table -below also uses the `f8115609` handoff build, `index-D0yL0Dgy.js`, and says which row is which). The host config is -`nginx/chat.gamecult.org.conf` in `gamecult-ops`, owned by Eureka; nothing here is applied -to the host. - -## What the host serves now - -The 2026-10-31 probe (no Content-Encoding, no Cache-Control on `/assets/`) is stale: the -`nginx.conf` has `gzip on`, the site file sets level 5, `gzip_vary` and `gzip_types` for js, css, json, svg and wasm, and carries an immutable -`Cache-Control` on `/assets/` and `/preview/assets/`. Probed with `curl -H 'Accept-Encoding: X'`. - -| Resource | raw | gzip (served now) | brotli (served now) | -|---|---|---|---| -| `/assets/index-CAataSJe.js` | 3,732,771 | 956,085 | none: `br` is ignored, identity returned | -| `/assets/index-BLdKqKqx.css` | 111,267 | 19,952 | none | -| `/assets/matrix_sdk_crypto_wasm_bg-DwIieW7K.wasm` | 5,573,870 | 1,878,147 | none | -| `/` (index.html, no-cache) | 3,066 | gzip | none | - -Headers: hashed `/assets/` answer `Cache-Control: public, max-age=31536000, immutable`; -`/`, `index.html`, `sw.js`, `manifest.json`, `config.json` answer `no-cache`; compressed -responses carry `Vary: Accept-Encoding` and a weak ETag. `config.json` (424 B) is below -`gzip_min_length 1024` and goes uncompressed, which is correct. - -So the original ask is done. What is left is brotli, which nginx 1.26.3 here cannot serve: -`/usr/sbin/nginx -V` shows `--with-http_gzip_static_module` and no brotli module. - -## What brotli buys - -Level-5 gzip is what the host does per request. Offline measurement on the same files -(node `zlib`, 2026-11-12): - -| File | raw | gzip 5 (node) | gzip 9 | brotli 11 | brotli vs live | -|---|---|---|---|---|---| -| live main chunk `index-CAataSJe.js` | 3,732,771 | 957,199 | 945,424 | 725,167 | -24% | -| handoff main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% | -| crypto wasm | 5,573,870 | 1,868,282 | 1,851,108 | 1,343,595 | -28% | -| `ReactPrism-Dm2uRF4v.js` | 577,517 | 209,339 | 208,376 | 163,525 | -22% | - -A cold load of the two big files falls from about 2.8 MB to about 2.1 MB on the wire; the -saving is ~0.7 MB once per hashed build per device while the HTTP cache keeps the file (immutable; the -service worker does no precaching, so eviction or a new device pays again). gzip 9 alone buys 1-2%, so -`gzip_static` without brotli is not worth a build step. Brotli is the whole prize, and it -needs one root step. - -Cost: brotli q11 takes about 12 s for the wasm and 8 s for the main chunk on one core, so -the whole dist is about two minutes at build time (measured, 84 files) and zero CPU per request. Do not use q11 -on the fly; that is why the plan is precompressed twins. - -## Root list for Eureka (host) - -1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` (trixie/main, 1.0.0~rc-6, - depend on `nginx-abi-1.26.3-1`, which the installed nginx provides; apt may offer an nginx patch upgrade - alongside, say so before accepting). Do not enable the config yet. -2. Install a build with twins (`PRECOMPRESS=1`, below) the usual way - (`docs/gamecult/client-build-install.md`). Twins for files that are not there change nothing. -3. In the `chat.gamecult.org` `server` block, beside the existing gzip lines: - -```nginx - brotli_static on; # serve .br when the client sends br - gzip_static on; # serve .gz; falls back to the on-the-fly gzip above - brotli on; # on-the-fly br for files with no twin (index.html, small files) - brotli_comp_level 5; - brotli_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml application/wasm; -``` - - The `location ^~ /assets/` block and `/preview/`, `/element/` and `/mockups/` inherit these; they only add headers. - No other change: the `Cache-Control` and `Vary` lines stay as they are, and - `gzip_vary on` already makes the on-the-fly and static paths vary correctly. -4. `nginx -t && systemctl reload nginx`. Twins first, config second: with `brotli on` and no twins every - client gets on-the-fly q5 brotli of the 3.7 and 5.6 MB files, which costs CPU per request. - -Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on disk, unused. - -## Build side - -`build-prod-dist.sh` (Void's, in `/opt/commons/handoff/ratatoskr-main-build/`) gains -`PRECOMPRESS=1`, which runs `precompress.cjs ` before the manifest is written. It -writes `.br` (q11) and `.gz` (level 9, no mtime, deterministic for one node) next -to each js, css, wasm, json, svg, html file over 1 KiB, only when smaller than the original. -Default is off, so builds installed before the host change do not ship ~27 MB of twins that -nothing serves. The twins appear in `SHA256SUMS`; the determinism compare still applies. -Tested on `ReactPrism-Dm2uRF4v.js` (gzip roundtrip identical to the original); a full -`PRECOMPRESS=1` build has not been run because it waits on step 1. - -The script is not in this repo; its placement is a standing open item of the build handoff. - -## Before/after measurement - -Run from any machine; `X` is the asset URL. Before = today's table above. - -```sh -for ae in identity gzip br; do - printf '%s ' "$ae"; curl -s -o /dev/null -H "Accept-Encoding: $ae" -w '%{size_download} %header{content-encoding}\n' "$X" -done -curl -sI -H 'Accept-Encoding: br' "$X" | grep -iE 'content-encoding|vary|etag|cache-control' -``` - -Pass: with `br` the main chunk is about 774 KB and the wasm about 1.34 MB with -`Content-Encoding: br`; with `gzip` the bytes are no larger than today; with `identity` -the file is the raw size; `Vary: Accept-Encoding` and the immutable `Cache-Control` are -still present on all three; `index.html`, `sw.js` and `config.json` still `no-cache`. -The service worker's registration and an existing session must still work (open chat on -the operator's phone, hard reload once, check the network panel's "transferred" column). -Check `/preview/assets/` the same way, but `/preview/` is served from `/srv/cinny/preview`, which this build -script does not produce: it gets on-the-fly brotli, not twins, until a preview build carries them. diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md deleted file mode 100644 index b2505fe1..00000000 --- a/docs/gamecult/client-build-install.md +++ /dev/null @@ -1,121 +0,0 @@ -# Ratatoskr client: build, install, verify, roll back - -Who does what. The steward (Void) builds and hands off; Void holds no root and does not deploy. -Eureka installs on the chat.gamecult.org host, which is Eureka's. This page is the shared -procedure. Nothing here is run by the build step against the live host. - -## Facts about the host - -- Web root: `/srv/cinny/`. `current` is a symlink to one directory under `releases/`; the web - server serves `current`. Releases are `releases/ratatoskr-<8hex>` (older ones are - `ratatoskr-nemesis-<8hex>`). Previous releases stay on disk; that is the rollback. -- The client is static. `config.json` sits in the release root beside `index.html` and is read - at load. Cinny's `Dockerfile` and `docker-nginx.conf` are upstream's container path, not the - chat.gamecult.org path. -- Check the pointer before anything else: `readlink /srv/cinny/current`. - -## 1. Build (steward) - -Two builds exist, and they treat `config.json` differently. - -**By hand (the repo's config).** `vite build` copies the repo's `config.json` into `dist/` -(`vite.config.js`, static copy of `config.json`). Ratatoskr #103 made the repo file the intended -served config, and #106 set `backgroundScene.style` to `ridge`; the served file has not caught up -(see Open). Do not hand-edit `dist/config.json`. - -```sh -git clone https://eureka.gamecult.org/forge/GameCult/Ratatoskr.git && cd Ratatoskr -git checkout # build a named commit, not a branch tip -npm ci --ignore-scripts # .npmrc sets legacy-peer-deps; skips husky's prepare hook -node scripts/sync-poses.mjs # pose art is not committed; this also rewrites the tracked - # src/app/components/message/pose/catalogue.json -NODE_OPTIONS=--max_old_space_size=6144 npm run build # writes dist/ -``` - -Skip `sync-poses` and the build ships with no poses. Do not use `npm run build:preview` for -production: it rewrites `dist/config.json` for preview (hash router, chosen tier and style). - -**The handoff build (what the host installs).** -`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh ` (see its `README.md`) -writes `/opt/commons/handoff/ratatoskr-main-/{dist,SHA256SUMS,BUILD-INFO,build.log}`, -locks the files read-only and refuses to overwrite. The dist carries the repo's `config.json` at -that sha (BUILD-INFO `config_json_source`, `config_json_sha256`); there is no separate pinned copy. -Its live check always runs: it fetches the served config and compares it byte for byte with the -repo's. A difference prints the diff and stops the build. `ALLOW_CONFIG_DRIFT=1` builds anyway and -BUILD-INFO records `config_json_live_check: DIFFERS ...` and `config_json_live_sha256`, so the -handoff states which config differs and the installer decides. From Yggdrasil the public name does -not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. -The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and -the node and npm versions, is what the install request names. -Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, -with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff -build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. -To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. - -Before handing off: - -```sh -cd /dist && sha256sum -c ../SHA256SUMS # 0 failures -grep -rl 'demo.invalid\|demo-preview-token' /dist # must print nothing -# from Yggdrasil the public name may not resolve; --resolve points it at the local server -diff <(curl -fsS --resolve chat.gamecult.org:443:127.0.0.1 https://chat.gamecult.org/config.json) /dist/config.json -``` - -A config difference is a finding to report, not something to paper over: either live drifted or -the repo is wrong, and a ruling (Meta, for `backgroundScene.style`) says which ships. - -## 2. Install (host owner, root, by hand) - -Note the rollback target first: `readlink /srv/cinny/current`. - -Unpack beside the live release; do not touch the live directory. The target must not exist, or -`cp -r` nests `dist` inside it. - -```sh -R=ratatoskr-<8hex> -H=/opt/commons/handoff/ratatoskr-main- -test ! -e /srv/cinny/releases/$R -cp -r $H/dist /srv/cinny/releases/$R -cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json -(cd /srv/cinny/releases/$R && sha256sum -c $H/SHA256SUMS) -``` - -`cmp` is a drift check, not a pass check. Live has no trailing newline; the repo's file has one, -so even equal content differs. Read the difference (`diff` the two) and settle which config -ships. To keep the host's config byte-for-byte, as past installs did, -`cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json`; `sha256sum -c` then flags -`config.json` only (read that one line; `--ignore-missing` does not hide a mismatch). - -Switch (a single symlink swap; `ln -sfn` unlinks then links, so there is a moment with no link): - -```sh -ln -sfn releases/$R /srv/cinny/current -``` - -## 3. Verify - -- `readlink /srv/cinny/current` names `releases/$R`. -- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals `sha256sum /srv/cinny/releases/$R/config.json`. -- Load https://chat.gamecult.org/ and hard-refresh (the service worker, `sw.js`, can hold the - previous assets). Sign-in card shows, the background scene draws, the browser console has no - page errors, a poses/asset request returns 200 and not index.html. - -## 4. Roll back - -Point `current` at the release noted before the switch. Live at the time of writing is -`ratatoskr-nemesis-9b9b495d`: - -```sh -ln -sfn releases/ratatoskr-nemesis-9b9b495d /srv/cinny/current -``` - -Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for diagnosis. - -## Open - -- The served config (checked 2026-11-12) is not the repo's: it says `backgroundScene.style` - `terrain` where the repo says `ridge` (#106, Meta 2026-11-11), lacks the repo's `dings`, - `fileZoneShare` and `registration` blocks (all off in the repo), and has no trailing newline. - The new keys default off or inert when absent, so only the style is a decision. Meta says which - style ships. Until the served file is replaced, every handoff build stops at the live check - unless `ALLOW_CONFIG_DRIFT=1` is set (section 1). diff --git a/public/manifest.json b/public/manifest.json index c28e7b41..ef0a3510 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,13 +1,12 @@ { "name": "Ratatoskr", "short_name": "Ratatoskr", - "id": "/", "description": "GameCult's Matrix client", "dir": "auto", "lang": "en-US", "display": "standalone", + "orientation": "portrait", "start_url": "./", - "scope": "./", "background_color": "#07111a", "theme_color": "#07111a", "icons": [ @@ -55,12 +54,6 @@ "src": "./public/android/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" - }, - { - "src": "./public/android/android-chrome-maskable-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" } ] } diff --git a/public/res/android/android-chrome-maskable-512x512.png b/public/res/android/android-chrome-maskable-512x512.png deleted file mode 100644 index 211a0650..00000000 Binary files a/public/res/android/android-chrome-maskable-512x512.png and /dev/null differ diff --git a/scripts/buildSha.js b/scripts/buildSha.js deleted file mode 100644 index 61a89445..00000000 --- a/scripts/buildSha.js +++ /dev/null @@ -1,56 +0,0 @@ -// The commit a bundle was built from, stamped into its index.html as , so a built -// folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. -// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has -// uncommitted changes, or `-mismatch` when an explicit BUILD_SHA names a different commit, so a stamp -// never vouches for code it does not hold; a git failure there is 'unknown'. Where git does not govern (a source -// archive), a full-sha BUILD_SHA stands, else 'unknown'. -import { execFileSync } from 'child_process'; -import { dirname, resolve } from 'path'; -import { fileURLToPath } from 'url'; - -const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); - -// Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the -// build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name, -// or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only. -const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[a-z]+\.timestamp-\d+-[0-9a-f]+\.mjs$/; - -export const gitIn = - (cwd) => - (...args) => - execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); - -const FULL_SHA = /^[0-9a-f]{40}$/; - -export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => { - const fromEnv = (env.BUILD_SHA ?? '').trim(); - const explicit = FULL_SHA.test(fromEnv) ? fromEnv : null; - // Git governs this tree only when REPO_DIR is its root: a source archive unpacked inside some other - // repository has no git of its own, and that repository's HEAD says nothing about it. - let governed; - try { - governed = run('rev-parse', '--show-prefix').trim() === ''; - } catch { - governed = false; - } - if (!governed) return explicit ?? 'unknown'; - // Git governs and answers: from here a failure is 'unknown', never the bare explicit sha. - try { - const head = run('rev-parse', 'HEAD').trim(); - if (!FULL_SHA.test(head)) return 'unknown'; - if (explicit && explicit !== head) return `${explicit}-mismatch`; - const dirty = run('status', '--porcelain', '-uall', '--', '.') - .split('\n') - .some((l) => l.trim() !== '' && !VITE_TEMP.test(l)); - return dirty ? `${head}-dirty` : head; - } catch { - return 'unknown'; - } -}; - -export const buildShaPlugin = (sha = readBuildSha()) => ({ - name: 'ratatoskr-build-sha', - transformIndexHtml: () => [ - { tag: 'meta', attrs: { name: 'build-sha', content: sha }, injectTo: 'head-prepend' }, - ], -}); diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js deleted file mode 100644 index 4a270867..00000000 --- a/scripts/buildSha.test.js +++ /dev/null @@ -1,170 +0,0 @@ -import { execFileSync } from 'child_process'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; - -const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; -const gitAt = - (head, status = '') => - (cmd, ...args) => - cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status; - -describe('readBuildSha', () => { - it('reads the checkout it is built from', () => { - expect(readBuildSha({}, gitAt(SHA))).toBe(SHA); - }); - it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => { - expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`); - }); - it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => { - const noGit = () => { - throw new Error('not a git repository'); - }; - expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); - expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA); - // a different HEAD is a mismatch, not a clean stamp of the explicit sha - expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`); - // a value that is not a lowercase full sha is ignored - ['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) => - expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown') - ); - // git that answers but cannot say what the tree holds fails closed - const brokenStatus = (cmd, ...args) => { - if (cmd === 'status') throw new Error('index file smaller than expected'); - return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`; - }; - expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown'); - expect(readBuildSha({}, brokenStatus)).toBe('unknown'); - expect(readBuildSha({}, gitAt('nope'))).toBe('unknown'); - // inside another repository's subfolder git does not govern: the explicit sha stands - const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA); - expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA); - expect(readBuildSha({}, nested)).toBe('unknown'); - expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown'); - }); - it("says 'unknown' rather than failing the build when git is absent", () => { - expect( - readBuildSha({}, () => { - throw new Error('spawn git ENOENT'); - }) - ).toBe('unknown'); - }); -}); - -describe('readBuildSha against a real repository', () => { - const made = []; - afterEach(() => { - made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })); - }); - const repo = () => { - const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); - made.push(dir); - const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); - g('init', '-q'); - mkdirSync(join(dir, 'src')); - writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n'); - g('add', '.'); - g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'); - return dir; - }; - it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => { - const dir = repo(); - const run = gitIn(dir); - writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), ''); - expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/); - writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); - expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/); - }); -}); - -describe('readBuildSha dirty rules against a real repository', () => { - const made2 = []; - afterEach(() => { - made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })); - }); - const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs'; - const repo2 = (tracked = []) => { - const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); - made2.push(dir); - const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); - g('init', '-q'); - mkdirSync(join(dir, 'src')); - writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n'); - tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n')); - g('add', '.'); - g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'); - return { - dir, - head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(), - }; - }; - it('does not let an explicit BUILD_SHA mask a dirty tree', () => { - const { dir, head } = repo2(); - const run = gitIn(dir); - expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head); - writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); - expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`); - }); - it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => { - const { dir } = repo2(); - const inner = join(dir, 'vendor', 'ratatoskr'); - mkdirSync(inner, { recursive: true }); - writeFileSync(join(inner, 'x.ts'), ''); - expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA); - expect(readBuildSha({}, gitIn(inner))).toBe('unknown'); - }); - it("notices an untracked .mjs that is not vite's temp file", () => { - const { dir, head } = repo2(); - writeFileSync(join(dir, 'foo.mjs'), ''); - expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`); - rmSync(join(dir, 'foo.mjs')); - // only vite's exact shape is its own: vite.config..timestamp--.mjs - [ - 'vite.config.js.timestamp-1-ab.mjs.bak', - 'vite.config.js.timestamp-x-ab.mjs', - 'vite.config.js.timestamp-1-zz.mjs', - 'vite.config.a.b.timestamp-1-ab.mjs', - 'vite.config.js.timestamp-1-ab-c.mjs', - ].forEach((name) => { - writeFileSync(join(dir, name), ''); - expect(readBuildSha({}, gitIn(dir)), name).toBe(`${head}-dirty`); - rmSync(join(dir, name)); - }); - expect(readBuildSha({}, gitIn(dir))).toBe(head); - }); - it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => { - const { dir, head } = repo2(); - const run = gitIn(dir); - writeFileSync(join(dir, TEMP), ''); - writeFileSync(join(dir, 'src', TEMP), ''); - expect(readBuildSha({}, run)).toBe(head); - writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); - expect(readBuildSha({}, run)).toBe(`${head}-dirty`); - }); - it('marks a tracked file of the temp name dirty once it changes', () => { - const { dir, head } = repo2([TEMP]); - const run = gitIn(dir); - expect(readBuildSha({}, run)).toBe(head); - writeFileSync(join(dir, TEMP), 'changed\n'); - expect(readBuildSha({}, run)).toBe(`${head}-dirty`); - }); - it('marks an untracked file in a new folder dirty (status lists files, not the folder)', () => { - const { dir, head } = repo2(); - mkdirSync(join(dir, 'new')); - writeFileSync(join(dir, 'new', TEMP), ''); - expect(readBuildSha({}, gitIn(dir))).toBe(head); - writeFileSync(join(dir, 'new', 'b.ts'), ''); - expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`); - }); -}); - -describe('buildShaPlugin', () => { - it("hands vite a meta tag for the page's head carrying the sha", () => { - const plugin = buildShaPlugin(SHA); - expect(plugin.transformIndexHtml()).toEqual([ - { tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' }, - ]); - }); -}); diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 8c28b935..2dbaf1f1 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -16,7 +16,6 @@ import { } from 'folds'; import { MatrixError } from 'matrix-js-sdk'; import { Cursor } from '../plugins/text-area'; -import { PhoneHit } from '../styles/PhoneHit.css'; import { syntaxErrorPosition } from '../utils/dom'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { Page, PageHeader } from './page'; @@ -295,7 +294,7 @@ export function AccountDataEditor({ - + diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx index 57a18674..068f437b 100644 --- a/src/app/components/BackupRestore.tsx +++ b/src/app/components/BackupRestore.tsx @@ -30,7 +30,6 @@ import { } from '../hooks/useKeyBackup'; import { stopPropagation } from '../utils/keyboard'; import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification'; -import { PhoneHit } from '../styles/PhoneHit.css'; type BackupStatusProps = { enabled: boolean; @@ -178,7 +177,6 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { )} Device Verification - + diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 68f42221..433fa6a1 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -25,7 +25,6 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { useAlive } from '../hooks/useAlive'; import { UseStateProvider } from './UseStateProvider'; -import { PhoneHit } from '../styles/PhoneHit.css'; type UIACallback = ( authDict: AuthDict | null @@ -302,7 +301,7 @@ export const DeviceVerificationSetup = forwardRef Setup Device Verification - + @@ -337,7 +336,7 @@ export const DeviceVerificationReset = forwardRef Reset Device Verification - + diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index d0e5f715..9c7fd980 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -27,7 +27,6 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { useZoom } from '../../hooks/useZoom'; import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist'; import { stopPropagation } from '../../utils/keyboard'; -import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css'; export type PdfViewerProps = { name: string; @@ -109,7 +108,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
- + @@ -118,7 +117,6 @@ export const PdfViewer = as<'div', PdfViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/UIAFlowOverlay.tsx b/src/app/components/UIAFlowOverlay.tsx index 49e1d821..a2bdd1b8 100644 --- a/src/app/components/UIAFlowOverlay.tsx +++ b/src/app/components/UIAFlowOverlay.tsx @@ -13,7 +13,6 @@ import { IconButton, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { PhoneHit } from '../styles/PhoneHit.css'; export type UIAFlowOverlayProps = { currentStep: number; @@ -54,7 +53,6 @@ export function UIAFlowOverlay({ > {(anchorRef) => ( + setOpen(!open)}> + + + }> + + setOpen(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + + + } + after={ + <> + setToolbar(!toolbar)} + aria-pressed={toolbar} + > + + + + + + + + + + } + bottom={ + toolbar && ( +
+ + +
+ ) + } + /> +
+
+
+
+
+ + ); +} diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx index 3721b909..7d701c42 100644 --- a/src/app/components/editor/Toolbar.tsx +++ b/src/app/components/editor/Toolbar.tsx @@ -36,7 +36,6 @@ import { KeySymbol } from '../../utils/key-symbol'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { stopPropagation } from '../../utils/keyboard'; -import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css'; function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { return ( @@ -73,7 +72,6 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) { {(triggerRef) => ( {(triggerRef) => ( {(triggerRef) => ( handleMenuSelect(1)} size="400" @@ -182,7 +178,6 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(2)} size="400" @@ -198,7 +193,6 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(3)} size="400" @@ -214,7 +208,6 @@ export function HeadingBlockButton() { } > {(triggerRef) => ( {(triggerRef) => ( setIsMarkdown(!isMarkdown)} diff --git a/src/app/components/emoji-board/components/Sidebar.tsx b/src/app/components/emoji-board/components/Sidebar.tsx index d2d5f197..de22b483 100644 --- a/src/app/components/emoji-board/components/Sidebar.tsx +++ b/src/app/components/emoji-board/components/Sidebar.tsx @@ -14,7 +14,6 @@ import { } from 'folds'; import classNames from 'classnames'; import * as css from './styles.css'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; export function Sidebar({ children }: { children: ReactNode }) { return ( @@ -69,7 +68,6 @@ function SidebarBtn({ > {(ref) => ( ( Seen by - +
diff --git a/src/app/components/image-editor/ImageEditor.tsx b/src/app/components/image-editor/ImageEditor.tsx index 0f072bb0..1ec474e8 100644 --- a/src/app/components/image-editor/ImageEditor.tsx +++ b/src/app/components/image-editor/ImageEditor.tsx @@ -2,7 +2,6 @@ import React from 'react'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; import * as css from './ImageEditor.css'; -import { PhoneHit } from '../../styles/PhoneHit.css'; export type ImageEditorProps = { name: string; @@ -25,7 +24,7 @@ export const ImageEditor = as<'div', ImageEditorProps>( >
- + diff --git a/src/app/components/image-pack-view/ImagePackView.tsx b/src/app/components/image-pack-view/ImagePackView.tsx index 15b1cd08..ab81d503 100644 --- a/src/app/components/image-pack-view/ImagePackView.tsx +++ b/src/app/components/image-pack-view/ImagePackView.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds'; import { PackAddress } from '../../plugins/custom-emoji'; -import { PhoneHit } from '../../styles/PhoneHit.css'; import { Page, PageHeader, PageContent } from '../page'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { RoomImagePack } from './RoomImagePack'; @@ -30,7 +29,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) { - + diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 4d2d7694..4956a7b6 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -7,7 +7,6 @@ import * as css from './ImageViewer.css'; import { useZoom } from '../../hooks/useZoom'; import { usePan } from '../../hooks/usePan'; import { downloadMedia } from '../../utils/matrix'; -import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css'; export type ImageViewerProps = { alt: string; @@ -34,7 +33,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( >
- + @@ -43,7 +42,6 @@ export const ImageViewer = as<'div', ImageViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx index a503fe13..ddac0576 100644 --- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -42,7 +42,6 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { BreakWord } from '../../styles/Text.css'; import { useAlive } from '../../hooks/useAlive'; -import { PhoneHit } from '../../styles/PhoneHit.css'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -174,7 +173,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { - + diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx index ffd5241c..50a89418 100644 --- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx +++ b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx @@ -20,7 +20,6 @@ import { stopPropagation } from '../../utils/keyboard'; import { isRoomAlias, isRoomId } from '../../utils/matrix'; import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to'; import { tryDecodeURIComponent } from '../../utils/dom'; -import { PhoneHit } from '../../styles/PhoneHit.css'; type JoinAddressProps = { onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void; @@ -83,7 +82,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) { Join with Address - +
diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index 832dd59d..217491e6 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -20,7 +20,6 @@ import { MatrixError } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; -import { PhoneHit } from '../../styles/PhoneHit.css'; type LeaveRoomPromptProps = { roomId: string; @@ -69,7 +68,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro Leave Room - +
diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx index 6c75ef99..8709b942 100644 --- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx +++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx @@ -20,7 +20,6 @@ import { MatrixError } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; -import { PhoneHit } from '../../styles/PhoneHit.css'; type LeaveSpacePromptProps = { roomId: string; @@ -69,7 +68,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP Leave Space - + diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 41cef5f4..2ffc9ec4 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -2,7 +2,6 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f import React, { ReactNode, useCallback } from 'react'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import FileSaver from 'file-saver'; -import { PhoneHit } from '../../styles/PhoneHit.css'; import { mimeTypeToExt } from '../../utils/mimeTypes'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -49,7 +48,6 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow variant={hasError ? 'Critical' : 'SurfaceVariant'} size="300" radii="300" - className={PhoneHit} > {downloading ? ( diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index 08003f0e..f020080e 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -1,9 +1,6 @@ import { createVar, style } from '@vanilla-extract/css'; import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; -import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; -import { PHONE_TAP } from '../../styles/PhoneHit.css'; -const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; const Container = createVar(); const ContainerHover = createVar(); const ContainerActive = createVar(); @@ -28,14 +25,6 @@ export const Reaction = style([ selectors: { 'button&': { cursor: 'pointer', - // Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the - // same 9 px of margin opens the row, so the chips of one row and of the next never share a tap. - '@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } }, - }, - 'button&::after': { - '@media': { - [PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` }, - }, }, '&[aria-pressed=true]': { vars: { diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 94132cea..478486a4 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -3,7 +3,6 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { Range } from 'react-range'; -import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { IAudioInfo } from '../../../../types/matrix/common'; @@ -160,7 +159,6 @@ export function AudioContent({ variant="SurfaceVariant" size="300" radii="Pill" - className={PhoneHitSpread24} onClick={() => setMute(!mute)} aria-pressed={mute} > diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 39a40aa8..3d1a9c83 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -299,10 +299,7 @@ export const BubbleLeftArrow = style({ }); // Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the -// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the -// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so -// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it. -// No background, border or outline moves. +// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves. const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; export const Username = style({ overflow: 'hidden', @@ -312,7 +309,7 @@ export const Username = style({ 'button&': { cursor: 'pointer', '@media': { - [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' }, + [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 }, }, }, 'button&:hover, button&:focus-visible': { diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 5156ed41..5c3f37b0 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -53,10 +53,6 @@ export const NavCategoryHeader = style({ '&&': { paddingLeft: 0 }, }, '@media': { - // A phone's section header is as tall as its 44 px button, so it does not overlap the next row. - 'screen and (max-width: 750px)': { - selectors: { '&&': { minHeight: toRem(44), height: toRem(44) } }, - }, // Category labels sit on the scene like the rows do, and need a wash to be read (wide screens; // a phone's list is plain text). 'screen and (min-width: 751px)': { @@ -121,8 +117,6 @@ const NavItemBase = style({ [PHONE]: { backgroundColor: 'transparent', borderRadius: 0, - // The row is as tall as the 44 px tap target inside it (EstateNavigator.css.ts), so neighbours do not overlap. - minHeight: toRem(44), }, [WIDE]: { selectors: { diff --git a/src/app/components/password-input/PasswordInput.tsx b/src/app/components/password-input/PasswordInput.tsx index 6aa0d4f3..4ffe0d83 100644 --- a/src/app/components/password-input/PasswordInput.tsx +++ b/src/app/components/password-input/PasswordInput.tsx @@ -1,7 +1,6 @@ import React, { ComponentProps, forwardRef } from 'react'; import { Icon, IconButton, Input, config, Icons } from 'folds'; import { UseStateProvider } from '../UseStateProvider'; -import { PhoneHit } from '../../styles/PhoneHit.css'; type PasswordInputProps = Omit, 'type' | 'size'> & { size: '400' | '500'; @@ -24,7 +23,6 @@ export const PasswordInput = forwardRef( <> {after} setVisible(!visible)} type="button" variant={visible ? 'Warning' : variant} diff --git a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx index 0d091e7e..52128772 100644 --- a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx +++ b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx @@ -4,7 +4,6 @@ import classNames from 'classnames'; import Linkify from 'linkify-react'; import * as css from './style.css'; import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; -import { PhoneHit } from '../../styles/PhoneHit.css'; export const RoomTopicViewer = as< 'div', @@ -27,7 +26,7 @@ export const RoomTopicViewer = as< {name}
- + diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index e9e150b5..ec4ed0a5 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -5,7 +5,6 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo import { ErrorBoundary } from 'react-error-boundary'; import * as css from './TextViewer.css'; import { copyToClipboard } from '../../utils/dom'; -import { PhoneHit } from '../../styles/PhoneHit.css'; const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism')); @@ -54,7 +53,7 @@ export const TextViewer = as<'div', TextViewerProps>( >
- + diff --git a/src/app/components/uia-stages/PasswordStage.tsx b/src/app/components/uia-stages/PasswordStage.tsx index a7ca2d06..0164cae6 100644 --- a/src/app/components/uia-stages/PasswordStage.tsx +++ b/src/app/components/uia-stages/PasswordStage.tsx @@ -4,7 +4,6 @@ import { AuthType } from 'matrix-js-sdk'; import { StageComponentProps } from './types'; import { ErrorCode } from '../../cs-errorcode'; import { PasswordInput } from '../password-input'; -import { PhoneHit } from '../../styles/PhoneHit.css'; export function PasswordStage({ stageData, @@ -46,7 +45,7 @@ export function PasswordStage({ Account Password - +
diff --git a/src/app/components/uia-stages/SSOStage.tsx b/src/app/components/uia-stages/SSOStage.tsx index 20decb6c..e3d6126a 100644 --- a/src/app/components/uia-stages/SSOStage.tsx +++ b/src/app/components/uia-stages/SSOStage.tsx @@ -1,7 +1,6 @@ import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds'; import React, { useCallback, useEffect, useState } from 'react'; import { StageComponentProps } from './types'; -import { PhoneHit } from '../../styles/PhoneHit.css'; export function SSOStage({ ssoRedirectURL, @@ -57,7 +56,7 @@ export function SSOStage({ SSO Login - + diff --git a/src/app/components/upload-card/CompactUploadCardRenderer.tsx b/src/app/components/upload-card/CompactUploadCardRenderer.tsx index d10fbaa6..b9bada71 100644 --- a/src/app/components/upload-card/CompactUploadCardRenderer.tsx +++ b/src/app/components/upload-card/CompactUploadCardRenderer.tsx @@ -6,7 +6,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { TUploadContent } from '../../utils/matrix'; import { bytesToSize, getFileTypeIcon } from '../../utils/common'; import { useMediaConfig } from '../../hooks/useMediaConfig'; -import { PhoneHitSpread28 } from '../../styles/PhoneHit.css'; type CompactUploadCardRendererProps = { isEncrypted?: boolean; @@ -64,7 +63,6 @@ export function CompactUploadCardRenderer({ )} )} (({ children, ...props }, ref) => { <>
(({ children, ...props }, ref) => { <>
Self Demotion - + @@ -121,7 +120,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) Shared Power - + diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index a57a6b10..d100096b 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -54,7 +54,6 @@ import { StateEvent } from '../../../types/matrix/room'; import { getViaServers } from '../../plugins/via-servers'; import { rateLimitedActions } from '../../utils/matrix'; import { useAlive } from '../../hooks/useAlive'; -import { PhoneHit } from '../../styles/PhoneHit.css'; const SEARCH_OPTS: UseAsyncSearchOptions = { limit: 500, @@ -202,7 +201,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM Add Existing - + diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx deleted file mode 100644 index bfa9a619..00000000 --- a/src/app/features/albums/ShareSet.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; -import { ClientConfigProvider } from '../../hooks/useClientConfig'; -import type { ClientConfig } from '../../hooks/useClientConfig'; -import { ShareState } from './shareController'; -import { ShareSetView, shareEnabled, useShareEnabled } from './ShareSet'; - -const text = (html: string) => - html - .replace(/<[^>]*>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -const noop = () => undefined; -const file = (name: string) => ({ name, type: 'audio/ogg' } as File); - -const view = (over: Partial> = {}) => - renderToStaticMarkup( - - ); - -describe('the share panel, drawn', () => { - it('idle: one "Share files" row and no title, names, Send or Cancel', () => { - const html = view(); - expect(text(html)).toBe('Share files ›'); - expect(html).not.toContain('aria-label="Album title"'); - expect(html).toContain('type="file"'); - expect(html).toContain('multiple'); - expect(html).toContain('hidden'); - }); - - it('picked: the title field, the names on one line, Send N files and Cancel; no pick row', () => { - const html = view({ files: [file('a.ogg'), file('b.ogg')] }); - expect(html).toContain('aria-label="Album title"'); - expect(html).toContain('placeholder="Shared files"'); - expect(text(html)).toBe('a.ogg · b.ogg Send 2 files Cancel'); - expect(text(html)).not.toContain('Share files'); - }); - - it('one picked file reads "Send 1 file"', () => { - expect(text(view({ files: [file('a.ogg')] }))).toContain('Send 1 file Cancel'); - }); - - it('sending: progress replaces Send and Cancel, and the title is locked', () => { - const html = view({ - files: [file('a'), file('b'), file('c')], - phase: { kind: 'sending', done: 2 }, - }); - expect(text(html)).toContain('Sending 2 of 3…'); - expect(text(html)).not.toContain('Send 3 files'); - expect(text(html)).not.toContain('Cancel'); - expect(html).toMatch( - /aria-label="Album title"[^>]*disabled|disabled[^>]*aria-label="Album title"/ - ); - }); - - it('the encrypted note shows above the kept pick', () => { - const html = view({ - files: [file('a.ogg')], - phase: { kind: 'done', text: 'Not shared: this room is encrypted.' }, - }); - expect(text(html)).toBe('Not shared: this room is encrypted. a.ogg Send 1 file Cancel'); - }); - - it('after a full success the idle row and the note show together', () => { - expect(text(view({ phase: { kind: 'done', text: 'Shared 2 files.' } }))).toBe( - 'Share files › Shared 2 files.' - ); - }); -}); - -describe('the flag', () => { - it('off, missing or empty offers no share', () => { - expect(shareEnabled({}, false)).toBe(false); - expect(shareEnabled({ fileZoneShare: {} }, false)).toBe(false); - expect(shareEnabled({ fileZoneShare: { enabled: false } }, false)).toBe(false); - }); - - it('on offers it, except in an encrypted room', () => { - expect(shareEnabled({ fileZoneShare: { enabled: true } }, false)).toBe(true); - expect(shareEnabled({ fileZoneShare: { enabled: true } }, true)).toBe(false); - }); - - it('the hook reads the client config and the room, as AlbumView mounts it', () => { - const room = (encrypted: boolean) => ({ hasEncryptionStateEvent: () => encrypted } as never); - function Probe({ encrypted }: { encrypted: boolean }) { - return {String(useShareEnabled(room(encrypted)))}; - } - const probe = (config: ClientConfig, encrypted: boolean) => - text( - renderToStaticMarkup( - - - - ) - ); - expect(probe({}, false)).toBe('false'); - expect(probe({ fileZoneShare: { enabled: true } }, false)).toBe('true'); - expect(probe({ fileZoneShare: { enabled: true } }, true)).toBe('false'); - }); -}); diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index 7b507d92..1d9c4302 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useSyncExternalStore } from 'react'; +import React, { useRef, useState } from 'react'; import { Text } from 'folds'; import { useQueryClient } from '@tanstack/react-query'; import { Room } from 'matrix-js-sdk'; @@ -8,55 +8,104 @@ import { uploadContent } from '../../utils/matrix'; import { safeFile } from '../../utils/mimeTypes'; import { getMsgContent } from '../room/msgContent'; import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; -import { ShareController, ShareState, createShareController } from './shareController'; -import { DEFAULT_TITLE, sendShareSet } from './shareSet'; +import { DEFAULT_TITLE, remainingAfter, resultNote, sendShareSet } from './shareSet'; import * as css from './AlbumView.css'; /** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ -export const shareEnabled = ( - config: { fileZoneShare?: { enabled?: boolean } }, - encrypted: boolean -) => !!config.fileZoneShare?.enabled && !encrypted; - export const useShareEnabled = (room: Room): boolean => - shareEnabled(useClientConfig(), room.hasEncryptionStateEvent()); + !!useClientConfig().fileZoneShare?.enabled && !room.hasEncryptionStateEvent(); -type ShareViewProps = { - state: ShareState; - onPickClick: () => void; - onPicked: (files: File[]) => void; - onTitle: (title: string) => void; - onSend: () => void; - onCancel: () => void; - inputRef?: React.Ref; -}; +/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */ +const sending = new Set(); + +type Phase = { kind: 'idle' } | { kind: 'sending'; done: number } | { kind: 'done'; text: string }; /** * One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked - * names, Send. Nothing but the row is visible until files are picked. + * names, Send. Sending makes one album of them (shareSet.ts). Nothing here is visible until used. */ -export function ShareSetView({ - state, - onPickClick, - onPicked, - onTitle, - onSend, - onCancel, - inputRef, -}: ShareViewProps) { - const { files, title, phase, epoch } = state; +export function ShareSet({ room }: { room: Room }) { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const input = useRef(null); + const [files, setFiles] = useState([]); + const [title, setTitle] = useState(''); + const [phase, setPhase] = useState({ kind: 'idle' }); + + const reset = () => { + setFiles([]); + setTitle(''); + if (input.current) input.current.value = ''; + }; + + const send = async () => { + // One send at a time, and never into a room that has turned encrypted since the panel mounted. + if (sending.has(room.roomId)) return; + if (room.hasEncryptionStateEvent()) { + setPhase({ kind: 'done', text: 'Not shared: this room is encrypted.' }); + return; + } + sending.add(room.roomId); + const items = files.map((file) => ({ name: file.name, mimeType: file.type, file })); + setPhase({ kind: 'sending', done: 0 }); + let settled = 0; + try { + const res = await sendShareSet( + { + send: async (content) => (await mx.sendMessage(room.roomId, content as any)).event_id, + contentOf: async (f) => { + const file = safeFile(f.file); + const mxc = await new Promise((resolve, reject) => { + uploadContent(mx, file, { onSuccess: resolve, onError: reject }); + }); + const content = await getMsgContent( + mx, + { + file, + originalFile: f.file, + metadata: { markedAsSpoiler: false }, + encInfo: undefined, + }, + mxc + ); + const sha256 = await hashForSend(f.file); + if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; + return content; + }, + onFile: () => { + settled += 1; + setPhase({ kind: 'sending', done: settled }); + }, + }, + title, + items + ); + queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }); + const left = remainingAfter(items, res).map((i) => i.file); + setFiles(left); + if (left.length === 0) reset(); + setPhase({ kind: 'done', text: resultNote(res) }); + } catch (e: any) { + setPhase({ kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` }); + } finally { + sending.delete(room.roomId); + } + }; + return (
onPicked(Array.from(e.target.files ?? []))} + onChange={(e) => { + setFiles(Array.from(e.target.files ?? [])); + setPhase({ kind: 'idle' }); + }} /> {files.length === 0 && phase.kind !== 'sending' && ( - )} @@ -73,7 +122,7 @@ export function ShareSetView({ placeholder={DEFAULT_TITLE} aria-label="Album title" disabled={phase.kind === 'sending'} - onChange={(e) => onTitle(e.target.value)} + onChange={(e) => setTitle(e.target.value)} /> {files.map((f) => f.name).join(' · ')} @@ -84,12 +133,12 @@ export function ShareSetView({ ) : ( <> - - @@ -99,62 +148,3 @@ export function ShareSetView({
); } - -export function ShareSet({ room }: { room: Room }) { - const mx = useMatrixClient(); - const queryClient = useQueryClient(); - const input = useRef(null); - // Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a - // rerender with a new client or Room object must not build another. Live values go through `live`. - const live = useRef({ mx, room, queryClient }); - live.current = { mx, room, queryClient }; - const [controller]: [ShareController, unknown] = useState(() => - createShareController({ - roomId: room.roomId, - isEncrypted: () => live.current.room.hasEncryptionStateEvent(), - onSent: () => - live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }), - run: (files, title, onFile) => - sendShareSet( - { - send: async (content) => - (await live.current.mx.sendMessage(room.roomId, content as any)).event_id, - contentOf: async (f) => { - const file = safeFile(f.file); - const mxc = await new Promise((resolve, reject) => { - uploadContent(live.current.mx, file, { onSuccess: resolve, onError: reject }); - }); - const content = await getMsgContent( - live.current.mx, - { - file, - originalFile: f.file, - metadata: { markedAsSpoiler: false }, - encInfo: undefined, - }, - mxc - ); - const sha256 = await hashForSend(f.file); - if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; - return content; - }, - onFile, - }, - title, - files.map((file) => ({ name: file.name, mimeType: file.type, file })) - ), - }) - ); - const state = useSyncExternalStore(controller.subscribe, controller.getState); - return ( - input.current?.click()} - onPicked={controller.pick} - onTitle={controller.setTitle} - onSend={controller.send} - onCancel={controller.cancel} - /> - ); -} diff --git a/src/app/features/albums/shareController.test.ts b/src/app/features/albums/shareController.test.ts deleted file mode 100644 index 3cbc03ce..00000000 --- a/src/app/features/albums/shareController.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Picked, ShareDeps, createShareController } from './shareController'; -import { ShareResult } from './shareSet'; - -const file = (name: string): Picked => ({ name, type: 'audio/ogg' }); -const ok = (n: number): ShareResult => ({ albumId: '$r', sent: n, failed: [], failedAt: [] }); - -/** A run the test settles by hand, so "in flight" is a state the test can stand in. */ -const hand = () => { - const calls: { files: Picked[]; title: string; onFile: () => void }[] = []; - let release: (r: ShareResult) => void = () => undefined; - let fail: (e: Error) => void = () => undefined; - const run: ShareDeps['run'] = (files, title, onFile) => { - calls.push({ files, title, onFile }); - return new Promise((resolve, reject) => { - release = resolve; - fail = reject; - }); - }; - return { calls, run, release: (r: ShareResult) => release(r), fail: (e: Error) => fail(e) }; -}; - -const make = (over: Partial> = {}, roomId = '!zone:x') => { - const h = hand(); - const sentTo: string[] = []; - const c = createShareController({ - roomId, - isEncrypted: () => false, - run: h.run, - onSent: () => sentTo.push(roomId), - ...over, - }); - return { c, h, sentTo }; -}; - -describe('picking', () => { - it('starts idle with nothing picked, and a pick holds the files in order', () => { - const { c } = make(); - expect(c.getState().files).toEqual([]); - expect(c.getState().phase).toEqual({ kind: 'idle' }); - c.pick([file('a'), file('b')]); - expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']); - }); - - it('Cancel empties the pick and the title and bumps the input epoch', () => { - const { c } = make(); - c.pick([file('a')]); - c.setTitle('Take 4'); - const before = c.getState().epoch; - c.cancel(); - expect(c.getState()).toMatchObject({ files: [], title: '', epoch: before + 1 }); - }); - - it('subscribers hear every change and stop hearing after unsubscribe', () => { - const { c } = make(); - let heard = 0; - const off = c.subscribe(() => { - heard += 1; - }); - c.pick([file('a')]); - off(); - c.pick([]); - expect(heard).toBe(1); - }); -}); - -describe('the Send guard', () => { - it('a double tap starts one send', async () => { - const { c, h } = make(); - c.pick([file('a'), file('b')]); - const first = c.send(); - const second = c.send(); - expect(h.calls).toHaveLength(1); - h.release(ok(2)); - await Promise.all([first, second]); - expect(h.calls).toHaveLength(1); - }); - - it('a re-mounted panel for the same room cannot start a second send while one is in flight', async () => { - const { c, h } = make(); - c.pick([file('a')]); - const first = c.send(); - // The page was left and returned to: a new controller, nothing picked in it but a new pick. - const again = make({}, '!zone:x'); - again.c.pick([file('b')]); - await again.c.send(); - expect(again.h.calls).toHaveLength(0); - h.release(ok(1)); - await first; - // Once the first has settled the room is free again. - const next = again.c.send(); - expect(again.h.calls).toHaveLength(1); - again.h.release(ok(1)); - await next; - }); - - it('another room is not blocked by a send in flight', async () => { - const a = make({}, '!a:x'); - const b = make({}, '!b:x'); - a.c.pick([file('a')]); - b.c.pick([file('b')]); - const sendA = a.c.send(); - const sendB = b.c.send(); - expect(b.h.calls).toHaveLength(1); - a.h.release(ok(1)); - b.h.release(ok(1)); - await Promise.all([sendA, sendB]); - }); - - it('sends nothing when nothing is picked', async () => { - const { c, h } = make(); - await c.send(); - expect(h.calls).toHaveLength(0); - }); - - it('a send that throws frees the room and says so', async () => { - const { c, h } = make(); - c.pick([file('a')]); - const p = c.send(); - h.fail(new Error('M_FORBIDDEN')); - await p; - expect(c.getState().phase).toEqual({ kind: 'done', text: 'Could not share: M_FORBIDDEN' }); - expect(c.getState().files).toHaveLength(1); - const retry = c.send(); - expect(h.calls).toHaveLength(2); - h.release(ok(1)); - await retry; - }); -}); - -describe('progress and results', () => { - it('counts files as they settle, passing the title through', async () => { - const { c, h } = make(); - c.pick([file('a'), file('b')]); - c.setTitle('Take 4'); - const p = c.send(); - expect(c.getState().phase).toEqual({ kind: 'sending', done: 0 }); - expect(h.calls[0].title).toBe('Take 4'); - h.calls[0].onFile(); - expect(c.getState().phase).toEqual({ kind: 'sending', done: 1 }); - h.release(ok(2)); - await p; - expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2 files.' }); - }); - - it('full success clears the pick, tells the room list, and remounts the input', async () => { - const { c, h, sentTo } = make(); - c.pick([file('a')]); - const { epoch } = c.getState(); - const p = c.send(); - h.release(ok(1)); - await p; - expect(c.getState()).toMatchObject({ files: [], title: '', epoch: epoch + 1 }); - expect(sentTo).toEqual(['!zone:x']); - }); - - it('failed files stay picked by position, so a same-named twin that went is not re-sent', async () => { - const { c, h } = make(); - const twins = [file('a.ogg'), file('a.ogg'), file('b.ogg')]; - c.pick(twins); - const p = c.send(); - h.release({ albumId: '$r', sent: 2, failed: ['a.ogg'], failedAt: [1] }); - await p; - expect(c.getState().files).toEqual([twins[1]]); - expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2. Not sent: a.ogg' }); - }); - - it('when every upload failed everything stays picked and the note says nothing was shared', async () => { - const { c, h } = make(); - const picked = [file('a'), file('b')]; - c.pick(picked); - const p = c.send(); - h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] }); - await p; - expect(c.getState().files).toEqual(picked); - expect(c.getState().phase).toEqual({ - kind: 'done', - text: 'Nothing was shared. Not sent: a, b', - }); - }); - - it('a new pick after a result drops the result note', async () => { - const { c, h } = make(); - c.pick([file('a')]); - const p = c.send(); - h.release(ok(1)); - await p; - c.pick([file('b')]); - expect(c.getState().phase).toEqual({ kind: 'idle' }); - }); -}); - -describe('what a send leaves behind', () => { - it('keeps the title when some files failed, so the retry has the same name', async () => { - const { c, h } = make(); - c.pick([file('a'), file('b')]); - c.setTitle('Take 4'); - const p = c.send(); - h.release({ albumId: '$r', sent: 1, failed: ['b'], failedAt: [1] }); - await p; - expect(c.getState().title).toBe('Take 4'); - }); - - it('tells the room list only after the send settled, and not when it threw', async () => { - const { c, h, sentTo } = make(); - c.pick([file('a')]); - const p = c.send(); - expect(sentTo).toEqual([]); - h.release(ok(1)); - await p; - expect(sentTo).toEqual(['!zone:x']); - - const bad = make(); - bad.c.pick([file('a')]); - const q = bad.c.send(); - bad.h.fail(new Error('boom')); - await q; - expect(bad.sentTo).toEqual([]); - }); - - it('a success lands in one notification: the pick is never empty while still sending', async () => { - const { c, h } = make(); - c.pick([file('a')]); - const p = c.send(); - const seen: string[] = []; - c.subscribe(() => { - const st = c.getState(); - seen.push(`${st.files.length}:${st.phase.kind}`); - }); - h.release(ok(1)); - await p; - expect(seen).toEqual(['0:done']); - }); -}); - -describe('an encrypted room', () => { - it('says so, sends nothing and keeps the pick', async () => { - const { c, h } = make({ isEncrypted: () => true }); - c.pick([file('a')]); - await c.send(); - expect(c.getState().phase).toEqual({ - kind: 'done', - text: 'Not shared: this room is encrypted.', - }); - expect(c.getState().files).toHaveLength(1); - expect(h.calls).toHaveLength(0); - }); - - it('is checked at Send, not at mount: a room that turns encrypted after the pick is refused', async () => { - let encrypted = false; - const { c, h } = make({ isEncrypted: () => encrypted }); - c.pick([file('a')]); - encrypted = true; - await c.send(); - expect(h.calls).toHaveLength(0); - expect(c.getState().phase).toMatchObject({ - kind: 'done', - text: 'Not shared: this room is encrypted.', - }); - }); -}); diff --git a/src/app/features/albums/shareController.ts b/src/app/features/albums/shareController.ts deleted file mode 100644 index b6219c72..00000000 --- a/src/app/features/albums/shareController.ts +++ /dev/null @@ -1,87 +0,0 @@ -// The share panel's state machine, apart from React and Matrix: what is picked, what phase the -// send is in, and the rules that keep a room to one send at a time. ShareSet.tsx draws it. -import { ShareResult, remainingAfter, resultNote } from './shareSet'; - -export type Picked = { name: string; type: string }; - -export type SharePhase = - | { kind: 'idle' } - | { kind: 'sending'; done: number } - | { kind: 'done'; text: string }; - -export type ShareState = { - files: F[]; - title: string; - phase: SharePhase; - /** Bumps whenever the pick is emptied, so the file input can be remounted and re-pick the same files. */ - epoch: number; -}; - -export const ENCRYPTED_NOTE = 'Not shared: this room is encrypted.'; - -export type ShareDeps = { - roomId: string; - isEncrypted: () => boolean; - /** Sends the files as one album; reports each settled file through onFile. */ - run: (files: F[], title: string, onFile: () => void) => Promise; - /** Called after a send that reached the room, whatever it left unsent. */ - onSent?: () => void; -}; - -/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */ -const sending = new Set(); - -export const createShareController = (deps: ShareDeps) => { - let state: ShareState = { files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }; - const listeners = new Set<() => void>(); - const set = (patch: Partial>) => { - state = { ...state, ...patch }; - listeners.forEach((l) => l()); - }; - - const clear = () => set({ files: [], title: '', epoch: state.epoch + 1 }); - - const send = async () => { - if (sending.has(deps.roomId) || state.files.length === 0) return; - if (deps.isEncrypted()) { - set({ phase: { kind: 'done', text: ENCRYPTED_NOTE } }); - return; - } - sending.add(deps.roomId); - const { files, title } = state; - set({ phase: { kind: 'sending', done: 0 } }); - let settled = 0; - try { - const res = await deps.run(files, title, () => { - settled += 1; - set({ phase: { kind: 'sending', done: settled } }); - }); - deps.onSent?.(); - const left = remainingAfter(files, res); - const phase: SharePhase = { kind: 'done', text: resultNote(res) }; - // One notification: no state is ever drawn with the pick emptied and the send still running. - if (left.length === 0) set({ files: [], title: '', epoch: state.epoch + 1, phase }); - else set({ files: left, phase }); - } catch (e: any) { - set({ phase: { kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` } }); - } finally { - sending.delete(deps.roomId); - } - }; - - return { - getState: () => state, - subscribe: (l: () => void) => { - listeners.add(l); - return () => { - listeners.delete(l); - }; - }, - pick: (files: F[]) => set({ files, phase: { kind: 'idle' } }), - setTitle: (title: string) => set({ title }), - cancel: clear, - send, - }; -}; - -export type ShareController = ReturnType>; diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts index 3e572595..803fbb87 100644 --- a/src/app/features/albums/shareSet.ts +++ b/src/app/features/albums/shareSet.ts @@ -72,7 +72,7 @@ export const sendShareSet = async ( * makes a second album (adding to an existing one is not built), and a share where nothing went * leaves its root as a lone message the album list does not show (it lists threads with replies). */ -export const remainingAfter = (files: T[], result: ShareResult): T[] => +export const remainingAfter = (files: F[], result: ShareResult): F[] => files.filter((_, i) => result.failedAt.includes(i)); export const resultNote = (result: ShareResult): string => { diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index d63f8709..1808be92 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -5,7 +5,6 @@ import { StatusDivider } from './components'; import { CallEmbed, useCallControlState } from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { callEmbedAtom } from '../../state/callEmbed'; -import { PhoneHitSpread26 } from '../../styles/PhoneHit.css'; type MicrophoneButtonProps = { enabled: boolean; @@ -15,7 +14,7 @@ type MicrophoneButtonProps = { function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { const [micState, toggleMic] = useAsyncCallback(onToggle); const loading = micState.status === AsyncStatus.Loading; - + return ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( callEmbed.control.toggleMicrophone(), - [callEmbed] - ); + const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); const [hangupState, hangup] = useAsyncCallback( @@ -203,7 +195,11 @@ export function CallControl({ disabled={!callJoined} /> {!compact && } - + {!compact && ( callEmbed.control.toggleMicrophone(), - [callEmbed] - ); + const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); const [hangupState, hangup] = useAsyncCallback( @@ -101,7 +97,10 @@ export function CallControls({ callEmbed }: CallControlsProps) { > - + callEmbed.control.toggleSound()} /> {!compact && } @@ -170,7 +169,6 @@ export function CallControls({ callEmbed }: CallControlsProps) { } > {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( toggleMicrophone(), [toggleMicrophone]); const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]); diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 61e05a62..29b6aa51 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -30,7 +30,6 @@ import { AccountDataSubmitCallback, } from '../../../components/AccountDataEditor'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type DeveloperToolsProps = { requestClose: () => void; @@ -93,7 +92,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { - + diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx index a6e60a7b..f25ba7c3 100644 --- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx +++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx @@ -22,7 +22,6 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { syntaxErrorPosition } from '../../../utils/dom'; import { Cursor } from '../../../plugins/text-area'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; const EDITOR_INTENT_SPACE_COUNT = 2; @@ -119,7 +118,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro - + diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx index 0f9f4034..0ea96901 100644 --- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx +++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx @@ -31,7 +31,6 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; const EDITOR_INTENT_SPACE_COUNT = 2; @@ -276,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi - + diff --git a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx index f7e901d5..ad8ffae6 100644 --- a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx @@ -4,7 +4,6 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { ImagePack } from '../../../plugins/custom-emoji'; import { ImagePackView } from '../../../components/image-pack-view'; import { RoomPacks } from './RoomPacks'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type EmojisStickersProps = { requestClose: () => void; @@ -30,7 +29,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { - + diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index 7be8de35..fdbe546e 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -40,7 +40,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useAlive } from '../../../hooks/useAlive'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type CreatePackTileProps = { packs: ImagePack[]; @@ -216,7 +215,6 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) { {canEdit && (removed ? ( ) : ( Enable Encryption - setPrompt(false)} - radii="300" - > + setPrompt(false)} radii="300"> diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx index 2971d565..45a480aa 100644 --- a/src/app/features/common-settings/general/RoomUpgrade.tsx +++ b/src/app/features/common-settings/general/RoomUpgrade.tsx @@ -39,7 +39,6 @@ import { useAlive } from '../../../hooks/useAlive'; import { creatorsSupported } from '../../../utils/matrix'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { BreakWord } from '../../../styles/Text.css'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { const mx = useMatrixClient(); @@ -106,7 +105,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'} - + diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx index a1dddb54..15cd1d08 100644 --- a/src/app/features/common-settings/members/Members.tsx +++ b/src/app/features/common-settings/members/Members.tsx @@ -56,7 +56,6 @@ import { useSpaceOptionally } from '../../../hooks/useSpace'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { getMouseEventCords } from '../../../utils/dom'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -162,7 +161,7 @@ export function Members({ requestClose }: MembersProps) { - + @@ -278,7 +277,6 @@ export function Members({ requestClose }: MembersProps) { anchorRef={scrollTopAnchorRef} > virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx index 0684a48a..97305946 100644 --- a/src/app/features/common-settings/permissions/PowersEditor.tsx +++ b/src/app/features/common-settings/permissions/PowersEditor.tsx @@ -49,7 +49,6 @@ import { useAlive } from '../../../hooks/useAlive'; import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { creatorsSupported } from '../../../utils/matrix'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type EditPowerProps = { maxPower: number; @@ -370,7 +369,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) { - + diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index 9332f35c..d5d8c92d 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -24,7 +24,6 @@ import { import { CreateRoomModalState } from '../../state/createRoomModal'; import { stopPropagation } from '../../utils/keyboard'; import { CreateRoomType } from '../../components/create-room/types'; -import { PhoneHit } from '../../styles/PhoneHit.css'; type CreateRoomModalProps = { state: CreateRoomModalState; @@ -64,7 +63,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { - + diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx index faa53b25..c1bc6898 100644 --- a/src/app/features/create-space/CreateSpaceModal.tsx +++ b/src/app/features/create-space/CreateSpaceModal.tsx @@ -23,7 +23,6 @@ import { } from '../../state/hooks/createSpaceModal'; import { CreateSpaceModalState } from '../../state/createSpaceModal'; import { stopPropagation } from '../../utils/keyboard'; -import { PhoneHit } from '../../styles/PhoneHit.css'; type CreateSpaceModalProps = { state: CreateSpaceModalState; @@ -62,7 +61,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) { New Space - + diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx index a09501e7..f1695716 100644 --- a/src/app/features/desk/Desk.tsx +++ b/src/app/features/desk/Desk.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; -import { PhoneHit } from '../../styles/PhoneHit.css'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { getHomePath } from '../../pages/pathUtils'; @@ -34,11 +33,7 @@ export function Desk() { {phone && ( - navigate(getHomePath())} - > + navigate(getHomePath())}> )} diff --git a/src/app/features/desk/DeskView.css.ts b/src/app/features/desk/DeskView.css.ts index 042f0644..4c7d08a7 100644 --- a/src/app/features/desk/DeskView.css.ts +++ b/src/app/features/desk/DeskView.css.ts @@ -30,29 +30,13 @@ export const Title = style({ export const Fold = style({ all: 'unset', cursor: 'pointer', + display: 'block', width: '100%', minHeight: 44, boxSizing: 'border-box', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: config.space.S200, - // A folded row says it can be opened: a chevron at the end, turned down while the row is open. - selectors: { - '&::after': { - content: '"›"', - flexShrink: 0, - fontSize: '1.5em', - lineHeight: 1, - transition: 'transform 120ms', - }, - '&[aria-expanded=true]::after': { transform: 'rotate(90deg)' }, - }, }); -// Quiet lines (age, who it waits on, helper text) were measured at 4.5 over the day sky and 3.9-4.0 at dusk and night -// with the theme's 0.75 (taps2/desk-REPORT.md); 0.9 clears 4.5 in all three. -export const Quiet = style({ opacity: 0.9, margin: `${config.space.S100} 0` }); +export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` }); export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` }); export const Options = style({ margin: 0, padding: 0, listStyle: 'none' }); export const Option = style({ padding: `${config.space.S100} 0` }); @@ -66,9 +50,8 @@ export const Note = style({ font: 'inherit', color: 'inherit', background: 'none', - // The note is disabled (slice 1). Its border is a UI boundary (3:1 wanted; the faint line under the 0.38 disabled - // opacity measured 2.0), so it is drawn dashed in the full ink and not faded. - border: '1px dashed currentColor', + border: `1px solid ${color.Background.ContainerLine}`, + opacity: config.opacity.Disabled, }); /** Sticky beside the rows on wide screens; a small fixed corner image on phones. */ @@ -114,9 +97,3 @@ export const BoothLine = style({ }, }, }); - -/** The recorded rulings sit on the dark ridge in every sky; they carry their own ground (scrimStyle). */ -export const Recorded = style({ - marginTop: config.space.S400, - padding: `${config.space.S200} ${config.space.S300}`, -}); diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx index 1bc7aa59..9c60ed53 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -1,19 +1,14 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { MemoryRouter } from 'react-router-dom'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; -import { SkyInkContextProvider } from '../../hooks/useTheme'; -import { SCRIM_GROUND, SCRIM_INK } from '../scene/skyInk'; import { DeskEntry } from './DeskEntry'; import { DeskView, SIGN_IN_NOTE } from './DeskView'; import { ForkList } from './forkContract'; import { STATES } from './fixtures'; import { setDeskSource } from './source'; -// useTheme reads the stored settings when it loads. -vi.hoisted(() => vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined })); - const view = ( state: keyof typeof STATES | 'unpublished', size: ScreenSize, @@ -71,19 +66,6 @@ describe('DeskView', () => { expect(html).toContain('Question done (ruling:done-1)'); expect(html).not.toContain('Question shut'); }); - it('gives the recorded rulings their own ground and ink while a scene runs, none without one', () => { - const withScene = renderToStaticMarkup( - - - - - - ); - // renderToStaticMarkup writes the inline style as the browser would read it back - expect(withScene).toContain(SCRIM_GROUND.replace(/, /g, ', ')); - expect(withScene).toContain(`color:${SCRIM_INK}`); - expect(view('settled', ScreenSize.Desktop)).not.toContain('background-color'); - }); it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => { const html = view('answered', ScreenSize.Desktop); expect(html).toContain( @@ -163,15 +145,7 @@ describe("a question's age", () => { expect(html('2026-11-11T11:40:00Z')).toContain('raised just now'); }); it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => { - [ - 'null', - 'yesterday', - '2027-01-01T00:00:00Z', - '2026-11-06', - 'Nov 6 2026', - '0', - '2026-02-30T00:00:00Z', - ].forEach((v) => { + ['null', 'yesterday', '2027-01-01T00:00:00Z', '2026-11-06', 'Nov 6 2026', '0', '2026-02-30T00:00:00Z'].forEach((v) => { expect(html(v === 'null' ? null : v)).not.toContain('raised '); }); }); diff --git a/src/app/features/desk/DeskView.tsx b/src/app/features/desk/DeskView.tsx index 64727799..f83dcd82 100644 --- a/src/app/features/desk/DeskView.tsx +++ b/src/app/features/desk/DeskView.tsx @@ -4,8 +4,6 @@ import { PoseAvatar } from '../../components/message/pose/PoseAvatar'; import { MINDS } from '../../components/message/pose/poses'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk'; -import { useSkyInk } from '../../hooks/useTheme'; -import { scrimStyle } from '../scene/skyInk'; import { ForkList } from './forkContract'; import { deskMood } from './deskMood'; import { raisedAge } from './age'; @@ -146,7 +144,6 @@ type DeskViewProps = { /** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */ export function DeskView({ mind, label, list, unverified, now = Date.now() }: DeskViewProps) { const desk = buildDesk(list, unverified); - const skyInk = useSkyInk(); // Slice 1 has no session, so nothing on the desk is the viewer's answer yet. const mood = deskMood(desk.counts.open, 0); return ( @@ -169,7 +166,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De )}
{desk.recorded.length > 0 && ( -
+ <>

Recorded as rulings

    {desk.recorded.map((r) => ( @@ -178,7 +175,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De ))}
-
+ )} ); diff --git a/src/app/features/dings/manifest.test.ts b/src/app/features/dings/manifest.test.ts index 722481b1..0001e685 100644 --- a/src/app/features/dings/manifest.test.ts +++ b/src/app/features/dings/manifest.test.ts @@ -109,10 +109,7 @@ describe('v4 manifest against the parser', () => { it("every voice file is Aqua's v4 render (sha256 prefix)", () => { const hash = (file: string) => - createHash('sha256') - .update(readFileSync(join(DINGS, file))) - .digest('hex') - .slice(0, 12); + createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12); expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length); Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want)); }); diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx index 43576cff..028cd560 100644 --- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx +++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx @@ -10,7 +10,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { allRoomsAtom } from '../../state/room-list/roomList'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { BackRouteHandler } from '../../components/BackRouteHandler'; -import { PhoneHit } from '../../styles/PhoneHit.css'; type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] }; export function JoinBeforeNavigate({ @@ -39,7 +38,7 @@ export function JoinBeforeNavigate({ {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index cdfa2d8a..1802e3b0 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -30,7 +30,6 @@ import { IPowerLevels } from '../../hooks/usePowerLevels'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; -import { PhoneHitSpread24 } from '../../styles/PhoneHit.css'; type HierarchyItemWithParent = HierarchyItem & { parentId: string; @@ -232,7 +231,6 @@ export function HierarchyItemMenu({ return ( virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index ce248fe5..ed526c68 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -38,7 +38,6 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; -import { PhoneHit } from '../../styles/PhoneHit.css'; type LobbyMenuProps = { powerLevels: IPowerLevels; @@ -166,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { {(onBack) => ( - + )} @@ -241,7 +240,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { > {(triggerRef) => ( useMemo( @@ -194,7 +193,6 @@ export function MessageSearch({ virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/features/push/PushNotification.tsx b/src/app/features/push/PushNotification.tsx deleted file mode 100644 index a818354e..00000000 --- a/src/app/features/push/PushNotification.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Switch, Text, color } from 'folds'; -import { SettingTile } from '../../components/setting-tile'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { getNotificationState, usePermissionState } from '../../hooks/usePermission'; -import type { ActivePushConfig } from './config'; -import { pushSessionToSW } from '../../../sw-session'; -import { disablePush, enablePush, pushIsOn } from './pusher'; - -export const pushSupported = (): boolean => - 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; - -const readyManager = async () => (await navigator.serviceWorker.ready).pushManager; - -/** Background notifications through the installed app's service worker. The caller renders it only when the host's config fills the push block and the browser can. */ -export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { - const mx = useMatrixClient(); - const permission = usePermissionState('notifications', getNotificationState()); - const [on, setOn] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(); - - useEffect(() => { - readyManager() - .then((m) => pushIsOn(mx, m, cfg)) - .then(setOn) - .catch(() => undefined); - }, [cfg, mx]); - - const toggle = useCallback( - async (enable: boolean) => { - setBusy(true); - setError(undefined); - try { - if (enable) { - if ((await window.Notification.requestPermission()) !== 'granted') { - throw new Error('Notification permission was not granted.'); - } - await enablePush(mx, await readyManager(), cfg, navigator.userAgent.slice(0, 64)); - } else { - await disablePush(mx, await readyManager(), cfg); - } - // The worker keeps the token only while a subscription exists: tell it the new state. - pushSessionToSW(mx.baseUrl, mx.getAccessToken() ?? undefined); - setOn(enable); - } catch (e) { - setError(e instanceof Error ? e.message : 'Could not change background notifications.'); - } finally { - setBusy(false); - } - }, - [cfg, mx] - ); - - return ( - - {error ?? 'Notification permission is blocked. Allow it from the browser address bar.'} - - ) : ( - - Notify when the app is closed. Installing the app to the home screen helps on iOS. - - ) - } - after={} - /> - ); -} diff --git a/src/app/features/push/config.test.ts b/src/app/features/push/config.test.ts deleted file mode 100644 index f014138a..00000000 --- a/src/app/features/push/config.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { activePushConfig } from './config'; - -describe('activePushConfig', () => { - it('is off unless all three keys are filled', () => { - expect(activePushConfig(undefined)).toBeUndefined(); - expect(activePushConfig({})).toBeUndefined(); - expect( - activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'https://g/_matrix/push/v1/notify' }) - ).toBeUndefined(); - expect(activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'u', appId: 'a' })).toEqual({ - vapidPublicKey: 'k', - gatewayUrl: 'u', - appId: 'a', - }); - }); - it('the shipped config.json carries the block empty, so push is off', () => { - const cfg = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8')); - expect(cfg.push).toEqual({ vapidPublicKey: '', gatewayUrl: '', appId: '' }); - expect(activePushConfig(cfg.push)).toBeUndefined(); - }); -}); diff --git a/src/app/features/push/config.ts b/src/app/features/push/config.ts deleted file mode 100644 index 31e476cd..00000000 --- a/src/app/features/push/config.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Web Push is off unless the host's config.json fills all three keys (ships empty). The public key is not a secret; -// the gateway URL is the push gateway's notify endpoint the homeserver calls (`/_matrix/push/v1/notify`). -export type PushConfig = { - vapidPublicKey?: string; - gatewayUrl?: string; - appId?: string; -}; - -export type ActivePushConfig = Required; - -export const activePushConfig = (c?: PushConfig): ActivePushConfig | undefined => - c?.vapidPublicKey && c.gatewayUrl && c.appId ? (c as ActivePushConfig) : undefined; diff --git a/src/app/features/push/manifest.test.ts b/src/app/features/push/manifest.test.ts deleted file mode 100644 index 806b3fc4..00000000 --- a/src/app/features/push/manifest.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -const ROOT = join(__dirname, '../../../..'); -const manifest = JSON.parse(readFileSync(join(ROOT, 'public/manifest.json'), 'utf8')); - -describe('web app manifest', () => { - it('has a stable identity and scope, and no orientation lock', () => { - expect(manifest.id).toBe('/'); // what Chrome already derives from start_url "./" at the site root - expect(manifest.scope).toBe('./'); - expect(manifest.start_url).toBe('./'); - expect(manifest.display).toBe('standalone'); - expect(manifest.orientation).toBeUndefined(); - }); - it('offers 192 and 512 icons for any use and a 512 maskable one, every file present', () => { - const icons = manifest.icons as { src: string; sizes: string; purpose?: string }[]; - const any = icons.filter((i) => !i.purpose); - expect(any.map((i) => i.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512'])); - const maskable = icons.filter((i) => i.purpose === 'maskable'); - expect(maskable.map((i) => i.sizes)).toEqual(['512x512']); - // the build copies public/res/android to public/android (vite.config.js) - icons.forEach((i) => - expect( - existsSync(join(ROOT, i.src.replace('./public/android/', 'public/res/android/'))) - ).toBe(true) - ); - }); -}); diff --git a/src/app/features/push/payload.test.ts b/src/app/features/push/payload.test.ts deleted file mode 100644 index 8e9e04eb..00000000 --- a/src/app/features/push/payload.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { describePush, GENERIC, parsePush } from './payload'; -import { matrixNotify, mockHomeserver, SESSION } from './testkit'; - -describe('parsePush', () => { - it('reads the Matrix gateway body and the bare inner object', () => { - const a = parsePush(matrixNotify('!r:x', '$e')); - expect(a).toMatchObject({ roomId: '!r:x', eventId: '$e' }); - expect(parsePush({ room_id: '!r:x', event_id: '$e' })).toMatchObject({ roomId: '!r:x' }); - }); - it('rejects a body with no room id', () => { - expect(parsePush({ notification: { event_id: '$e' } })).toBeUndefined(); - expect(parsePush(undefined)).toBeUndefined(); - expect(parsePush('x')).toBeUndefined(); - }); - it('carries text the gateway added', () => { - expect( - parsePush({ - notification: { - room_id: '!r:x', - room_name: 'Lobby', - sender_display_name: 'Ann', - content: { body: 'hi' }, - }, - }) - ).toMatchObject({ roomName: 'Lobby', sender: 'Ann', text: 'hi' }); - }); -}); - -describe('describePush', () => { - const ids = { roomId: '!r:x', eventId: '$e' }; - const event = { - type: 'm.room.message', - sender: '@ann:x', - content: { msgtype: 'm.text', body: 'hello there' }, - }; - - it('resolves ids through the homeserver with the stored token', async () => { - const hs = mockHomeserver({ - token: 'tok-1', - events: { $e: event }, - roomNames: { '!r:x': 'Lobby' }, - }); - expect(await describePush(ids, SESSION, hs.fetchFn)).toEqual({ - title: 'Lobby', - body: 'ann: hello there', - }); - expect(hs.seen).toHaveLength(2); - expect( - hs.seen.every( - (r) => r.auth === 'Bearer tok-1' && r.url.startsWith('https://matrix.example/_matrix/') - ) - ).toBe(true); - expect(hs.seen[0].url).toContain('/rooms/!r%3Ax/'); - }); - it('shows the generic notification with no session, and asks nobody', async () => { - const hs = mockHomeserver({ token: 'tok-1' }); - expect(await describePush(ids, undefined, hs.fetchFn)).toEqual({ - title: 'Ratatoskr', - body: 'New message', - }); - expect(hs.seen).toHaveLength(0); - }); - it('falls back when the token is refused, the network is down, or the event is encrypted', async () => { - const stale = mockHomeserver({ token: 'other', events: { $e: event } }); - expect(await describePush(ids, SESSION, stale.fetchFn)).toEqual(GENERIC); - const down = mockHomeserver({ token: 'tok-1', failAll: true }); - expect(await describePush(ids, SESSION, down.fetchFn)).toEqual(GENERIC); - const enc = mockHomeserver({ - token: 'tok-1', - events: { - $e: { - type: 'm.room.encrypted', - sender: '@ann:x', - content: { algorithm: 'm.megolm.v1.aes-sha2' }, - }, - }, - roomNames: { '!r:x': 'Lobby' }, - }); - expect(await describePush(ids, SESSION, enc.fetchFn)).toEqual({ - title: 'Lobby', - body: 'New message', - }); - }); - it('does not fetch what the push already carried', async () => { - const hs = mockHomeserver({ token: 'tok-1' }); - const shown = await describePush( - { ...ids, roomName: 'R', sender: 'Bo', text: 'yo' }, - SESSION, - hs.fetchFn - ); - expect(shown).toEqual({ title: 'R', body: 'Bo: yo' }); - expect(hs.seen).toHaveLength(0); - }); - it('clips a long body', async () => { - const hs = mockHomeserver({ - token: 'tok-1', - events: { $e: { ...event, content: { body: 'x'.repeat(500) } } }, - }); - const shown = await describePush(ids, SESSION, hs.fetchFn); - expect(shown.body.length).toBeLessThanOrEqual(200); - expect(shown.body.endsWith('…')).toBe(true); - }); - it('shows the generic notification when the push names no room', async () => { - const hs = mockHomeserver({ token: 'tok-1' }); - expect(await describePush(undefined, SESSION, hs.fetchFn)).toEqual(GENERIC); - }); -}); diff --git a/src/app/features/push/payload.ts b/src/app/features/push/payload.ts deleted file mode 100644 index b722d92a..00000000 --- a/src/app/features/push/payload.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { SessionRecord } from './sessionStore'; - -/** What a push names. The homeserver sends ids only (`push.include_content: false`); a gateway may add text. */ -export interface PushIds { - roomId: string; - eventId?: string; - roomName?: string; - sender?: string; - text?: string; -} - -export interface Shown { - title: string; - body: string; -} - -const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); - -/** The Matrix push body is `{ notification: {...} }`; a gateway may forward the inner object. No room id: not ours to show. */ -export function parsePush(raw: unknown): PushIds | undefined { - const top = (raw ?? {}) as Record; - const n = (top.notification ?? top) as Record; - const roomId = str(n.room_id); - if (!roomId) return undefined; - const content = (n.content ?? {}) as Record; - return { - roomId, - eventId: str(n.event_id), - roomName: str(n.room_name), - sender: str(n.sender_display_name) ?? str(n.sender), - text: str(content.body), - }; -} - -export const GENERIC: Shown = { title: 'Ratatoskr', body: 'New message' }; -const MAX_BODY = 200; -const clip = (s: string): string => (s.length > MAX_BODY ? `${s.slice(0, MAX_BODY - 1)}…` : s); -const localpart = (userId: string): string => userId.replace(/^@/, '').split(':')[0]; - -type Fetch = (url: string, init: RequestInit) => Promise; - -async function getJson( - f: Fetch, - session: SessionRecord, - path: string, - timeoutMs: number -): Promise | undefined> { - try { - const res = await f(`${session.baseUrl.replace(/\/+$/, '')}${path}`, { - headers: { Authorization: `Bearer ${session.accessToken}` }, - signal: AbortSignal.timeout(timeoutMs), - }); - return res.ok ? ((await res.json()) as Record) : undefined; - } catch { - return undefined; - } -} - -/** - * Title and body for a push. Text the push carried wins. Otherwise, with a session, the event and the room's name are - * fetched (clear-text rooms; an encrypted event shows the generic body). Every failure ends at the generic notification: - * a push must always show something. - */ -export async function describePush( - ids: PushIds | undefined, - session: SessionRecord | undefined, - f: Fetch, - timeoutMs = 4000 -): Promise { - if (!ids) return GENERIC; - let { roomName, sender, text } = ids; - if (session && (!text || !roomName)) { - const room = encodeURIComponent(ids.roomId); - const [event, name] = await Promise.all([ - !text && ids.eventId - ? getJson( - f, - session, - `/_matrix/client/v3/rooms/${room}/event/${encodeURIComponent(ids.eventId)}`, - timeoutMs - ) - : undefined, - !roomName - ? getJson(f, session, `/_matrix/client/v3/rooms/${room}/state/m.room.name`, timeoutMs) - : undefined, - ]); - const content = (event?.content ?? {}) as Record; - if (event?.type === 'm.room.message') text = str(content.body); - if (!sender && str(event?.sender)) sender = localpart(event?.sender as string); - roomName = roomName ?? str(name?.name); - } - let { body } = GENERIC; - if (text) body = sender ? `${sender}: ${text}` : text; - return { title: roomName ?? GENERIC.title, body: clip(body) }; -} diff --git a/src/app/features/push/pusher.test.ts b/src/app/features/push/pusher.test.ts deleted file mode 100644 index ffefa1f4..00000000 --- a/src/app/features/push/pusher.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { IPusherRequest } from 'matrix-js-sdk'; -import { - disablePush, - enablePush, - PushManagerPort, - pushIsOn, - vapidKeyBytes, - webPushPusher, -} from './pusher'; - -const cfg = { - vapidPublicKey: 'BAUD', - gatewayUrl: 'https://matrix.example/_matrix/push/v1/notify', - appId: 'org.gamecult.ratatoskr.web', -}; -const key = (...b: number[]) => Uint8Array.from(b).buffer; - -function subscription(unsubscribed: string[], serverKey?: ArrayBuffer): PushSubscription { - return { - options: { applicationServerKey: serverKey ?? vapidKeyBytes(cfg.vapidPublicKey).buffer }, - endpoint: 'https://push.example/send/abc', - getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)), - unsubscribe: async () => { - unsubscribed.push('x'); - return true; - }, - } as unknown as PushSubscription; -} - -function world() { - const calls: string[] = []; - const pushers: IPusherRequest[] = []; - let sub: PushSubscription | null = null; - const unsub: string[] = []; - const mx = { - setPusher: async (p: IPusherRequest) => { - calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher'); - pushers.push(p); - }, - // The account's pushers as the homeserver would list them: what setPusher last wrote, minus deletions. - getPushers: async () => ({ - pushers: pushers - .filter( - (p, i) => - p.kind !== null && - !pushers.slice(i + 1).some((q) => q.kind === null && q.pushkey === p.pushkey) - ) - .map((p) => ({ app_id: p.app_id, pushkey: p.pushkey })), - }), - }; - const push: PushManagerPort = { - getSubscription: async () => sub, - subscribe: async (o) => { - calls.push(`subscribe:${o.userVisibleOnly}:${o.applicationServerKey.length}`); - sub = subscription(unsub); - return sub; - }, - }; - const swapSub = (s: PushSubscription | null) => { - sub = s; - }; - return { mx, push, calls, pushers, unsub, swapSub }; -} - -describe('web push pusher', () => { - it('decodes the URL-safe VAPID key', () => { - expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]); - }); - it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => { - const p = webPushPusher(cfg, subscription([]), 'phone') as unknown as Record< - string, - unknown - > & { - data: Record; - }; - expect(p).toMatchObject({ - kind: 'http', - app_id: cfg.appId, - pushkey: '-_8B', - device_display_name: 'phone', - append: false, - }); - expect(p.data).toMatchObject({ - url: cfg.gatewayUrl, - format: 'event_id_only', - endpoint: 'https://push.example/send/abc', - auth: '-gI', - p256dh: '-_8B', - }); - }); - it('enable subscribes with userVisibleOnly, then registers the pusher; a second enable reuses the subscription', async () => { - const w = world(); - await enablePush(w.mx, w.push, cfg, 'phone'); - expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']); - await enablePush(w.mx, w.push, cfg, 'phone'); - expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher', 'set-pusher']); - }); - it('disable deletes the pusher before the subscription, and does nothing when there is none', async () => { - const w = world(); - await disablePush(w.mx, w.push, cfg); - expect(w.calls).toEqual([]); - await enablePush(w.mx, w.push, cfg, 'phone'); - await disablePush(w.mx, w.push, cfg); - expect(w.calls.slice(-1)).toEqual(['delete-pusher']); - expect(w.pushers.at(-1)).toMatchObject({ kind: null, app_id: cfg.appId, pushkey: '-_8B' }); - expect(w.unsub).toEqual(['x']); - }); - it('a failed pusher write leaves the subscription alone', async () => { - const w = world(); - await enablePush(w.mx, w.push, cfg, 'phone'); - w.mx.setPusher = async () => { - throw new Error('homeserver said no'); - }; - await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no'); - expect(w.unsub).toEqual([]); - }); - it('the toggle is on only with a subscription and the pusher of this account for it', async () => { - const w = world(); - expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); - await enablePush(w.mx, w.push, cfg, 'phone'); - expect(await pushIsOn(w.mx, w.push, cfg)).toBe(true); - // another account on the same browser: the subscription exists, its pusher does not - const other = { ...w.mx, getPushers: async () => ({ pushers: [] }) }; - expect(await pushIsOn(other, w.push, cfg)).toBe(false); - await disablePush(w.mx, w.push, cfg); - expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); - }); - it('a subscription made under another VAPID key is replaced, not reused', async () => { - const w = world(); - const old = [] as string[]; - w.swapSub(subscription(old, Uint8Array.from([9, 9, 9]).buffer)); - await enablePush(w.mx, w.push, cfg, 'phone'); - expect(old).toEqual(['x']); - expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']); - }); - it('a failed pusher write after subscribing leaves the toggle off', async () => { - const w = world(); - const fail = { ...w.mx, setPusher: async () => Promise.reject(new Error('no')) }; - await expect(enablePush(fail, w.push, cfg, 'phone')).rejects.toThrow('no'); - expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); - }); -}); diff --git a/src/app/features/push/pusher.ts b/src/app/features/push/pusher.ts deleted file mode 100644 index d57c2821..00000000 --- a/src/app/features/push/pusher.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { IPusherRequest } from 'matrix-js-sdk'; -import type { ActivePushConfig } from './config'; - -/** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */ -export interface PusherClient { - setPusher(p: IPusherRequest): Promise; - getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>; -} -export interface PushManagerPort { - getSubscription(): Promise; - subscribe(o: { - userVisibleOnly: boolean; - applicationServerKey: Uint8Array; - }): Promise; -} - -export function vapidKeyBytes(b64url: string): Uint8Array { - const pad = '='.repeat((4 - (b64url.length % 4)) % 4); - const bin = atob((b64url + pad).replace(/-/g, '+').replace(/_/g, '/')); - return Uint8Array.from(bin, (c) => c.charCodeAt(0)); -} - -const b64url = (buf: ArrayBuffer | null): string => - btoa(String.fromCharCode(...new Uint8Array(buf ?? new ArrayBuffer(0)))) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - -/** - * The Matrix pusher for a browser subscription (Sygnal's `webpush` shape: the subscription's keys ride in `data`; the - * pushkey is the subscription's p256dh key, as Element Web does). `event_id_only` asks the homeserver for ids, which - * the worker resolves itself. - */ -export function webPushPusher( - cfg: ActivePushConfig, - sub: PushSubscription, - deviceName: string -): IPusherRequest { - const p256dh = b64url(sub.getKey('p256dh')); - return { - kind: 'http', - app_id: cfg.appId, - pushkey: p256dh, - app_display_name: 'Ratatoskr', - device_display_name: deviceName, - lang: navigator.language || 'en', - data: { - url: cfg.gatewayUrl, - format: 'event_id_only', - endpoint: sub.endpoint, - auth: b64url(sub.getKey('auth')), - p256dh, - }, - append: false, - } as IPusherRequest; -} - -const sameKey = (sub: PushSubscription, vapid: Uint8Array): boolean => { - const have = sub.options?.applicationServerKey; - return !have || b64url(have) === b64url(vapid.buffer as ArrayBuffer); -}; - -/** Subscribe (replacing a subscription made under another VAPID key), then register the pusher for this account. */ -export async function enablePush( - mx: PusherClient, - push: PushManagerPort, - cfg: ActivePushConfig, - deviceName: string -): Promise { - const key = vapidKeyBytes(cfg.vapidPublicKey); - let sub = await push.getSubscription(); - if (sub && !sameKey(sub, key)) { - await sub.unsubscribe(); - sub = null; - } - sub = sub ?? (await push.subscribe({ userVisibleOnly: true, applicationServerKey: key })); - await mx.setPusher(webPushPusher(cfg, sub, deviceName)); -} - -/** On only when this browser holds a subscription AND this account has a pusher for it (the two live in different places). */ -export async function pushIsOn( - mx: PusherClient, - push: PushManagerPort, - cfg: ActivePushConfig -): Promise { - const sub = await push.getSubscription(); - if (!sub) return false; - const pushkey = b64url(sub.getKey('p256dh')); - const { pushers } = await mx.getPushers(); - return pushers.some((p) => p.app_id === cfg.appId && p.pushkey === pushkey); -} - -/** Delete this account's pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ -export async function disablePush( - mx: PusherClient, - push: PushManagerPort, - cfg: ActivePushConfig -): Promise { - const sub = await push.getSubscription(); - if (!sub) return; - await mx.setPusher({ - kind: null, - app_id: cfg.appId, - pushkey: b64url(sub.getKey('p256dh')), - } as unknown as IPusherRequest); - await sub.unsubscribe(); -} diff --git a/src/app/features/push/sessionStore.test.ts b/src/app/features/push/sessionStore.test.ts deleted file mode 100644 index 86cf9147..00000000 --- a/src/app/features/push/sessionStore.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { idbPort, RecordPort, SessionRecord, sessionStore } from './sessionStore'; - -const memory = (): RecordPort & { held?: unknown } => { - const m: RecordPort & { held?: unknown } = { - async read() { - return m.held; - }, - async write(v) { - m.held = v; - }, - }; - return m; -}; - -describe('sessionStore', () => { - it('saves a valid session and loads it back', async () => { - const s = sessionStore(memory()); - await s.save('https://hs/', 'tok'); - expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); - }); - it('deletes the record on logout, and on anything that is not two strings', async () => { - const port = memory(); - const s = sessionStore(port); - await s.save('https://hs/', 'tok'); - await s.save(undefined, undefined); - expect(port.held).toBeUndefined(); - await s.save('https://hs/', 'tok'); - await s.save('https://hs/', 5); - expect(await s.load()).toBeUndefined(); - }); - it('keeps the token only while a push subscription exists', async () => { - const port = memory(); - const s = sessionStore(port); - await s.saveIfSubscribed('https://hs/', 'tok', async () => false); - expect(port.held).toBeUndefined(); - await s.saveIfSubscribed('https://hs/', 'tok', async () => true); - expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); - await s.saveIfSubscribed('https://hs/', 'tok', async () => false); - expect(port.held).toBeUndefined(); - await s.saveIfSubscribed('https://hs/', 'tok', async () => true); - await s.saveIfSubscribed('https://hs/', 'tok', () => - Promise.reject(new Error('no push manager')) - ); - expect(port.held).toBeUndefined(); - }); - it('ignores a record that is not a session', async () => { - const port = memory(); - port.held = { baseUrl: 1 }; - expect(await sessionStore(port).load()).toBeUndefined(); - }); -}); - -// Just enough of IndexedDB for the port: one database, object stores of key -> value, async request callbacks. -function fakeIndexedDB(): IDBFactory { - const dbs = new Map>>(); - const request = (run: () => T) => { - const r = {} as IDBRequest; - setTimeout(() => { - (r as { result: T }).result = run(); - r.onsuccess?.(new Event('success')); - }, 0); - return r; - }; - return { - open(name: string) { - const r = {} as IDBOpenDBRequest; - setTimeout(() => { - const fresh = !dbs.has(name); - const stores = dbs.get(name) ?? new Map(); - dbs.set(name, stores); - const db = { - createObjectStore: (n: string) => stores.set(n, new Map()), - close: () => undefined, - transaction: (n: string) => { - const tx = {} as IDBTransaction; - const store = stores.get(n) as Map; - const api = { - get: (k: string) => request(() => store.get(k)), - put: (v: unknown, k: string) => { - store.set(k, v); - setTimeout(() => tx.oncomplete?.(new Event('complete')), 0); - }, - delete: (k: string) => { - store.delete(k); - setTimeout(() => tx.oncomplete?.(new Event('complete')), 0); - }, - }; - (tx as { objectStore: unknown }).objectStore = () => api; - return tx; - }, - } as unknown as IDBDatabase; - (r as { result: IDBDatabase }).result = db; - if (fresh) r.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent); - r.onsuccess?.(new Event('success')); - }, 0); - return r; - }, - } as unknown as IDBFactory; -} - -describe('idbPort', () => { - it('round-trips the one record through IndexedDB and deletes it', async () => { - const s = sessionStore(idbPort(fakeIndexedDB())); - expect(await s.load()).toBeUndefined(); - await s.save('https://hs/', 'tok'); - expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); - await s.save(undefined, undefined); - expect(await s.load()).toBeUndefined(); - }); -}); diff --git a/src/app/features/push/sessionStore.ts b/src/app/features/push/sessionStore.ts deleted file mode 100644 index f1835e9c..00000000 --- a/src/app/features/push/sessionStore.ts +++ /dev/null @@ -1,88 +0,0 @@ -// The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names. -// Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other -// origins; the token is already in the page's own storage). Written when the page hands the worker its session -// while this browser holds a push subscription; deleted when the page hands it none (logout) or the subscription is -// gone. A browser that never turned push on stores nothing. Nothing else reads it but the push handler. -export interface SessionRecord { - baseUrl: string; - accessToken: string; -} - -/** The two operations the store needs from a key-value backend, so tests run without IndexedDB. */ -export interface RecordPort { - read(): Promise; - write(value: SessionRecord | undefined): Promise; -} - -const DB = 'ratatoskr-sw'; -const STORE = 'session'; -const KEY = 'session'; - -const done = (req: IDBRequest): Promise => - new Promise((resolve, reject) => { - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - -export function idbPort(factory: IDBFactory): RecordPort { - const open = (): Promise => { - const req = factory.open(DB, 1); - req.onupgradeneeded = () => req.result.createObjectStore(STORE); - return done(req); - }; - return { - async read() { - const db = await open(); - try { - return await done(db.transaction(STORE).objectStore(STORE).get(KEY)); - } finally { - db.close(); - } - }, - async write(value) { - const db = await open(); - try { - const tx = db.transaction(STORE, 'readwrite'); - const store = tx.objectStore(STORE); - const finished = new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(tx.error); - }); - if (value) store.put(value, KEY); - else store.delete(KEY); - await finished; - } finally { - db.close(); - } - }, - }; -} - -export function sessionStore(port: RecordPort) { - return { - /** A valid session replaces the record; anything else (logout) deletes it. */ - async save(baseUrl: unknown, accessToken: unknown): Promise { - await port.write( - typeof baseUrl === 'string' && typeof accessToken === 'string' && baseUrl && accessToken - ? { baseUrl, accessToken } - : undefined - ); - }, - /** The token is kept only while this browser holds a push subscription; without one the record is deleted. */ - async saveIfSubscribed( - baseUrl: unknown, - accessToken: unknown, - subscribed: () => Promise - ): Promise { - const keep = await subscribed().catch(() => false); - await this.save(keep ? baseUrl : undefined, keep ? accessToken : undefined); - }, - async load(): Promise { - const v = (await port.read()) as Partial | undefined; - return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string' - ? { baseUrl: v.baseUrl, accessToken: v.accessToken } - : undefined; - }, - }; -} diff --git a/src/app/features/push/testkit.ts b/src/app/features/push/testkit.ts deleted file mode 100644 index 7f594f9f..00000000 --- a/src/app/features/push/testkit.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { SessionRecord } from './sessionStore'; - -/** A mock homeserver: answers the two reads the push handler makes, and records every request it saw. */ -export function mockHomeserver(opts: { - token: string; - events?: Record; - roomNames?: Record; - failAll?: boolean; -}) { - const seen: { url: string; auth: string | null }[] = []; - const fetchFn = async (url: string, init: RequestInit): Promise => { - const auth = new Headers(init.headers).get('Authorization'); - seen.push({ url, auth }); - if (opts.failAll) throw new Error('network down'); - if (auth !== `Bearer ${opts.token}`) return new Response('{}', { status: 401 }); - const ev = /\/rooms\/([^/]+)\/event\/([^/]+)$/.exec(url); - if (ev) { - const e = opts.events?.[decodeURIComponent(ev[2])]; - return e ? new Response(JSON.stringify(e)) : new Response('{}', { status: 404 }); - } - const nm = /\/rooms\/([^/]+)\/state\/m\.room\.name$/.exec(url); - if (nm) { - const name = opts.roomNames?.[decodeURIComponent(nm[1])]; - return name ? new Response(JSON.stringify({ name })) : new Response('{}', { status: 404 }); - } - return new Response('{}', { status: 404 }); - }; - return { fetchFn, seen }; -} - -export const SESSION: SessionRecord = { baseUrl: 'https://matrix.example/', accessToken: 'tok-1' }; - -/** The body a homeserver posts to a push gateway for `format: event_id_only` (Matrix push gateway API). */ -export const matrixNotify = (roomId: string, eventId: string) => ({ - notification: { - event_id: eventId, - room_id: roomId, - counts: { unread: 1 }, - devices: [{ app_id: 'org.gamecult.ratatoskr.web', pushkey: 'k', data: {} }], - }, -}); diff --git a/src/app/features/push/usePushOpen.ts b/src/app/features/push/usePushOpen.ts deleted file mode 100644 index e3b88d3a..00000000 --- a/src/app/features/push/usePushOpen.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect } from 'react'; -import { useRoomNavigate } from '../../hooks/useRoomNavigate'; -import { OPEN_ROOM, PUSH_EVENT_PARAM, PUSH_ROOM_PARAM } from './worker'; - -/** Opens the room a push notification named: from the worker's message (app open) or the start URL (app was closed). */ -export function usePushOpen(): void { - const { navigateRoom } = useRoomNavigate(); - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const roomId = params.get(PUSH_ROOM_PARAM); - if (roomId) { - navigateRoom(roomId, params.get(PUSH_EVENT_PARAM) ?? undefined); - params.delete(PUSH_ROOM_PARAM); - params.delete(PUSH_EVENT_PARAM); - const rest = params.toString(); - window.history.replaceState( - null, - '', - `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}` - ); - } - const onMessage = (ev: MessageEvent) => { - if (ev.data?.type === OPEN_ROOM && typeof ev.data.roomId === 'string') { - navigateRoom( - ev.data.roomId, - typeof ev.data.eventId === 'string' ? ev.data.eventId : undefined - ); - } - }; - navigator.serviceWorker?.addEventListener('message', onMessage); - return () => navigator.serviceWorker?.removeEventListener('message', onMessage); - }, [navigateRoom]); -} diff --git a/src/app/features/push/worker.test.ts b/src/app/features/push/worker.test.ts deleted file mode 100644 index 5eaad32f..00000000 --- a/src/app/features/push/worker.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { handleClick, handlePush, OPEN_ROOM, WorkerPort } from './worker'; -import { matrixNotify, mockHomeserver, SESSION } from './testkit'; - -type Shown = { title: string; options: NotificationOptions & { renotify?: boolean } }; - -function port(over: Partial = {}) { - const shown: Shown[] = []; - const opened: string[] = []; - const w: WorkerPort = { - windows: async () => [], - openWindow: async (u) => { - opened.push(u); - }, - show: async (title, options) => { - shown.push({ title, options }); - }, - loadSession: async () => SESSION, - fetch: mockHomeserver({ token: 'tok-1' }).fetchFn, - scope: 'https://chat.example/', - ...over, - }; - return { w, shown, opened }; -} - -const msg = { - type: 'm.room.message', - sender: '@ann:x', - content: { msgtype: 'm.text', body: 'hello' }, -}; - -describe('push against a mock gateway and homeserver', () => { - it('a push the homeserver sent for event_id_only becomes a notification naming room, sender and text', async () => { - const hs = mockHomeserver({ - token: 'tok-1', - events: { $e: msg }, - roomNames: { '!r:x': 'Lobby' }, - }); - const { w, shown } = port({ fetch: hs.fetchFn }); - await handlePush(matrixNotify('!r:x', '$e'), w); - expect(shown).toHaveLength(1); - expect(shown[0].title).toBe('Lobby'); - expect(shown[0].options.body).toBe('ann: hello'); - expect(shown[0].options.tag).toBe('!r:x'); - expect(shown[0].options.renotify).toBe(true); - expect(shown[0].options.data).toEqual({ roomId: '!r:x', eventId: '$e' }); - }); - it('with no stored session it still shows (a push must always show) and sends the token nowhere', async () => { - const hs = mockHomeserver({ token: 'tok-1', events: { $e: msg } }); - const { w, shown } = port({ fetch: hs.fetchFn, loadSession: async () => undefined }); - await handlePush(matrixNotify('!r:x', '$e'), w); - expect(shown[0]).toMatchObject({ title: 'Ratatoskr', options: { body: 'New message' } }); - expect(hs.seen).toHaveLength(0); - }); - it('shows the generic notification for a body it cannot read', async () => { - const { w, shown } = port(); - await handlePush(undefined, w); - expect(shown[0].title).toBe('Ratatoskr'); - expect(shown[0].options.tag).toBeUndefined(); - expect(shown[0].options.data).toBeUndefined(); - }); - it('shows even when the window list cannot be read', async () => { - const { w, shown } = port({ windows: () => Promise.reject(new Error('no clients')) }); - await handlePush(matrixNotify('!r:x', '$e'), w); - expect(shown).toHaveLength(1); - }); - it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => { - const win = (visibilityState: string, focused: boolean) => ({ - visibilityState, - focused, - focus: async () => undefined, - postMessage: () => undefined, - }); - const a = port({ windows: async () => [win('visible', true)] }); - await handlePush(matrixNotify('!r:x', '$e'), a.w); - expect(a.shown).toHaveLength(0); - const b = port({ windows: async () => [win('hidden', false), win('visible', false)] }); - await handlePush(matrixNotify('!r:x', '$e'), b.w); - expect(b.shown).toHaveLength(1); - }); -}); - -describe('notification click', () => { - it('focuses the open window and tells it the room', async () => { - const focus = vi.fn(async () => undefined); - const postMessage = vi.fn(); - const { w, opened } = port({ - windows: async () => [{ visibilityState: 'hidden', focused: false, focus, postMessage }], - }); - await handleClick({ roomId: '!r:x', eventId: '$e' }, w); - expect(focus).toHaveBeenCalled(); - expect(postMessage).toHaveBeenCalledWith({ type: OPEN_ROOM, roomId: '!r:x', eventId: '$e' }); - expect(opened).toHaveLength(0); - }); - it('with no window, starts the app with the room in the URL', async () => { - const { w, opened } = port(); - await handleClick({ roomId: '!r:x', eventId: '$e' }, w); - const u = new URL(opened[0]); - expect(u.origin + u.pathname).toBe('https://chat.example/'); - expect(u.searchParams.get('pushRoom')).toBe('!r:x'); - expect(u.searchParams.get('pushEvent')).toBe('$e'); - }); - it('with no window and no target, just opens the app', async () => { - const { w, opened } = port(); - await handleClick(undefined, w); - expect(opened).toEqual(['https://chat.example/']); - }); -}); diff --git a/src/app/features/push/worker.ts b/src/app/features/push/worker.ts deleted file mode 100644 index a98f873d..00000000 --- a/src/app/features/push/worker.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describePush, parsePush } from './payload'; -import type { SessionRecord } from './sessionStore'; - -/** The slice of the service worker's scope the handlers use, so they run in tests without a worker. */ -export interface WorkerPort { - windows(): Promise< - { - visibilityState: string; - focused: boolean; - focus(): Promise; - postMessage(m: unknown): void; - }[] - >; - openWindow(url: string): Promise; - show(title: string, options: NotificationOptions): Promise; - loadSession(): Promise; - fetch(url: string, init: RequestInit): Promise; - /** The worker's scope URL: where a cold click opens the app. */ - scope: string; -} - -export interface PushTarget { - roomId: string; - eventId?: string; -} - -/** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */ -export async function handlePush(raw: unknown, w: WorkerPort): Promise { - const ids = parsePush(raw); - const open = await w.windows().catch(() => []); - if (open.some((c) => c.visibilityState === 'visible' && c.focused)) return; - const shown = await describePush(ids, await w.loadSession(), w.fetch); - await w.show(shown.title, { - body: shown.body, - // One notification per room, replaced (and re-alerted) by the next message there. - tag: ids?.roomId, - renotify: !!ids?.roomId, - icon: './public/android/android-chrome-192x192.png', - data: ids ? ({ roomId: ids.roomId, eventId: ids.eventId } satisfies PushTarget) : undefined, - } as NotificationOptions); -} - -export const OPEN_ROOM = 'openRoom'; -export const PUSH_ROOM_PARAM = 'pushRoom'; -export const PUSH_EVENT_PARAM = 'pushEvent'; - -/** A click focuses an open window and tells it which room; with none open it starts the app with the room in the URL. */ -export async function handleClick(target: PushTarget | undefined, w: WorkerPort): Promise { - const windows = await w.windows(); - const open = windows[0]; - if (open) { - await open.focus(); - if (target?.roomId) open.postMessage({ type: OPEN_ROOM, ...target }); - return; - } - const url = new URL(w.scope); - if (target?.roomId) { - url.searchParams.set(PUSH_ROOM_PARAM, target.roomId); - if (target.eventId) url.searchParams.set(PUSH_EVENT_PARAM, target.eventId); - } - await w.openWindow(url.href); -} diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 8d53ce7f..e8a349f0 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -66,7 +66,6 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; import { StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; -import { PhoneHitSpread24 } from '../../styles/PhoneHit.css'; type RoomNavItemMenuProps = { room: Room; @@ -230,7 +229,6 @@ function CallChatToggle() { return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" @@ -438,7 +436,6 @@ export function RoomNavItem({ } > {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index e29fe2da..15157304 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -15,7 +15,6 @@ import { } from '../../common-settings/general'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type GeneralProps = { requestClose: () => void; @@ -36,7 +35,7 @@ export function General({ requestClose }: GeneralProps) { - + diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx index d513dda9..7572a71b 100644 --- a/src/app/features/room-settings/permissions/Permissions.tsx +++ b/src/app/features/room-settings/permissions/Permissions.tsx @@ -9,7 +9,6 @@ import { usePermissionGroups } from './usePermissionItems'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type PermissionsProps = { requestClose: () => void; @@ -46,7 +45,7 @@ export function Permissions({ requestClose }: PermissionsProps) { - + diff --git a/src/app/features/room/CallChatView.tsx b/src/app/features/room/CallChatView.tsx index fe40eb50..6c32f4a0 100644 --- a/src/app/features/room/CallChatView.tsx +++ b/src/app/features/room/CallChatView.tsx @@ -6,7 +6,6 @@ import { Page, PageHeader } from '../../components/page'; import { callChatAtom } from '../../state/callEmbed'; import { RoomView } from './RoomView'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { PhoneHit } from '../../styles/PhoneHit.css'; export function CallChatView() { const { eventId } = useParams(); @@ -42,12 +41,7 @@ export function CallChatView() { } > {(triggerRef) => ( - + )} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index 3162eff0..d9205076 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -59,7 +59,6 @@ import { useSpaceOptionally } from '../../hooks/useSpace'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../hooks/useRoomCreators'; -import { PhoneHit } from '../../styles/PhoneHit.css'; type MemberDrawerHeaderProps = { room: Room; @@ -88,7 +87,6 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) { > {(triggerRef) => ( setPeopleDrawer(false)} @@ -361,7 +359,6 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 2d63cbf1..b4483d8b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -28,9 +28,6 @@ import { config, toRem, } from 'folds'; -import { RoomOutbox } from './RoomOutbox'; -import { sendTracked } from '../../state/room/outboxSend'; -import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { @@ -116,7 +113,6 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; -import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css'; interface RoomInputProps { editor: Editor; @@ -225,66 +221,22 @@ export const RoomInput = forwardRef( useCallback((width) => setHideStickerBtn(width < 500), []) ); - const userId = mx.getUserId() ?? ''; - const draftSaveTimer = useRef>(); - - // The atom is only a cache: after a reload it is empty and the stored draft is the truth. - useEffect(() => { - if (msgDraft.length === 0) { - const stored = loadDraft(userId, roomId); - if (stored.length > 0) setMsgDraft(stored); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [userId, roomId]); - useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); - const persistDraft = useCallback(() => { - clearTimeout(draftSaveTimer.current); - draftSaveTimer.current = undefined; - saveDraft(userId, roomId, editor.children); - }, [userId, roomId, editor]); - - // Write on input (debounced), and flush when the page is hidden or unloading, which is - // all a mobile reload gives us. - const handleEditorChange = useCallback(() => { - clearTimeout(draftSaveTimer.current); - draftSaveTimer.current = setTimeout(persistDraft, 400); - }, [persistDraft]); - - useEffect(() => { - const flush = () => { - if (draftSaveTimer.current !== undefined) persistDraft(); - }; - const onVisibility = () => { - if (document.visibilityState === 'hidden') flush(); - }; - window.addEventListener('pagehide', flush); - document.addEventListener('visibilitychange', onVisibility); - return () => { - window.removeEventListener('pagehide', flush); - document.removeEventListener('visibilitychange', onVisibility); - }; - }, [persistDraft]); - useEffect( () => () => { - clearTimeout(draftSaveTimer.current); - draftSaveTimer.current = undefined; if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); - saveDraft(userId, roomId, parsedDraft); } else { setMsgDraft([]); - clearDraft(userId, roomId); } resetEditor(editor); resetEditorHistory(editor); }, - [roomId, editor, setMsgDraft, userId] + [roomId, editor, setMsgDraft] ); const handleFileMetadata = useCallback( @@ -335,7 +287,7 @@ export const RoomInput = forwardRef( }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); - contents.forEach((content) => sendTracked(mx, roomId, content as Record)); + contents.forEach((content) => mx.sendMessage(roomId, content as any)); }; const submit = useCallback(() => { @@ -414,25 +366,12 @@ export const RoomInput = forwardRef( content['m.relates_to'].is_falling_back = false; } } - sendTracked(mx, roomId, content); - clearTimeout(draftSaveTimer.current); - draftSaveTimer.current = undefined; - clearDraft(userId, roomId); + mx.sendMessage(roomId, content as any); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); sendTypingStatus(false); - }, [ - mx, - roomId, - userId, - editor, - replyDraft, - sendTypingStatus, - setReplyDraft, - isMarkdown, - commands, - ]); + }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -494,7 +433,11 @@ export const RoomInput = forwardRef( await getImageUrlBlob(stickerUrl) ); - sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker); + mx.sendEvent(roomId, EventType.Sticker, { + body: label, + url: mxc, + info, + }); }; return ( @@ -594,53 +537,47 @@ export const RoomInput = forwardRef( onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onPaste={handlePaste} - onChange={handleEditorChange} top={ - <> - - {replyDraft && ( -
- + + setReplyDraft(undefined)} + variant="SurfaceVariant" + size="300" + radii="300" > - setReplyDraft(undefined)} - variant="SurfaceVariant" - size="300" - radii="300" - > - - - - {replyDraft.relation?.rel_type === RelationType.Thread && } - - - {getMemberDisplayName(room, replyDraft.userId) ?? - getMxIdLocalPart(replyDraft.userId) ?? - replyDraft.userId} - - - } - > + + + + {replyDraft.relation?.rel_type === RelationType.Thread && } + - {trimReplyFromBody(replyDraft.body)} + + {getMemberDisplayName(room, replyDraft.userId) ?? + getMxIdLocalPart(replyDraft.userId) ?? + replyDraft.userId} + - - + } + > + + {trimReplyFromBody(replyDraft.body)} + + -
- )} - +
+
+ ) } before={ pickFile('*')} variant="SurfaceVariant" size="300" @@ -652,7 +589,6 @@ export const RoomInput = forwardRef( after={ <> ( > {!hideStickerBtn && ( setEmojiBoardTab(EmojiBoardTab.Sticker)} variant="SurfaceVariant" @@ -709,7 +644,6 @@ export const RoomInput = forwardRef( )} ( )} - + diff --git a/src/app/features/room/RoomOutbox.actions.test.tsx b/src/app/features/room/RoomOutbox.actions.test.tsx deleted file mode 100644 index 0dbd4f5d..00000000 --- a/src/app/features/room/RoomOutbox.actions.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { enqueueOutbox, failOutbox, loadOutbox } from '../../state/room/composerStore'; -import { RoomOutbox } from './RoomOutbox'; - -// The component's hooks are held still (state is its initial value, effects do not run) so the element tree -// it returns can be read and its click handlers called without a DOM. -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal(); - const useState = (init: unknown) => [ - typeof init === 'function' ? (init as () => unknown)() : init, - () => undefined, - ]; - return { - ...actual, - default: { ...actual, useState, useEffect: () => undefined }, - useState, - useEffect: () => undefined, - }; -}); - -type El = { props?: { children?: unknown; onClick?: () => void } }; -const flatten = (node: unknown): El[] => { - if (Array.isArray(node)) return node.flatMap(flatten); - if (node && typeof node === 'object') { - const el = node as El; - return [el, ...flatten(el.props?.children)]; - } - return []; -}; -const textOf = (node: unknown): string => { - if (typeof node === 'string' || typeof node === 'number') return String(node); - if (Array.isArray(node)) return node.map(textOf).join(''); - if (node && typeof node === 'object') return textOf((node as El).props?.children); - return ''; -}; -/** The onClick of the chip whose label is `label`. */ -const clickOf = (tree: unknown, label: string) => { - const chip = flatten(tree).find((e) => e.props?.onClick && textOf(e) === label); - if (!chip?.props?.onClick) throw new Error(`no ${label} chip`); - return chip.props.onClick; -}; - -describe('RoomOutbox actions', () => { - beforeEach(() => { - const m = new Map(); - vi.stubGlobal('localStorage', { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - }); - enqueueOutbox('@a:x', { txnId: 'keep', roomId: '!r:x', content: { body: 'stay' } }); - enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'did not go' } }); - failOutbox('@a:x', 'bad', 'ECONNRESET'); - }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - const mount = () => { - const mx = { - getUserId: () => '@a:x', - makeTxnId: () => 'txn-new', - getRoom: () => undefined, - sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }), - }; - const tree = (RoomOutbox as unknown as (p: unknown) => unknown)({ mx, roomId: '!r:x' }); - return { mx, tree }; - }; - - it('Discard removes that message from the outbox and no other', () => { - const { mx, tree } = mount(); - // both entries are shown; the failed one is the one with Discard - clickOf(tree, 'Discard')(); - expect(loadOutbox('@a:x').map((e) => e.txnId)).toEqual(['keep']); - expect(mx.sendEvent).not.toHaveBeenCalled(); - }); - - it('Resend sends that message again and does not discard it first', async () => { - const { mx, tree } = mount(); - // the server has not answered yet: the resend is in flight - mx.sendEvent.mockReturnValue( - new Promise(() => { - // never settles - }) - ); - clickOf(tree, 'Resend')(); - await Promise.resolve(); - expect(mx.sendEvent).toHaveBeenCalledTimes(1); - expect(mx.sendEvent).toHaveBeenCalledWith( - '!r:x', - 'm.room.message', - { body: 'did not go' }, - 'bad' - ); - // still in the outbox, pending again under the same id, until the server confirms it - expect(loadOutbox('@a:x').find((e) => e.txnId === 'bad')).toMatchObject({ status: 'pending' }); - }); -}); diff --git a/src/app/features/room/RoomOutbox.render.test.tsx b/src/app/features/room/RoomOutbox.render.test.tsx deleted file mode 100644 index e4c258df..00000000 --- a/src/app/features/room/RoomOutbox.render.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { enqueueOutbox, failOutbox } from '../../state/room/composerStore'; -import { RoomOutbox } from './RoomOutbox'; - -// What the person sees under the timeline for sends the server has not confirmed. -const text = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' '); - -describe('RoomOutbox as rendered', () => { - beforeEach(() => { - const m = new Map(); - vi.stubGlobal('localStorage', { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - }); - }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - const mx = { getUserId: () => '@a:x' } as never; - const entry = (txnId: string, roomId: string, body: string) => ({ - txnId, - roomId, - content: { body }, - }); - - it('renders nothing when the room has no unconfirmed sends', () => { - enqueueOutbox('@a:x', entry('t1', '!other:x', 'elsewhere')); - expect(renderToStaticMarkup()).toBe(''); - }); - - it('shows a fresh send as Sending with its text and no Resend', () => { - enqueueOutbox('@a:x', entry('t1', '!r:x', 'on its way'), Date.now() + 1000); - const html = renderToStaticMarkup(); - expect(html).toContain('data-outbox-status="pending"'); - expect(text(html)).toContain('Sending…'); - expect(text(html)).toContain('on its way'); - expect(text(html)).not.toContain('Resend'); - }); - - it('shows a failed send with its error, Resend and Discard', () => { - enqueueOutbox('@a:x', entry('t2', '!r:x', 'did not go')); - failOutbox('@a:x', 't2', 'ECONNRESET'); - const html = renderToStaticMarkup(); - expect(html).toContain('data-outbox-status="failed"'); - expect(text(html)).toContain('Not sent: ECONNRESET'); - expect(text(html)).toContain('Resend'); - expect(text(html)).toContain('Discard'); - }); - - it('shows a send left over from before this page load as interrupted, resendable', () => { - enqueueOutbox('@a:x', entry('t3', '!r:x', 'lost to a reload'), 5); - const html = renderToStaticMarkup(); - expect(text(html)).toContain('Interrupted by a reload'); - expect(text(html)).toContain('Resend'); - }); -}); diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts deleted file mode 100644 index 30d7c0bb..00000000 --- a/src/app/features/room/RoomOutbox.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SLOW_SEND_MS, outboxView } from './RoomOutbox'; - -describe('outboxView', () => { - it('walks sending -> still sending (resendable) -> failed', () => { - expect(outboxView({ status: 'pending', ts: 100 }, 100, 0)).toMatchObject({ - label: 'Sending…', - canResend: false, - }); - expect(outboxView({ status: 'pending', ts: 100 }, 100 + SLOW_SEND_MS, 0)).toMatchObject({ - label: expect.stringMatching(/^Still sending/), - canResend: true, - }); - expect(outboxView({ status: 'failed', ts: 100, error: 'x' }, 100, 0)).toMatchObject({ - failed: true, - canResend: true, - }); - }); - - it('offers Resend to a pending send at 30 s and not a millisecond before', () => { - const at = (ms: number) => outboxView({ status: 'pending', ts: 100 }, 100 + ms, 0).canResend; - expect([at(29_999), at(30_000)]).toEqual([false, true]); - }); - - it('an entry older than this page load reads as interrupted', () => { - expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/); - }); -}); diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx deleted file mode 100644 index 1e77df43..00000000 --- a/src/app/features/room/RoomOutbox.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { MatrixClient } from 'matrix-js-sdk'; -import { Box, Chip, Text, color, config } from 'folds'; -import { - OutboxEntry, - loadOutbox, - removeOutbox, - subscribeOutbox, -} from '../../state/room/composerStore'; -import { PAGE_START, resendTracked } from '../../state/room/outboxSend'; - -/** A send still unconfirmed after this long is offered Resend: the request may be hung. */ -export const SLOW_SEND_MS = 30_000; - -export type OutboxView = { label: string; failed: boolean; canResend: boolean }; - -export const outboxView = ( - entry: Pick, - now: number, - since: number = PAGE_START -): OutboxView => { - if (entry.status === 'failed') { - return { - label: `Not sent${entry.error ? `: ${entry.error}` : ''}`, - failed: true, - canResend: true, - }; - } - if (entry.ts < since) { - return { - label: 'Interrupted by a reload: may not have been sent', - failed: false, - canResend: true, - }; - } - if (now - entry.ts >= SLOW_SEND_MS) { - return { - label: 'Still sending: slow connection (Resend may duplicate it if it lands later)', - failed: false, - canResend: true, - }; - } - return { label: 'Sending…', failed: false, canResend: false }; -}; - -export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => { - const read = () => loadOutbox(userId).filter((e) => e.roomId === roomId); - const [entries, setEntries] = useState(read); - useEffect(() => { - setEntries(read()); - return subscribeOutbox(() => setEntries(read())); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [userId, roomId]); - return entries; -}; - -/** Re-render every few seconds while anything is waiting, so a slow send turns resendable. */ -const useNow = (active: boolean): number => { - const [now, setNow] = useState(Date.now()); - useEffect(() => { - if (!active) return undefined; - setNow(Date.now()); - const id = setInterval(() => setNow(Date.now()), 5000); - return () => clearInterval(id); - }, [active]); - return now; -}; - -type RoomOutboxProps = { mx: MatrixClient; roomId: string }; - -/** - * Messages this client sent that the server has not confirmed: sending, slow, interrupted by a - * reload, or failed, each with Resend where one makes sense and Discard. - */ -export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { - const userId = mx.getUserId() ?? ''; - const entries = useRoomOutbox(userId, roomId); - const now = useNow(entries.length > 0); - if (entries.length === 0) return null; - - return ( - - {entries.map((entry) => { - const view = outboxView(entry, now); - return ( - - - - {view.label} - - - {String(entry.content.body ?? '')} - - - {view.canResend && ( - <> - resendTracked(mx, entry)}> - Resend - - removeOutbox(userId, entry.txnId)} - > - Discard - - - )} - - ); - })} - - ); -} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 9bd776f5..e9c13e2a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -47,7 +47,6 @@ import { import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useTranslation } from 'react-i18next'; -import { isUnconfirmedLocalEcho } from './localEcho'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; @@ -390,19 +389,6 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) }, [room, onArrive]); }; -/** A local echo changes status (or becomes its remote echo) without a Timeline event: redraw. */ -const useLocalEchoRefresh = (room: Room, onUpdate: () => void) => { - useEffect(() => { - const handle: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (_evt, r) => { - if (r.roomId === room.roomId) onUpdate(); - }; - room.on(RoomEvent.LocalEchoUpdated, handle); - return () => { - room.removeListener(RoomEvent.LocalEchoUpdated, handle); - }; - }, [room, onUpdate]); -}; - const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => { @@ -662,18 +648,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ) ); - useLocalEchoRefresh( - room, - useCallback(() => { - // the echo was hidden while unconfirmed, so its arrival scrolled to nothing: scroll now - if (atBottomRef.current) { - scrollToBottomRef.current.count += 1; - scrollToBottomRef.current.smooth = true; - } - setTimeline((ct) => ({ ...ct })); - }, []) - ); - const handleOpenEvent = useCallback( async ( evtId: string, @@ -1672,7 +1646,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli if (mEvent.isRedacted() && !showHiddenEvents) { return null; } - if (isUnconfirmedLocalEcho(mEvent)) return null; if (!newDivider && readUptoEventIdRef.current) { newDivider = prevEvent?.getId() === readUptoEventIdRef.current; diff --git a/src/app/features/room/RoomTrainingLine.tsx b/src/app/features/room/RoomTrainingLine.tsx index 4cddfc75..fd410979 100644 --- a/src/app/features/room/RoomTrainingLine.tsx +++ b/src/app/features/room/RoomTrainingLine.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { useStateEvent } from '../../hooks/useStateEvent'; import { useClientConfig } from '../../hooks/useClientConfig'; import { useSkyInk } from '../../hooks/useTheme'; -import { scrimStyle } from '../scene/skyInk'; +import { noticeInk } from '../scene/skyInk'; import { ROOM_NOTICE_EVENT_TYPE, parseRoomNotice, @@ -24,8 +24,7 @@ export function RoomTrainingLine({ room }: { room: Room }) { size="T200" style={{ padding: `${config.space.S100} 0`, - color: color.SurfaceVariant.OnContainer, - ...scrimStyle(skyInk), + color: (skyInk && noticeInk(skyInk)) ?? color.SurfaceVariant.OnContainer, }} > {line} diff --git a/src/app/features/room/RoomViewHeader.css.ts b/src/app/features/room/RoomViewHeader.css.ts index e342c80c..cff21a99 100644 --- a/src/app/features/room/RoomViewHeader.css.ts +++ b/src/app/features/room/RoomViewHeader.css.ts @@ -2,14 +2,15 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; -// Phone tap target: the topic is an 18 px line directly under the room name (no gap), so its button grows only -// downward, 26 px (44 px to tap) and the negative margin keeps the layout where it was: growing up would take taps -// from the name line. +// Phone tap target: the topic is an 18 px line under the room name; its button grows 9 px up and 17 px down +// (44 px to tap; more below because the name is above) and the negative margin keeps the layout where it was. export const HeaderTopic = style({ '@media': { [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { - paddingBottom: 26, - marginBottom: -26, + paddingTop: 9, + paddingBottom: 17, + marginTop: -9, + marginBottom: -17, }, }, ':hover': { diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 3132c77a..8089b5ef 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -72,7 +72,6 @@ import { RoomSettingsPage } from '../../state/roomSettings'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { webRTCSupported } from '../../utils/rtc'; -import { PhoneHitSpread40 } from '../../styles/PhoneHit.css'; type RoomMenuProps = { room: Room; @@ -337,7 +336,6 @@ function CallButton() { > {(triggerRef) => ( {(onBack) => ( - + @@ -533,12 +531,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { } > {(triggerRef) => ( - + )} @@ -555,7 +548,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( {(triggerRef) => ( - + )} @@ -644,7 +631,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( ( )} - + diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx index 4b231856..223c6cf6 100644 --- a/src/app/features/room/jump-to-time/JumpToTime.tsx +++ b/src/app/features/room/jump-to-time/JumpToTime.tsx @@ -31,7 +31,6 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../ import { DatePicker, TimePicker } from '../../../components/time-date'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type JumpToTimeProps = { onCancel: () => void; @@ -109,7 +108,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) { Jump to Time - + diff --git a/src/app/features/room/localEcho.test.ts b/src/app/features/room/localEcho.test.ts deleted file mode 100644 index fa44ac9b..00000000 --- a/src/app/features/room/localEcho.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { EventStatus } from 'matrix-js-sdk'; -import { isUnconfirmedLocalEcho } from './localEcho'; - -describe('isUnconfirmedLocalEcho', () => { - it.each([ - EventStatus.ENCRYPTING, - EventStatus.QUEUED, - EventStatus.SENDING, - EventStatus.NOT_SENT, - EventStatus.CANCELLED, - ])('hides %s', (status) => { - expect(isUnconfirmedLocalEcho({ status })).toBe(true); - }); - - it('shows a server-confirmed event and one the server has accepted (SENT)', () => { - expect(isUnconfirmedLocalEcho({ status: null })).toBe(false); - expect(isUnconfirmedLocalEcho({ status: EventStatus.SENT })).toBe(false); - }); -}); diff --git a/src/app/features/room/localEcho.ts b/src/app/features/room/localEcho.ts deleted file mode 100644 index 4f827500..00000000 --- a/src/app/features/room/localEcho.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { EventStatus, MatrixEvent } from 'matrix-js-sdk'; - -/** - * A local echo the server has not accepted: the SDK adds it to the live timeline the moment - * sendMessage is called, marks it NOT_SENT on failure, and drops it on reload. Rendered like - * any message it looks delivered and then vanishes. The outbox (RoomOutbox) is the one place - * that shows unconfirmed sends, so the timeline hides these until the status is SENT. - */ -export const isUnconfirmedLocalEcho = (mEvent: Pick): boolean => - mEvent.status !== null && mEvent.status !== undefined && mEvent.status !== EventStatus.SENT; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index a1bc17c9..4b3d436f 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -86,7 +86,6 @@ import colorMXID from '../../../../util/colorMXID'; import { useSkyInk } from '../../../hooks/useTheme'; import { userColorInk } from '../../scene/skyInk'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; -import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -112,7 +111,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( {recentEmojis.map((emoji) => ( Delete Message - + @@ -595,12 +589,7 @@ export const MessageReportItem = as< Report Message - + @@ -961,7 +950,6 @@ export const Message = as<'div', MessageProps>( } > ( )} ( {!isThreadedMessage && ( onReplyClick(ev, true)} data-event-id={mEvent.getId()} variant="SurfaceVariant" @@ -996,7 +982,6 @@ export const Message = as<'div', MessageProps>( )} {canEditEvent(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} variant="SurfaceVariant" size="300" @@ -1162,7 +1147,6 @@ export const Message = as<'div', MessageProps>( } > ( } > ( ( } > diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx index b1a674d9..6c686bc5 100644 --- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx +++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx @@ -28,7 +28,6 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../../hooks/useSpace'; import { getMouseEventCords } from '../../../utils/dom'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; export type ReactionViewerProps = { room: Room; @@ -101,7 +100,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( {`Reacted with :${selectedShortcode}:`} - + diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 25fe835f..9986849f 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -86,7 +86,6 @@ import { useGetMemberPowerTag, } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; -import { PhoneHit, PhoneHitSpread26 } from '../../../styles/PhoneHit.css'; type PinnedMessageProps = { room: Room; @@ -147,7 +146,6 @@ function PinnedMessage({ {canPinEvent && ( ( Pinned Messages - + diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index ee973822..30efaa1c 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -15,12 +15,7 @@ import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../../utils/keyboard'; import { useSkyState } from '../../hooks/useSkyState'; import { SKY_STATES, setSkyState, type SkyState } from './skyState'; -import { - HORIZON_CREDIT, - WEATHER_CREDIT, - WEATHER_CREDIT_URL, - WEATHER_DATASET, -} from './ridge/credits'; +import { HORIZON_CREDIT, WEATHER_CREDIT, WEATHER_CREDIT_URL, WEATHER_DATASET } from './ridge/credits'; import { useClientConfig } from '../../hooks/useClientConfig'; import { getSceneStyleOverride, @@ -28,7 +23,6 @@ import { setSceneStyle, type ServedSceneStyle, } from './sceneStyle'; -import { PhoneHit } from '../../styles/PhoneHit.css'; const STYLE_LABEL: Record = { terrain: 'Terrain', ridge: 'Ridges' }; @@ -124,11 +118,7 @@ export function SkyStateControl() { )} {backgroundScene && sceneStyle === 'ridge' && ( - + Data @@ -150,7 +140,6 @@ export function SkyStateControl() { } > { it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => { expect(creatureScale({ neutral: true })).toBeNull(); - buckets.forEach((sizeBucket) => - expect(creatureScale({ sizeBucket, neutral: true })).toBeNull() - ); + buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()); }); it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index d88ce4b5..a0b2bd29 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -33,7 +33,5 @@ export const BUCKET_SCALE: Readonly> = { /** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ export function creatureScale(c: Pick): number | null { if (c.neutral || c.sizeBucket === undefined) return null; - return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) - ? BUCKET_SCALE[c.sizeBucket] - : null; + return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; } diff --git a/src/app/features/scene/cssModules.test.ts b/src/app/features/scene/cssModules.test.ts index 7205b822..27fd4d4d 100644 --- a/src/app/features/scene/cssModules.test.ts +++ b/src/app/features/scene/cssModules.test.ts @@ -13,8 +13,6 @@ describe('scene .css.ts files, compiled as the production build compiles them', it.each(FILES)('%s compiles with exports vanilla-extract accepts', async (file) => { const filePath = `${process.cwd()}/${file}`; const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - await expect( - processVanillaFile({ source, filePath, identOption: 'debug' }) - ).resolves.toBeTypeOf('string'); + await expect(processVanillaFile({ source, filePath, identOption: 'debug' })).resolves.toBeTypeOf('string'); }); }); diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index cb24f04b..0234e558 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -818,12 +818,8 @@ describe('endless drift: chunks baked on demand and recycled', () => { }); describe('rain in the scene', () => { - const rainy = (w: number): SkyColours => ({ - ...DAY, - physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w), - }); - const rainEls = (parent: any) => - parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent)); + const rainy = (w: number): SkyColours => ({ ...DAY, physical: makePhysicalSky('day', new Date('2026-06-21T10:40:00Z'), w) }); + const rainEls = (parent: any) => parent.children.filter((c: any) => c.tag === 'div' && c !== rigOf(parent)); it('lays one falling tile per rain layer over the ridges when it rains, each slid by its own height on the compositor', () => { const { parent, canvas } = host(); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index b0320b1b..8590a833 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -6,15 +6,7 @@ import { skyColours, SkyColours, Srgb } from './skyColour'; import { paintSky as paintPhysicalSky } from './physicalSky'; import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; import { bakeSlice, BakeParams, layerPaint } from './ridgeBake'; -import { - LAYERS, - LayerSpec, - edgeBlur, - HORIZON_TRACK_SHIFT, - layerSize, - scatterAlpha, - transmittance, -} from './ridgeWorld'; +import { LAYERS, LayerSpec, edgeBlur, HORIZON_TRACK_SHIFT, layerSize, scatterAlpha, transmittance } from './ridgeWorld'; import { hfovDeg } from './horizon'; import { viewFor, VFOV_DEG } from './skyDome'; import { drawRainTile, RAIN_TILES } from './rain'; @@ -479,13 +471,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { * The rain over everything: per tile, a div of the tile's texture (drawn once) taller than the viewport by one tile, slid down one * tile height per loop on the compositor. 'still' draws none (a settled still has no animation to carry it). */ - const startRain = ( - rain: number, - colour: Srgb, - w: number, - h: number, - scale: number - ): NonNullable => { + const startRain = (rain: number, colour: Srgb, w: number, h: number, scale: number): NonNullable => { const els: HTMLElement[] = []; const anims: (Animation | undefined)[] = []; RAIN_TILES.forEach((t, k) => { @@ -512,14 +498,11 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { parent.appendChild(el); els.push(el); anims.push( - el.animate?.( - [{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], - { - duration: t.seconds * 1000, - iterations: Infinity, - easing: 'linear', - } - ) + el.animate?.([{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(0,${px}px,0)` }], { + duration: t.seconds * 1000, + iterations: Infinity, + easing: 'linear', + }) ); }); return { els, anims }; @@ -623,8 +606,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { parent.appendChild(rig); layers.forEach((l) => startDrift(l, rotate)); built.sway = startSway(rig, w, h); - if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) - built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale); + if (tier !== 'still' && sky.physical && sky.physical.weather.rain > 0) built.rain = startRain(sky.physical.weather.rain, sky.horizon, w, h, scale); pump(); syncPlayState(); return true; @@ -658,9 +640,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { }; function syncPlayState() { - const anims = built - ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] - : []; + const anims = built ? [built.sway, ...built.layers.map((l) => l.anim), ...(built.rain?.anims ?? [])] : []; anims.forEach((a) => (moving() ? a?.play() : a?.pause())); if (!built?.twinkle) return; if (moving()) { diff --git a/src/app/features/scene/ridge/credits.test.ts b/src/app/features/scene/ridge/credits.test.ts index 887cc2d4..633a1b33 100644 --- a/src/app/features/scene/ridge/credits.test.ts +++ b/src/app/features/scene/ridge/credits.test.ts @@ -4,7 +4,7 @@ import { HORIZON_ATTRIBUTION } from './horizon'; import { WEATHER_ATTRIBUTION } from './weatherFeed'; describe('the credits the Sky menu shows', () => { - it("are the sources' own", () => { + it('are the sources\' own', () => { expect(HORIZON_CREDIT).toBe(HORIZON_ATTRIBUTION); expect(WEATHER_CREDIT).toBe(WEATHER_ATTRIBUTION); }); diff --git a/src/app/features/scene/ridge/earthAtmosphere.ts b/src/app/features/scene/ridge/earthAtmosphere.ts index cadddbc5..f5350244 100644 --- a/src/app/features/scene/ridge/earthAtmosphere.ts +++ b/src/app/features/scene/ridge/earthAtmosphere.ts @@ -362,8 +362,7 @@ function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 { const sx = Math.sqrt(Math.max(0, 1 - cosSun * cosSun)); const sz = cosSun; // Hillaire's PLANET_RADIUS_OFFSET: a ray that starts exactly on the ground is degenerate. - const height = - PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM); + const height = PLANET_KM + RADIUS_OFFSET_KM + clamp(v, 0, 1) * (TOP_KM - PLANET_KM - RADIUS_OFFSET_KM); const sumMs = [0, 0, 0]; const sumL = [0, 0, 0]; for (let a = 0; a < dirs; a++) { @@ -411,11 +410,7 @@ function bakeMultiScatter(mieScale: number, trLut: Lut2): Lut2 { * Sky-view LUT axes (Hillaire's non-linear mapping, upper hemisphere only: nothing below the horizon is drawn). y: view zenith angle * from 0 to the horizon's, squared toward it; x: the azimuth from the sun, the cosine mapped `-(2 c^2 - 1)`. */ -function skyViewParamsToUv( - viewCos: number, - cosFromSun: number, - horizonZenith: number -): [number, number] { +function skyViewParamsToUv(viewCos: number, cosFromSun: number, horizonZenith: number): [number, number] { const zen = Math.acos(clamp(viewCos, -1, 1)); let coord = 1 - clamp(zen / horizonZenith, 0, 1); coord = Math.sqrt(coord); @@ -499,14 +494,7 @@ export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere { const horizonZenith = PI - beta; const transmittance = bakeTransmittance(mieScale); const multiScatter = bakeMultiScatter(mieScale, transmittance); - const skyView = bakeSkyView( - sunCos, - observerKm, - mieScale, - transmittance, - multiScatter, - horizonZenith - ); + const skyView = bakeSkyView(sunCos, observerKm, mieScale, transmittance, multiScatter, horizonZenith); const tmp = [0, 0, 0]; const trv = [0, 0, 0]; const atm: Atmosphere = { @@ -526,11 +514,7 @@ export function bakeAtmosphere(opts: AtmosphereOptions): Atmosphere { out[2] = trv[2]; }, radiance(viewCos, cosFromSun, out) { - const [x, y] = skyViewParamsToUv( - Math.max(viewCos, Math.cos(horizonZenith)), - cosFromSun, - horizonZenith - ); + const [x, y] = skyViewParamsToUv(Math.max(viewCos, Math.cos(horizonZenith)), cosFromSun, horizonZenith); sampleTexel(skyView, x * (skyView.w - 1), y * (skyView.h - 1), tmp); out[0] = tmp[0]; out[1] = tmp[1]; diff --git a/src/app/features/scene/ridge/horizon.test.ts b/src/app/features/scene/ridge/horizon.test.ts index 0061bab1..092d5835 100644 --- a/src/app/features/scene/ridge/horizon.test.ts +++ b/src/app/features/scene/ridge/horizon.test.ts @@ -21,10 +21,7 @@ describe('the baked horizon', () => { expect(horizonDistanceKm(305)).toBeGreaterThan(1); }); it('interpolates between samples and returns unit normals', () => { - expect(horizonElevation(100.25)).toBeCloseTo( - (bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, - 6 - ); + expect(horizonElevation(100.25)).toBeCloseTo((bake.elevationDeg[200] + bake.elevationDeg[201]) / 2, 6); expect(Math.hypot(...horizonNormal(250))).toBeCloseTo(1, 6); }); }); diff --git a/src/app/features/scene/ridge/horizon.ts b/src/app/features/scene/ridge/horizon.ts index 89d896bc..82265a77 100644 --- a/src/app/features/scene/ridge/horizon.ts +++ b/src/app/features/scene/ridge/horizon.ts @@ -8,7 +8,7 @@ const N = bake.elevationDeg.length; /** Index pair and weight of an azimuth (degrees clockwise from north) on the baked ring. */ function at(azimuth: number): [number, number, number] { - const f = (((azimuth / bake.stepDeg) % N) + N) % N; + const f = ((((azimuth / bake.stepDeg) % N) + N) % N); const i = Math.floor(f); return [i, (i + 1) % N, f - i]; } diff --git a/src/app/features/scene/ridge/horizonLayer.test.ts b/src/app/features/scene/ridge/horizonLayer.test.ts index b034d03c..8ed27e4c 100644 --- a/src/app/features/scene/ridge/horizonLayer.test.ts +++ b/src/app/features/scene/ridge/horizonLayer.test.ts @@ -25,9 +25,7 @@ describe('the horizon layer', () => { }); it('stays in 0..1 and covers a real range of shape, not a line', () => { - const v = Array.from({ length: 200 }, (_, i) => - layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg }) - ); + const v = Array.from({ length: 200 }, (_, i) => layerProfile(far, -0.06 + (i / 200) * 1.2, { azimuthDeg: 305, hfovDeg: view.hfovDeg })); expect(Math.min(...v)).toBeGreaterThanOrEqual(0); expect(Math.max(...v)).toBeLessThanOrEqual(1); expect(Math.max(...v) - Math.min(...v)).toBeGreaterThan(0.2); @@ -45,7 +43,7 @@ describe('the horizon layer', () => { }); describe('weather in the layers', () => { - it("mist multiplies the layers' own, and clear air leaves it alone", () => { + it('mist multiplies the layers\' own, and clear air leaves it alone', () => { const base = skyColours(); const day = new Date('2026-06-21T10:40:00Z'); const clear = layerPaint(LAYERS[1], { ...base, physical: makePhysicalSky('day', day, 0) }); diff --git a/src/app/features/scene/ridge/physicalSky.test.ts b/src/app/features/scene/ridge/physicalSky.test.ts index be12945e..bb8c75a2 100644 --- a/src/app/features/scene/ridge/physicalSky.test.ts +++ b/src/app/features/scene/ridge/physicalSky.test.ts @@ -10,8 +10,7 @@ const lin = (v: number): number => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; -const luma = (d: Uint8ClampedArray, i: number): number => - 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]); +const luma = (d: Uint8ClampedArray, i: number): number => 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]); const DAYS = ['2026-03-20', '2026-06-21', '2026-09-23', '2026-12-21']; const WEATHER = [0, 0.3, 0.55, 0.75, 1]; @@ -22,31 +21,28 @@ const VIEWPORTS: [number, number][] = [ [390, 844], ]; -describe.each(['day', 'dusk', 'night'] as FixedSkyState[])( - 'the %s sky stays inside its backdrop bounds', - (state) => { - it('over weather, season and viewport shape', () => { - const b = skyBackdrop(state); - let lo = 2; - let hi = -1; - for (const day of DAYS) { - for (const w of WEATHER) { - const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w); - for (const [vw, vh] of VIEWPORTS) { - const px = paintSky(sky, vw, vh); - for (let i = 0; i < px.w * px.h * 4; i += 4) { - const l = luma(px.data, i); - lo = Math.min(lo, l); - hi = Math.max(hi, l); - } +describe.each(['day', 'dusk', 'night'] as FixedSkyState[])('the %s sky stays inside its backdrop bounds', (state) => { + it('over weather, season and viewport shape', () => { + const b = skyBackdrop(state); + let lo = 2; + let hi = -1; + for (const day of DAYS) { + for (const w of WEATHER) { + const sky = makePhysicalSky(state, new Date(`${day}T10:40:00Z`), w); + for (const [vw, vh] of VIEWPORTS) { + const px = paintSky(sky, vw, vh); + for (let i = 0; i < px.w * px.h * 4; i += 4) { + const l = luma(px.data, i); + lo = Math.min(lo, l); + hi = Math.max(hi, l); } } } - expect(lo).toBeGreaterThanOrEqual(b.darkest); - expect(hi).toBeLessThanOrEqual(b.lightest); - }); - } -); + } + expect(lo).toBeGreaterThanOrEqual(b.darkest); + expect(hi).toBeLessThanOrEqual(b.lightest); + }); +}); describe('weather in the sky', () => { it('overcast is cloud: less blue and less contrast than clear air at the same sun', () => { @@ -74,21 +70,14 @@ describe('weather in the sky', () => { describe('the sun disc', () => { it('keeps the display floor the dark link was fitted to, at every hour it is drawn', () => { - const lin = (c: number) => - c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4; + const lin = (c: number) => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4); let drawn = 0; for (const state of ['day', 'dusk'] as FixedSkyState[]) { for (let m = 0; m < 24 * 60; m += 10) { - const d = paintSky( - makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), - 1280, - 720 - ).sunDisc; + const d = paintSky(makePhysicalSky(state, new Date(Date.UTC(2026, 10, 11, 0, m)), 0), 1280, 720).sunDisc; if (!d) continue; drawn += 1; - expect( - 0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2]) - ).toBeGreaterThanOrEqual(0.4744); + expect(0.2126 * lin(d.rgb[0]) + 0.7152 * lin(d.rgb[1]) + 0.0722 * lin(d.rgb[2])).toBeGreaterThanOrEqual(0.4744); } } expect(drawn).toBeGreaterThan(0); diff --git a/src/app/features/scene/ridge/physicalSky.ts b/src/app/features/scene/ridge/physicalSky.ts index 0e034416..48a10a16 100644 --- a/src/app/features/scene/ridge/physicalSky.ts +++ b/src/app/features/scene/ridge/physicalSky.ts @@ -4,15 +4,7 @@ import type { FixedSkyState } from '../skyBand'; import { bakeAtmosphere } from './earthAtmosphere'; import type { Atmosphere } from './earthAtmosphere'; -import { - domeSize, - HORIZON_AT, - renderDome, - SUN_RADIUS, - sunOnScreen, - viewFor, - VFOV_DEG, -} from './skyDome'; +import { domeSize, HORIZON_AT, renderDome, SUN_RADIUS, sunOnScreen, viewFor, VFOV_DEG } from './skyDome'; import type { Dome, DomeView } from './skyDome'; import type { Srgb } from './skyColour'; import { display8, fitDisplay, holdBudget, MARGIN } from './toneMap'; @@ -53,11 +45,7 @@ export function rowMean(dome: Dome, display: Display, row: number): Srgb { return [Math.round(acc[0] / dome.w), Math.round(acc[1] / dome.w), Math.round(acc[2] / dome.w)]; } -export function makePhysicalSky( - state: FixedSkyState, - now: Date, - weatherValue: number -): PhysicalSky { +export function makePhysicalSky(state: FixedSkyState, now: Date, weatherValue: number): PhysicalSky { const t0 = clock(); const sun = sunForState(state, now); const weather = weatherOf(weatherValue); @@ -129,16 +117,8 @@ function sunDisc(p: PhysicalSky, view: DomeView): SkyPixels['sunDisc'] { const top = Math.max(...trans, 1e-9); const rgb = trans.map((t) => Math.round(255 * (t / top) ** 0.6)); // The disc sits on the sky, so it keeps the display floor the dark link was fitted to: mix toward white until it does. - const lin = (c: number): number => - c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4; - const luma = (c: number[]): number => - 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); - for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) - for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1); - return { - ...at, - rgb: rgb as unknown as Srgb, - radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, - alpha, - }; + const lin = (c: number): number => (c / 255 <= 0.04045 ? c / 255 / 12.92 : ((c / 255 + 0.055) / 1.055) ** 2.4); + const luma = (c: number[]): number => 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]); + for (let m = 0; luma(rgb) < DISC_FLOOR && m < 40; m += 1) for (let i = 0; i < 3; i += 1) rgb[i] = Math.round(rgb[i] + (255 - rgb[i]) * 0.1); + return { ...at, rgb: rgb as unknown as Srgb, radius: (SUN_RADIUS * 180) / Math.PI / VFOV_DEG, alpha }; } diff --git a/src/app/features/scene/ridge/rain.test.ts b/src/app/features/scene/ridge/rain.test.ts index 80ce2289..6575ea20 100644 --- a/src/app/features/scene/ridge/rain.test.ts +++ b/src/app/features/scene/ridge/rain.test.ts @@ -4,8 +4,7 @@ import { drawRainTile, RAIN_TILES, streakCount } from './rain'; const recorder = () => { const calls: string[] = []; const ctx = new Proxy({} as Record, { - get: (t, k: string) => - k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`), + get: (t, k: string) => (k in t ? t[k] : (...a: unknown[]) => calls.push(`${k}(${a.join(',')})`)), set: (t, k: string, v) => { t[k] = v; return true; @@ -28,8 +27,6 @@ describe('rain', () => { drawRainTile(a.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1); drawRainTile(b.ctx, RAIN_TILES[1], 1, [200, 210, 220], 1); expect(a.calls).toEqual(b.calls); - expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength( - streakCount(RAIN_TILES[1], 1) * 9 - ); + expect(a.calls.filter((c) => c.startsWith('stroke'))).toHaveLength(streakCount(RAIN_TILES[1], 1) * 9); }); }); diff --git a/src/app/features/scene/ridge/rain.ts b/src/app/features/scene/ridge/rain.ts index 2644ed96..699130cf 100644 --- a/src/app/features/scene/ridge/rain.ts +++ b/src/app/features/scene/ridge/rain.ts @@ -27,18 +27,10 @@ export const RAIN_TILES: readonly RainTile[] = [ export const RAIN_SLANT = 0.18; /** Streaks to draw at a rain amount 0..1 (none at 0). */ -export const streakCount = (t: RainTile, rain: number): number => - rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain)); +export const streakCount = (t: RainTile, rain: number): number => (rain <= 0 ? 0 : Math.max(4, Math.round(t.streaks * rain))); /** Draw one tile. The streaks wrap across the tile's edges so the tile repeats without a seam. */ -export function drawRainTile( - ctx: CanvasRenderingContext2D, - t: RainTile, - rain: number, - colour: Srgb, - scale: number, - seed = 0 -): void { +export function drawRainTile(ctx: CanvasRenderingContext2D, t: RainTile, rain: number, colour: Srgb, scale: number, seed = 0): void { const n = streakCount(t, rain); ctx.clearRect(0, 0, t.size, t.size); ctx.lineCap = 'round'; diff --git a/src/app/features/scene/ridge/ridgeBake.ts b/src/app/features/scene/ridge/ridgeBake.ts index 063c4e3e..72736d85 100644 --- a/src/app/features/scene/ridge/ridgeBake.ts +++ b/src/app/features/scene/ridge/ridgeBake.ts @@ -3,15 +3,7 @@ // slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze // gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast // by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone. -import { - gullyAt, - HorizonView, - LayerSpec, - layerProfile, - hazeColour, - layerColour, - transmittance, -} from './ridgeWorld'; +import { gullyAt, HorizonView, LayerSpec, layerProfile, hazeColour, layerColour, transmittance } from './ridgeWorld'; import type { SkyBand } from '../skyBand'; import { LUMA, SKY_BAND } from '../skyBand'; import type { SkyColours, Srgb } from './skyColour'; @@ -145,10 +137,7 @@ function crestRows(p: BakeParams, c0: number, c1: number): Float64Array { const amp = p.spec.relief * p.viewH; const lift = p.spec.base * p.viewH; for (let c = c0 - 1; c <= c1; c += 1) { - const top = Math.min( - p.bandH, - lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView) - ); + const top = Math.min(p.bandH, lift + amp * layerProfile(p.spec, (p.px0 + c) / p.period, p.horizonView)); out[c - c0 + 1] = p.bandH - top; } return out; diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts index c6c745c3..6bd8e3b1 100644 --- a/src/app/features/scene/ridge/ridgeWorld.ts +++ b/src/app/features/scene/ridge/ridgeWorld.ts @@ -47,6 +47,7 @@ export interface HorizonView { /** The static horizon layer's track sits this many viewport widths left of the screen, so the camera's sway never shows its edge. */ export const HORIZON_TRACK_SHIFT = 0.06; + /** Four layers: one function, four parameter rows. */ export const LAYERS: readonly LayerSpec[] = [ { @@ -224,13 +225,7 @@ export function layerSize(cssW: number, cssH: number, scale: number) { } /** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */ -export function ridgeY( - spec: LayerSpec, - t: number, - bandH: number, - viewH: number, - view?: HorizonView -): number { +export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number, view?: HorizonView): number { const topFromBottom = spec.base * viewH + spec.relief * viewH * layerProfile(spec, t, view); return bandH - Math.min(bandH, topFromBottom); } diff --git a/src/app/features/scene/ridge/toneMap.ts b/src/app/features/scene/ridge/toneMap.ts index 44bc9dbe..b91a4d6d 100644 --- a/src/app/features/scene/ridge/toneMap.ts +++ b/src/app/features/scene/ridge/toneMap.ts @@ -20,8 +20,7 @@ const OUT: readonly Rgb[] = [ [-0.10208, 1.10813, -0.00605], [-0.00327, -0.07276, 1.07602], ]; -const fit = (v: number): number => - (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081); +const fit = (v: number): number => (v * (v + 0.0245786) - 0.000090537) / (v * (0.983729 * v + 0.432951) + 0.238081); const encode = (l: number): number => { const c = Math.min(1, Math.max(0, l)); return 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); @@ -33,8 +32,7 @@ export function acesLinear(r: number, g: number, b: number, exposure: number, ou const y = g * exposure; const z = b * exposure; const p = [0, 1, 2].map((i) => fit(Math.max(0, IN[i][0] * x + IN[i][1] * y + IN[i][2] * z))); - for (let i = 0; i < 3; i += 1) - out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2])); + for (let i = 0; i < 3; i += 1) out[i] = Math.min(1, Math.max(0, OUT[i][0] * p[0] + OUT[i][1] * p[1] + OUT[i][2] * p[2])); } /** The 8-bit sRGB of linear radiance, exposed and tone-mapped. */ @@ -46,8 +44,7 @@ export function toneMap8(r: number, g: number, b: number, exposure: number, out: } /** WCAG relative luminance of linear display RGB. */ -export const displayLuma = (c: ArrayLike): number => - 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +export const displayLuma = (c: ArrayLike): number => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; /** What a sky state needs of its backdrop's display luminance, from the ink it wears (skyBand.ts, skyInk.ts). */ export interface Budget { @@ -93,13 +90,7 @@ const decode = (v: number): number => { return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; /** Display luminance (WCAG relative, from the encoded bytes) of a radiance through a display. */ -export function displayLumaOf( - r: number, - g: number, - b: number, - d: Display, - tmp: number[] = [0, 0, 0] -): number { +export function displayLumaOf(r: number, g: number, b: number, d: Display, tmp: number[] = [0, 0, 0]): number { display8(r, g, b, d, tmp); return displayLuma([decode(tmp[0]), decode(tmp[1]), decode(tmp[2])]); } @@ -122,9 +113,7 @@ export interface Fit { export function fitDisplay(state: FixedSkyState, hdr: Float32Array, meanLuma: number): Fit { const b = BUDGET[state]; const keyExposure = - b.key === undefined - ? b.min - : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9))); + b.key === undefined ? b.min : Math.min(b.max, Math.max(b.min, (b.key * 1.6) / Math.max(meanLuma, 1e-9))); const n = hdr.length / 3; const stride = Math.max(1, Math.floor(n / 4000)); const tmp = [0, 0, 0]; @@ -198,10 +187,7 @@ export function holdBudget(state: FixedSkyState, hdr: Float32Array, d: Display): r = range(cur); } for (let k = 0; k < 8 && r.lo < b.floor; k += 1) { - cur = { - exposure: cur.exposure, - lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4, - }; + cur = { exposure: cur.exposure, lift: cur.lift + ((b.floor - r.lo) / (1 - r.lo)) * 1.05 + 1e-4 }; r = range(cur); } return cur; diff --git a/src/app/features/scene/ridge/vienna.test.ts b/src/app/features/scene/ridge/vienna.test.ts index 4f26303c..e0d4a4c7 100644 --- a/src/app/features/scene/ridge/vienna.test.ts +++ b/src/app/features/scene/ridge/vienna.test.ts @@ -31,8 +31,7 @@ describe('the sun over Vienna', () => { it('has a short day in December and a long one in June: published 8 h 4 min and 16 h 0 min with refraction, which adds about 6 minutes each end the ephemeris leaves out', () => { const daylight = (day: string) => { let n = 0; - for (let m = 0; m < 24 * 60; m += 1) - if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1; + for (let m = 0; m < 24 * 60; m += 1) if (viennaSun(new Date(Date.parse(day) + m * 60_000)).elevation > 0) n += 1; return n; }; expect(Math.abs(daylight('2026-12-21T00:00:00Z') - (8 * 60 + 4))).toBeLessThan(15); diff --git a/src/app/features/scene/ridge/vienna.ts b/src/app/features/scene/ridge/vienna.ts index f21082e5..56188226 100644 --- a/src/app/features/scene/ridge/vienna.ts +++ b/src/app/features/scene/ridge/vienna.ts @@ -33,8 +33,7 @@ export const STATE_ELEVATION: Record = night: [-90, -DAY_FROM], }; -const startOfUtcDay = (t: Date): number => - Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN; +const startOfUtcDay = (t: Date): number => Math.floor(t.getTime() / (24 * 60 * MIN)) * 24 * 60 * MIN; /** The moment of the day (UTC day of `t`, every 5 minutes) the sun is highest over Vienna: solar noon to the step. */ export function solarNoon(t: Date): Date { diff --git a/src/app/features/scene/ridge/viennaHorizon.json b/src/app/features/scene/ridge/viennaHorizon.json index adbc289a..62a4a126 100644 --- a/src/app/features/scene/ridge/viennaHorizon.json +++ b/src/app/features/scene/ridge/viennaHorizon.json @@ -1,306 +1 @@ -{ - "viewpoint": { "lat": 48.1929826, "lon": 16.3534237, "groundM": 183.3 }, - "stepDeg": 0.5, - "radiusKm": 60.0, - "minKm": 1.0, - "curvature": "drop d^2/2R*(1-0.13), R=6371000 m", - "source": { - "dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid", - "tiles": [ - "N47_00_E015_00", - "N47_00_E016_00", - "N47_00_E017_00", - "N48_00_E015_00", - "N48_00_E016_00", - "N48_00_E017_00", - "N49_00_E015_00", - "N49_00_E016_00", - "N49_00_E017_00" - ], - "tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed", - "url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)", - "licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty (https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)", - "attribution": "Contains modified Copernicus data: DEM \u00a9 DLR e.V. 2010-2014 and \u00a9 Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.", - "bakedBy": "scripts/bake-horizon.py" - }, - "elevationDeg": [ - 1.608, 1.522, 1.467, 1.452, 1.433, 1.426, 1.497, 1.6, 1.597, 1.564, 1.532, 1.495, 1.467, 1.544, - 1.62, 1.655, 1.707, 1.775, 1.803, 1.683, 1.532, 1.394, 1.263, 1.297, 1.358, 1.412, 1.433, 1.339, - 1.264, 1.269, 1.195, 1.142, 1.13, 1.22, 1.401, 1.368, 1.326, 1.294, 1.378, 1.46, 1.488, 1.516, - 1.549, 1.598, 1.647, 1.698, 1.769, 1.816, 1.801, 1.783, 1.695, 1.619, 1.586, 1.547, 1.442, - 1.335, 1.282, 1.286, 1.335, 1.421, 1.449, 1.437, 1.313, 1.268, 1.164, 1.14, 1.098, 1.06, 0.984, - 0.938, 0.91, 0.877, 0.884, 0.872, 0.943, 0.974, 0.919, 0.873, 0.861, 0.839, 0.731, 0.673, 0.56, - 0.494, 0.527, 0.601, 0.566, 0.584, 0.579, 0.643, 0.666, 0.741, 0.851, 0.711, 0.703, 0.573, 0.54, - 0.488, 0.433, 0.452, 0.467, 0.483, 0.475, 0.517, 0.419, 0.428, 0.457, 0.489, 0.522, 0.482, 0.5, - 0.527, 0.528, 0.471, 0.458, 0.487, 0.521, 0.578, 0.553, 0.419, 0.301, 0.324, 0.425, 0.468, - 0.512, 0.558, 0.575, 0.521, 0.477, 0.444, 0.372, 0.297, 0.365, 0.446, 0.486, 0.523, 0.532, - 0.516, 0.516, 0.531, 0.52, 0.546, 0.565, 0.535, 0.527, 0.508, 0.516, 0.37, 0.359, 0.33, 0.375, - 0.454, 0.546, 0.496, 0.449, 0.425, 0.447, 0.398, 0.388, 0.439, 0.476, 0.496, 0.517, 0.541, - 0.494, 0.439, 0.383, 0.411, 0.502, 0.621, 0.578, 0.548, 0.568, 0.642, 0.713, 0.708, 0.654, - 0.704, 0.741, 0.709, 0.677, 0.661, 0.66, 0.666, 0.662, 0.651, 0.699, 0.727, 0.746, 0.809, 0.788, - 0.799, 0.793, 0.702, 0.734, 0.79, 0.754, 0.704, 0.616, 0.598, 0.708, 0.719, 0.706, 0.704, 0.726, - 0.826, 0.849, 0.85, 0.813, 0.853, 0.926, 0.971, 0.918, 1.003, 1.0, 0.927, 0.985, 1.031, 1.043, - 1.067, 1.13, 1.022, 1.02, 1.043, 1.101, 1.144, 1.099, 1.082, 1.064, 1.2, 1.238, 1.16, 1.084, - 1.202, 1.326, 1.39, 1.429, 1.405, 1.332, 1.358, 1.361, 1.357, 1.354, 1.335, 1.319, 1.376, 1.309, - 1.289, 1.257, 1.283, 1.237, 1.296, 1.353, 1.259, 1.314, 1.236, 1.307, 1.344, 1.36, 1.251, 1.292, - 1.308, 1.26, 1.288, 1.365, 1.391, 1.414, 1.32, 1.299, 1.35, 1.288, 1.361, 1.303, 1.268, 1.29, - 1.305, 1.326, 1.344, 1.328, 1.32, 1.358, 1.375, 1.443, 1.477, 1.492, 1.525, 1.513, 1.422, 1.314, - 1.294, 1.337, 1.414, 1.343, 1.397, 1.431, 1.409, 1.378, 1.423, 1.438, 1.343, 1.257, 1.208, - 1.209, 1.233, 1.251, 1.267, 1.274, 1.248, 1.254, 1.297, 1.34, 1.359, 1.34, 1.199, 1.162, 1.255, - 1.292, 1.29, 1.24, 1.222, 1.24, 1.271, 1.264, 1.317, 1.256, 1.26, 1.321, 1.301, 1.266, 1.268, - 1.308, 1.357, 1.474, 1.419, 1.438, 1.393, 1.354, 1.343, 1.354, 1.333, 1.321, 1.359, 1.437, 1.47, - 1.395, 1.413, 1.446, 1.466, 1.434, 1.446, 1.458, 1.419, 1.522, 1.502, 1.506, 1.512, 1.528, - 1.526, 1.485, 1.427, 1.508, 1.5, 1.48, 1.534, 1.514, 1.449, 1.449, 1.386, 1.368, 1.388, 1.404, - 1.374, 1.374, 1.352, 1.3, 1.347, 1.332, 1.265, 1.235, 1.39, 1.285, 1.226, 1.233, 1.235, 1.211, - 1.164, 1.183, 1.217, 1.143, 1.128, 1.117, 1.112, 1.086, 1.071, 1.118, 1.103, 1.002, 0.99, 0.995, - 1.025, 0.998, 1.024, 0.971, 1.045, 1.09, 1.11, 1.108, 1.081, 1.171, 1.308, 1.446, 1.482, 1.484, - 1.494, 1.469, 1.472, 1.479, 1.493, 1.467, 1.388, 1.293, 1.203, 1.104, 1.145, 1.12, 1.028, 1.004, - 0.982, 0.968, 0.933, 0.97, 0.986, 1.035, 1.12, 1.227, 1.281, 1.316, 1.312, 1.273, 1.187, 1.205, - 1.197, 1.199, 1.176, 1.177, 1.142, 1.19, 1.377, 1.369, 1.322, 1.295, 1.385, 1.493, 1.576, 1.679, - 1.769, 1.622, 1.711, 1.641, 1.517, 1.582, 1.525, 1.39, 1.389, 1.343, 1.331, 1.369, 1.343, 1.257, - 1.212, 1.134, 1.039, 1.03, 1.075, 1.027, 1.059, 1.112, 1.132, 1.14, 1.16, 1.167, 1.178, 1.179, - 1.192, 1.214, 1.245, 1.289, 1.286, 1.279, 1.259, 1.256, 1.202, 1.138, 1.159, 1.116, 1.12, 1.102, - 0.999, 1.042, 1.051, 1.09, 1.143, 1.244, 1.269, 1.296, 1.353, 1.412, 1.579, 1.686, 1.814, 1.857, - 1.773, 1.731, 1.748, 1.742, 1.668, 1.594, 1.628, 1.692, 1.76, 1.822, 1.863, 1.904, 1.895, 1.849, - 1.786, 1.778, 1.731, 1.666, 1.633, 1.561, 1.526, 1.662, 1.747, 1.75, 1.704, 1.631, 1.563, 1.473, - 1.501, 1.488, 1.464, 1.441, 1.386, 1.31, 1.298, 1.293, 1.306, 1.371, 1.42, 1.416, 1.417, 1.418, - 1.409, 1.399, 1.408, 1.431, 1.434, 1.437, 1.509, 1.544, 1.58, 1.674, 1.528, 1.534, 1.517, 1.497, - 1.525, 1.546, 1.562, 1.58, 1.55, 1.634, 1.614, 1.66, 1.643, 1.663, 1.68, 1.829, 1.99, 2.058, - 2.014, 1.911, 2.025, 2.023, 2.036, 2.031, 2.009, 2.012, 2.02, 2.109, 2.107, 2.12, 2.171, 2.179, - 2.125, 2.084, 2.09, 2.129, 2.183, 2.16, 2.172, 2.239, 2.237, 2.218, 2.217, 2.137, 2.169, 2.203, - 2.233, 2.262, 2.272, 2.21, 2.133, 1.955, 1.909, 1.97, 2.138, 2.187, 2.229, 2.278, 2.191, 2.167, - 2.213, 2.327, 2.409, 2.31, 2.242, 2.205, 2.152, 2.154, 2.258, 2.217, 2.126, 2.054, 1.97, 1.942, - 2.022, 1.894, 1.886, 1.921, 1.897, 1.936, 1.964, 1.978, 1.961, 1.927, 1.879, 1.904, 1.96, 1.9, - 1.922, 2.023, 2.061, 2.115, 1.99, 1.805, 1.736, 1.776, 1.841, 1.863, 1.934, 1.942, 1.962, 2.043, - 2.082, 2.082, 2.092, 2.062, 1.957, 1.794, 1.821, 1.913, 2.009, 2.066, 2.105, 2.036, 1.964, - 1.984, 2.005, 2.036, 2.072, 2.094, 2.022, 2.153, 2.177, 2.146, 2.125, 2.061, 2.004, 1.955, - 1.902, 1.84, 1.767, 1.7, 1.696, 1.718, 1.725, 1.709, 1.745, 1.805, 1.836, 1.939, 2.053, 2.14, - 2.139, 2.142, 2.035, 1.902, 1.779, 1.667, 1.564, 1.57, 1.581, 1.628, 1.693, 1.739, 1.738, 1.738, - 1.675 - ], - "distanceKm": [ - 1.0, 1.0, 1.06, 1.06, 1.06, 1.06, 1.06, 1.03, 1.0, 1.0, 1.0, 1.0, 1.09, 1.09, 1.0, 1.09, 1.09, - 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.12, 1.12, 1.12, 1.12, 1.12, 1.12, 1.09, 1.09, 1.09, 1.09, - 1.0, 1.0, 1.0, 1.0, 1.03, 1.06, 1.06, 1.03, 1.03, 1.03, 1.03, 1.03, 1.03, 1.0, 1.0, 1.0, 1.0, - 1.0, 1.03, 1.03, 1.03, 1.03, 1.03, 1.0, 1.0, 1.06, 1.06, 1.06, 1.06, 1.06, 1.12, 1.0, 1.0, 1.24, - 1.24, 1.21, 1.21, 1.3, 1.3, 1.27, 1.3, 1.33, 1.33, 1.33, 1.33, 1.27, 1.24, 1.27, 1.27, 1.27, - 1.03, 1.03, 1.42, 1.84, 1.84, 1.81, 1.03, 1.72, 1.63, 1.63, 1.63, 1.33, 1.33, 1.06, 1.06, 1.72, - 1.72, 1.69, 1.69, 1.69, 1.69, 1.69, 1.81, 1.81, 1.75, 1.51, 1.75, 1.75, 1.75, 1.75, 1.75, 1.72, - 1.72, 1.72, 1.72, 1.72, 1.72, 1.72, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.42, - 1.42, 1.24, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.27, 1.03, 1.36, 1.36, 1.36, 1.36, 1.36, 1.45, - 1.45, 1.09, 1.12, 1.12, 1.54, 1.57, 1.48, 1.48, 1.48, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, - 1.0, 1.18, 1.18, 1.21, 1.21, 1.21, 1.12, 1.12, 1.12, 1.12, 1.12, 1.15, 1.15, 1.15, 1.15, 1.18, - 1.15, 1.15, 1.24, 1.24, 1.27, 1.27, 1.24, 1.24, 1.24, 1.27, 1.27, 1.27, 1.9, 1.9, 1.93, 1.93, - 1.93, 1.99, 1.99, 1.99, 1.99, 1.9, 2.02, 2.02, 2.02, 2.05, 2.02, 1.99, 1.99, 1.99, 1.48, 1.21, - 1.21, 1.57, 1.69, 1.69, 1.66, 1.39, 1.39, 1.39, 1.54, 1.6, 1.54, 1.54, 1.54, 1.48, 1.45, 1.21, - 1.21, 1.21, 1.27, 1.39, 1.39, 1.36, 1.39, 1.39, 1.39, 1.33, 1.33, 1.33, 1.33, 1.33, 1.48, 1.45, - 1.39, 1.39, 1.18, 1.18, 1.18, 1.48, 1.48, 1.27, 1.24, 1.27, 1.36, 1.36, 1.39, 1.33, 1.3, 1.3, - 1.3, 1.3, 1.3, 1.3, 1.33, 1.0, 1.42, 1.42, 1.27, 1.27, 1.27, 1.27, 1.03, 1.03, 1.03, 1.03, 1.03, - 1.03, 1.03, 1.03, 1.06, 1.06, 1.03, 1.03, 1.03, 1.36, 1.06, 1.06, 1.06, 1.06, 1.09, 1.09, 1.18, - 1.21, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.09, 1.15, 1.15, 1.36, 1.36, 1.36, - 1.36, 1.33, 1.33, 3.13, 1.21, 1.21, 3.34, 3.31, 2.83, 2.65, 2.65, 1.3, 2.29, 1.3, 1.15, 1.15, - 1.15, 2.29, 2.68, 2.41, 2.23, 2.23, 2.2, 1.12, 2.23, 2.17, 2.17, 2.17, 1.84, 2.74, 1.87, 1.87, - 2.95, 2.2, 2.17, 2.17, 2.35, 2.35, 2.23, 2.23, 2.11, 2.14, 2.08, 2.2, 2.2, 2.2, 2.2, 2.2, 2.2, - 2.41, 2.41, 2.38, 2.41, 2.41, 2.44, 2.44, 2.44, 1.99, 2.08, 2.08, 2.08, 2.08, 2.05, 2.05, 2.5, - 2.38, 2.47, 2.29, 2.23, 2.17, 1.69, 1.69, 2.38, 2.47, 2.38, 2.17, 2.71, 2.71, 2.2, 2.47, 2.41, - 2.41, 2.32, 2.38, 2.38, 2.08, 2.5, 2.11, 2.11, 1.75, 1.75, 1.48, 1.81, 1.81, 1.78, 1.78, 1.87, - 17.2, 17.2, 17.26, 17.32, 17.44, 18.1, 17.56, 17.5, 17.38, 17.5, 17.62, 17.68, 17.74, 17.8, - 14.86, 14.92, 14.98, 2.98, 26.38, 26.38, 26.38, 26.62, 26.14, 26.02, 26.02, 26.38, 26.26, 26.38, - 26.38, 26.26, 26.26, 26.26, 14.98, 14.98, 14.98, 14.44, 13.12, 13.18, 13.66, 13.72, 13.72, - 13.84, 13.84, 13.84, 13.72, 12.52, 12.58, 12.58, 12.28, 12.22, 12.28, 16.54, 14.02, 15.04, - 16.18, 16.24, 17.08, 17.26, 17.44, 17.5, 17.44, 17.32, 17.44, 17.02, 13.96, 14.14, 9.22, 9.16, - 9.1, 9.22, 9.28, 9.28, 9.22, 9.16, 9.16, 9.34, 8.68, 8.68, 8.62, 8.74, 8.86, 8.92, 8.86, 8.92, - 8.92, 11.74, 14.08, 11.98, 12.22, 12.04, 34.3, 10.36, 10.06, 9.64, 9.58, 9.58, 9.7, 9.7, 10.18, - 10.18, 10.18, 10.24, 10.06, 10.48, 9.94, 9.82, 9.76, 9.7, 8.02, 8.14, 8.14, 8.14, 8.08, 8.56, - 8.38, 8.38, 8.32, 8.38, 8.02, 8.02, 8.02, 8.14, 8.2, 7.9, 7.9, 7.84, 7.78, 7.66, 7.72, 7.72, - 7.72, 1.0, 1.0, 1.0, 1.0, 1.0, 1.45, 1.21, 1.21, 1.3, 1.15, 1.15, 1.15, 1.15, 1.15, 1.15, 1.15, - 1.33, 1.33, 1.33, 1.48, 1.3, 1.3, 1.3, 1.42, 1.09, 2.02, 1.81, 2.59, 2.59, 2.53, 1.21, 1.75, - 1.69, 1.66, 2.38, 1.57, 1.57, 1.57, 7.18, 7.18, 7.24, 7.24, 7.18, 7.12, 1.0, 1.0, 1.0, 1.0, 1.0, - 7.36, 7.3, 7.3, 7.18, 7.24, 7.06, 7.06, 7.06, 7.06, 7.0, 7.12, 7.12, 7.12, 7.0, 7.0, 7.0, 7.0, - 7.24, 7.24, 7.06, 7.18, 7.0, 7.06, 7.0, 7.0, 6.94, 7.0, 7.24, 1.03, 1.03, 1.03, 1.0, 1.0, 1.0, - 1.06, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.03, 1.03, 1.03, 1.03, 1.03, 1.03, 1.27, 1.27, 1.27, - 1.24, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.09, 1.09, 1.06, 1.06, 1.06, 1.06, 1.06, - 1.09, 1.09, 9.64, 9.58, 9.64, 9.64, 9.7, 9.7, 9.7, 9.7, 9.7, 9.64, 9.64, 9.64, 9.7, 1.03, 1.03, - 1.03, 1.03, 1.03, 1.03, 9.52, 1.0, 1.0, 9.4, 8.68, 8.68, 8.68, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, - 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 9.52, 9.46, 9.46, 9.46, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, - 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 - ], - "peakHeightM": [ - 211.4, 209.9, 210.5, 210.2, 209.9, 209.7, 211.0, 212.1, 211.2, 210.6, 210.1, 209.4, 211.3, - 212.7, 211.6, 214.8, 215.8, 217.1, 217.7, 215.4, 212.5, 209.9, 207.4, 208.7, 209.9, 211.0, - 211.4, 209.5, 208.1, 207.5, 206.1, 205.1, 204.8, 204.6, 207.8, 207.2, 206.5, 206.6, 208.8, - 210.4, 210.1, 210.6, 211.2, 212.1, 213.0, 213.9, 214.2, 215.0, 214.8, 214.5, 212.9, 212.5, - 211.9, 211.2, 209.3, 207.3, 205.7, 205.8, 208.1, 209.6, 210.2, 209.9, 207.6, 208.2, 203.7, - 203.2, 207.2, 206.3, 204.2, 203.2, 204.0, 203.3, 203.0, 203.2, 205.3, 206.0, 204.7, 203.7, - 202.5, 201.5, 199.6, 198.3, 195.8, 192.2, 192.8, 198.3, 201.7, 202.3, 201.8, 194.9, 203.5, - 204.5, 207.7, 203.7, 199.7, 196.7, 193.3, 192.4, 196.5, 197.1, 197.2, 197.7, 197.5, 198.7, - 195.8, 197.0, 197.9, 198.4, 197.2, 198.2, 198.7, 199.6, 199.6, 197.9, 197.2, 198.1, 199.1, - 200.8, 200.1, 196.0, 192.5, 189.0, 190.8, 191.5, 192.3, 193.1, 193.4, 192.4, 191.7, 191.1, - 189.8, 190.8, 192.5, 193.0, 194.4, 195.2, 195.5, 195.1, 195.1, 195.4, 195.2, 195.5, 193.5, - 196.1, 195.9, 195.5, 195.6, 192.2, 192.5, 191.8, 190.5, 192.2, 194.0, 196.8, 195.7, 194.4, - 195.0, 193.7, 190.1, 191.0, 191.7, 192.0, 192.4, 192.8, 192.0, 191.0, 190.0, 191.8, 193.7, - 196.5, 195.6, 194.9, 194.5, 195.9, 197.3, 197.2, 196.1, 197.5, 198.2, 197.6, 196.9, 197.0, - 196.6, 196.7, 197.7, 197.5, 198.9, 199.5, 199.5, 200.9, 200.4, 201.1, 201.0, 198.9, 207.9, - 209.7, 208.9, 207.2, 204.3, 204.3, 208.1, 208.5, 208.1, 206.9, 209.1, 212.7, 213.5, 214.0, - 212.2, 213.2, 215.7, 217.3, 207.1, 204.6, 204.5, 208.8, 212.5, 213.9, 213.7, 209.3, 210.8, - 208.2, 210.9, 212.6, 213.0, 214.2, 213.0, 211.4, 210.3, 208.7, 209.5, 207.9, 207.4, 212.6, - 215.6, 216.4, 218.1, 217.5, 215.7, 214.9, 215.0, 214.9, 214.8, 214.4, 217.5, 218.3, 215.2, - 214.7, 209.3, 209.8, 208.9, 216.9, 218.4, 211.3, 211.8, 210.8, 214.4, 215.3, 216.4, 212.4, - 212.7, 213.1, 212.0, 212.6, 214.4, 215.0, 216.2, 206.4, 215.6, 216.9, 211.9, 213.6, 212.3, - 211.5, 206.5, 206.8, 207.2, 207.5, 207.2, 207.1, 207.8, 208.1, 210.0, 210.7, 210.2, 210.8, - 210.5, 217.2, 207.7, 207.3, 208.1, 209.5, 208.9, 209.9, 212.8, 213.1, 209.6, 210.4, 210.7, - 208.9, 207.3, 206.3, 206.4, 206.8, 207.2, 207.5, 208.9, 208.4, 213.2, 214.2, 215.2, 215.7, - 214.5, 211.2, 247.4, 209.9, 210.7, 259.2, 255.7, 244.2, 241.1, 242.6, 212.1, 236.3, 211.9, - 208.7, 209.9, 209.5, 234.2, 243.1, 238.7, 236.5, 241.0, 238.1, 211.5, 237.8, 234.9, 234.5, - 234.9, 226.3, 246.9, 227.9, 230.4, 259.6, 237.2, 237.1, 238.4, 243.8, 242.5, 239.9, 240.4, - 235.8, 240.4, 238.1, 241.4, 241.7, 242.3, 242.2, 240.6, 238.4, 247.1, 246.8, 245.2, 248.2, - 247.4, 245.4, 245.4, 242.7, 231.1, 234.0, 234.5, 233.4, 233.5, 231.9, 230.1, 242.5, 239.0, - 238.2, 233.0, 237.7, 232.3, 219.6, 219.9, 235.0, 235.9, 232.0, 228.4, 241.4, 237.8, 226.9, - 231.8, 230.5, 229.4, 227.0, 230.1, 229.5, 219.9, 226.9, 220.2, 221.3, 214.0, 214.8, 208.5, - 216.5, 217.9, 218.0, 217.9, 218.8, 554.9, 596.3, 639.3, 651.8, 655.9, 677.7, 654.8, 653.7, - 652.5, 660.3, 655.7, 632.9, 605.1, 578.6, 484.8, 496.7, 491.5, 237.4, 693.3, 682.9, 676.3, - 665.1, 672.6, 677.4, 699.5, 746.5, 793.0, 820.8, 836.9, 831.7, 813.8, 774.3, 513.7, 511.5, - 512.0, 493.9, 464.6, 457.9, 479.7, 526.0, 524.0, 515.7, 509.3, 531.0, 553.8, 538.4, 562.8, - 582.7, 541.4, 558.5, 545.3, 640.0, 583.8, 599.0, 593.8, 595.2, 603.6, 604.7, 620.9, 614.5, - 586.6, 570.3, 549.1, 511.8, 447.5, 462.2, 354.3, 358.3, 365.5, 371.3, 373.8, 377.0, 376.8, - 377.3, 377.6, 383.6, 372.4, 377.1, 382.3, 384.6, 386.4, 384.8, 382.9, 375.9, 365.9, 430.3, - 471.0, 427.2, 428.5, 403.2, 887.7, 380.7, 381.5, 382.0, 397.5, 401.8, 409.1, 418.8, 441.2, - 470.9, 490.1, 514.8, 516.4, 515.3, 490.4, 489.5, 486.6, 472.2, 410.8, 419.1, 428.3, 437.9, - 444.8, 466.6, 466.6, 465.3, 456.6, 449.4, 436.6, 430.0, 421.0, 419.8, 411.4, 398.0, 416.8, - 426.6, 425.1, 415.1, 407.2, 398.0, 385.9, 209.6, 209.3, 208.9, 208.5, 207.5, 216.6, 210.8, - 210.7, 213.0, 210.9, 211.9, 211.8, 211.8, 211.8, 211.6, 211.4, 216.1, 216.6, 216.7, 220.6, - 217.6, 218.4, 219.2, 224.9, 212.4, 237.7, 231.4, 251.4, 252.7, 252.0, 216.4, 231.7, 229.2, - 230.8, 250.7, 228.9, 228.5, 229.0, 397.4, 416.0, 438.4, 447.0, 439.3, 424.3, 218.7, 218.7, - 218.9, 218.8, 218.4, 445.6, 444.4, 455.8, 451.0, 454.8, 454.3, 455.3, 448.6, 443.6, 442.1, - 451.4, 458.2, 455.3, 452.1, 460.3, 460.0, 457.7, 467.1, 457.0, 454.1, 463.0, 459.5, 465.5, - 464.3, 456.8, 445.0, 425.5, 428.2, 218.8, 221.8, 222.7, 222.3, 223.1, 221.6, 223.5, 222.0, - 224.0, 225.4, 223.7, 222.5, 221.8, 220.9, 222.1, 224.0, 223.2, 221.6, 220.3, 218.8, 226.4, - 228.2, 225.4, 224.2, 216.9, 216.5, 217.1, 217.6, 217.9, 217.6, 217.0, 216.1, 216.6, 220.6, - 219.5, 218.9, 220.8, 221.5, 222.5, 220.2, 217.7, 216.4, 488.4, 497.5, 503.2, 515.2, 518.5, - 522.0, 535.7, 542.3, 542.3, 541.8, 536.7, 518.9, 493.5, 216.1, 217.8, 219.5, 220.5, 221.2, - 220.0, 516.0, 218.0, 218.4, 523.4, 502.5, 505.8, 494.9, 220.9, 221.3, 220.8, 220.4, 219.3, - 218.3, 217.5, 216.5, 215.5, 214.2, 213.0, 212.9, 213.3, 213.5, 473.5, 477.6, 487.5, 492.6, - 217.2, 219.2, 220.7, 220.7, 220.7, 218.9, 216.5, 214.4, 212.5, 210.6, 210.7, 210.9, 211.8, - 212.9, 213.7, 213.7, 213.7, 212.6 - ], - "slopeEast": [ - 0.002, -0.001, 0.056, 0.043, 0.022, 0.002, 0.007, -0.005, 0.014, 0.012, 0.002, -0.013, 0.046, - 0.027, -0.083, -0.005, -0.017, -0.01, -0.007, -0.03, -0.051, -0.056, -0.058, -0.088, -0.109, - -0.099, -0.085, -0.079, -0.077, 0.008, 0.005, 0.006, -0.01, 0.062, 0.068, 0.071, 0.074, 0.084, - 0.019, -0.001, 0.023, 0.02, 0.021, 0.024, 0.025, 0.031, 0.054, 0.031, 0.003, -0.022, -0.034, - -0.054, -0.055, -0.049, -0.047, -0.045, -0.038, -0.041, -0.051, -0.054, -0.073, -0.086, -0.104, - -0.05, -0.085, -0.083, 0.125, 0.082, 0.049, 0.018, 0.064, 0.034, -0.013, -0.004, -0.06, -0.087, - -0.128, -0.166, -0.129, -0.038, -0.154, -0.158, -0.163, -0.116, -0.12, 0.088, 0.055, 0.048, - 0.095, 0.036, 0.154, 0.016, -0.009, -0.018, -0.079, -0.121, -0.161, -0.174, 0.003, 0.037, 0.108, - 0.103, 0.1, 0.106, 0.101, -0.064, -0.045, 0.013, 0.149, 0.083, 0.078, 0.071, 0.039, -0.062, - -0.065, -0.118, -0.105, -0.072, -0.045, -0.025, -0.001, -0.008, -0.007, -0.02, -0.037, -0.058, - -0.078, -0.075, -0.069, -0.063, -0.054, -0.008, 0.003, 0.123, 0.036, 0.015, -0.005, 0.017, - 0.049, 0.086, 0.068, 0.013, 0.032, -0.024, -0.01, 0.005, 0.009, -0.005, -0.004, 0.021, 0.062, - -0.012, -0.014, -0.013, -0.031, 0.02, -0.004, -0.021, -0.005, -0.018, -0.026, -0.032, -0.038, - -0.046, -0.055, -0.063, -0.067, -0.007, 0.009, -0.048, -0.051, -0.051, -0.004, -0.015, -0.025, - -0.018, 0.002, -0.002, 0.005, 0.013, 0.02, 0.049, 0.013, 0.002, -0.023, -0.009, -0.075, -0.065, - 0.018, 0.02, 0.024, -0.023, -0.022, -0.049, 0.01, 0.056, -0.003, -0.001, 0.008, -0.048, -0.05, - -0.03, -0.015, 0.002, -0.045, -0.077, -0.052, -0.113, -0.035, 0.052, 0.029, -0.006, -0.008, - 0.119, 0.111, 0.049, 0.019, -0.001, -0.008, 0.003, 0.025, 0.028, 0.004, -0.059, 0.008, 0.005, - -0.0, 0.055, 0.03, -0.044, -0.024, -0.002, -0.029, -0.015, -0.028, -0.04, -0.079, -0.084, - -0.073, 0.118, 0.104, 0.095, 0.098, 0.122, -0.042, 0.014, 0.108, 0.117, -0.077, -0.066, -0.052, - 0.006, 0.018, -0.026, -0.008, 0.016, -0.019, -0.02, 0.006, 0.033, 0.056, 0.035, 0.017, 0.002, - 0.011, 0.009, -0.015, 0.039, -0.016, -0.019, 0.073, 0.06, 0.028, 0.012, -0.052, -0.037, -0.027, - -0.026, -0.025, -0.027, -0.034, -0.033, -0.112, -0.098, -0.018, -0.005, 0.014, -0.002, -0.012, - -0.021, -0.041, -0.06, -0.073, -0.068, -0.007, 0.023, -0.002, 0.014, 0.031, 0.042, 0.045, 0.05, - 0.06, 0.075, 0.066, 0.054, 0.029, 0.03, -0.066, -0.045, -0.033, -0.035, 0.1, 0.072, 0.022, - -0.017, 0.002, 0.006, 0.105, 0.007, -0.015, 0.014, -0.019, -0.045, 0.021, -0.027, -0.032, - -0.043, 0.023, -0.048, 0.051, -0.072, -0.099, -0.013, 0.016, 0.062, 0.05, 0.066, 0.041, 0.022, - -0.031, -0.028, -0.003, 0.047, -0.019, -0.007, 0.014, -0.047, 0.019, -0.023, -0.023, 0.008, - -0.064, 0.049, -0.035, -0.026, 0.01, 0.043, 0.057, 0.094, -0.076, -0.07, 0.043, 0.029, 0.051, - 0.001, 0.036, 0.056, -0.016, -0.042, -0.015, -0.0, 0.037, 0.052, 0.089, 0.032, 0.021, 0.06, - -0.018, 0.018, 0.002, 0.036, 0.058, -0.043, 0.005, -0.0, -0.004, -0.084, -0.088, 0.055, -0.004, - 0.02, 0.027, 0.018, -0.003, -0.013, -0.01, -0.005, 0.032, 0.037, -0.085, -0.114, 0.016, -0.076, - -0.081, 0.043, 0.029, 0.014, -0.247, -0.334, -0.183, -0.03, -0.023, 0.027, 0.067, 0.018, -0.17, - 0.04, 0.076, 0.16, 0.142, 0.214, -0.239, -0.036, 0.236, 0.006, -0.075, -0.015, -0.134, 0.191, - -0.098, -0.235, -0.094, -0.348, -0.156, -0.126, 0.06, -0.008, 0.039, 0.184, 0.048, 0.037, 0.035, - -0.057, -0.06, 0.062, -0.295, -0.071, -0.175, 0.046, -0.045, -0.173, -0.173, -0.161, -0.148, - 0.042, -0.034, -0.145, 0.247, 0.002, -0.209, 0.028, -0.129, -0.037, 0.154, -0.069, -0.023, - 0.149, 0.119, -0.146, 0.175, 0.112, -0.005, -0.029, -0.11, -0.064, -0.06, -0.018, -0.04, 0.03, - -0.087, -0.043, -0.015, -0.031, -0.13, -0.068, -0.06, 0.001, -0.021, -0.0, -0.013, 0.032, 0.012, - -0.025, 0.013, 0.081, 0.008, -0.101, 0.006, -0.063, -0.072, -0.034, -0.109, -0.03, -0.058, - -0.068, -0.174, -0.1, -0.092, -0.088, -0.053, 0.072, 0.007, -0.043, -0.029, -0.004, -0.033, - -0.07, -0.071, -0.07, -0.077, -0.07, -0.021, -0.02, -0.061, -0.004, -0.114, -0.02, -0.023, - 0.032, 0.05, -0.037, -0.005, -0.02, -0.109, -0.063, 0.014, -0.015, -0.037, 0.007, 0.019, 0.035, - 0.051, 0.064, -0.018, -0.034, -0.025, 0.038, -0.074, -0.071, -0.068, -0.065, -0.061, -0.056, - -0.05, -0.038, -0.032, -0.02, 0.035, -0.023, -0.034, -0.042, -0.009, -0.053, -0.061, -0.048, - 0.032, 0.055, -0.052, 0.019, 0.04, -0.026, 0.007, -0.033, -0.04, -0.044, -0.047, 0.134, -0.002, - 0.095, 0.061, -0.082, -0.127, 0.039, 0.057, 0.077, 0.092, 0.081, 0.007, -0.041, 0.057, -0.038, - 0.02, 0.004, -0.126, -0.118, -0.042, -0.061, -0.009, -0.034, -0.028, -0.016, 0.066, -0.008, - -0.033, -0.062, -0.168, 0.054, 0.058, -0.007, 0.007, -0.054, -0.042, -0.257, -0.152, 0.009, - 0.029, 0.032, 0.036, -0.033, -0.046, -0.05, 0.018, -0.059, -0.065, -0.078, -0.093, -0.109, - -0.126, -0.136, -0.072, -0.057, -0.05, -0.039, -0.032, -0.029, -0.024, -0.055, -0.05, -0.057, - 0.019, 0.031, 0.057, 0.078, 0.091, 0.084, 0.071, 0.05, 0.033, 0.066, 0.024, -0.039, -0.043, - -0.062, -0.086, -0.078, -0.035, -0.041, 0.114, -0.004, 0.08, -0.043, 0.008, 0.035, 0.117, 0.075, - 0.091, -0.088, -0.074, -0.157, -0.187, 0.023, 0.023, 0.021, 0.021, 0.017, 0.002, -0.01, 0.054, - 0.037, 0.016, 0.058, -0.096, -0.292, 0.009, 0.007, -0.003, -0.011, -0.009, -0.005, -0.017, - -0.031, -0.04, -0.029, -0.019, 0.007, 0.036, 0.064, 0.06, 0.091, 0.084, 0.021, 0.044, 0.024, - 0.007, 0.007, 0.006, -0.001, -0.004, -0.011, -0.026, -0.037, -0.048, -0.06, -0.057, -0.054, - -0.051, -0.031, -0.014, -0.007 - ], - "slopeNorth": [ - -0.005, 0.005, -0.003, 0.002, -0.006, -0.016, -0.044, -0.027, -0.024, -0.026, -0.026, -0.026, - -0.05, -0.054, 0.02, -0.042, -0.033, -0.022, -0.009, 0.008, 0.025, 0.032, 0.037, 0.003, 0.01, - 0.01, 0.013, 0.017, 0.026, 0.059, 0.018, -0.02, -0.039, 0.024, 0.024, 0.008, -0.005, -0.003, - -0.06, -0.088, -0.024, -0.033, -0.037, -0.039, -0.046, -0.052, -0.026, -0.019, -0.023, -0.025, - -0.027, -0.106, -0.113, -0.118, -0.111, -0.103, -0.016, -0.016, -0.129, -0.122, -0.096, -0.075, - -0.046, -0.071, 0.056, 0.061, -0.108, -0.077, 0.028, 0.067, -0.029, -0.009, 0.048, -0.009, - -0.029, -0.018, 0.006, 0.025, 0.144, 0.19, 0.17, 0.167, 0.149, -0.078, -0.083, -0.0, -0.081, - -0.04, -0.001, -0.033, -0.082, -0.031, 0.061, 0.14, 0.078, 0.132, 0.104, 0.127, -0.011, -0.01, - 0.071, 0.129, 0.113, 0.103, 0.13, -0.038, -0.037, 0.044, 0.052, 0.016, 0.01, -0.01, -0.02, - -0.014, 0.06, 0.086, 0.082, 0.067, 0.049, 0.051, 0.058, -0.08, -0.095, -0.099, -0.098, -0.093, - -0.064, -0.024, 0.007, 0.03, 0.041, -0.085, -0.064, -0.021, -0.069, -0.063, -0.054, -0.027, - 0.013, 0.058, 0.082, 0.091, 0.065, 0.04, 0.085, 0.117, 0.139, 0.134, 0.059, 0.047, 0.008, 0.002, - 0.005, 0.01, 0.034, 0.039, 0.068, 0.077, -0.041, -0.017, -0.011, -0.017, -0.021, -0.021, -0.003, - 0.016, 0.034, -0.057, -0.046, -0.067, -0.062, -0.059, -0.032, -0.028, -0.023, -0.01, -0.01, - -0.033, -0.028, -0.004, 0.019, -0.01, 0.056, 0.067, -0.061, -0.049, -0.108, -0.079, -0.0, 0.018, - 0.024, 0.053, 0.08, 0.085, -0.008, 0.015, 0.016, 0.027, 0.048, -0.05, -0.038, -0.058, -0.086, - -0.001, -0.061, -0.038, -0.014, 0.027, 0.062, -0.036, -0.007, 0.046, -0.023, -0.001, 0.01, - -0.073, 0.053, 0.073, -0.02, 0.004, -0.001, -0.014, -0.069, -0.016, -0.041, 0.001, 0.021, - -0.017, -0.019, -0.115, -0.11, -0.083, -0.119, -0.097, -0.076, -0.101, -0.069, -0.054, -0.022, - -0.045, -0.028, -0.016, -0.008, 0.019, -0.037, 0.005, 0.007, 0.01, -0.092, -0.096, -0.079, - 0.074, 0.07, -0.076, -0.025, -0.075, -0.039, 0.003, -0.006, 0.013, -0.03, -0.064, -0.079, - -0.076, -0.056, -0.026, 0.042, -0.084, 0.038, 0.055, 0.002, 0.033, 0.01, -0.003, 0.001, -0.02, - -0.041, -0.057, -0.056, -0.053, -0.045, -0.037, 0.043, 0.037, -0.023, -0.013, -0.012, 0.063, - 0.029, 0.024, 0.012, -0.007, 0.01, 0.006, -0.057, 0.007, -0.017, 0.02, 0.061, 0.058, 0.039, - 0.021, 0.001, -0.02, -0.025, -0.024, 0.039, 0.052, 0.03, 0.03, 0.028, 0.035, 0.003, 0.005, - 0.027, -0.048, -0.059, 0.005, -0.037, -0.029, -0.058, -0.066, -0.009, -0.02, 0.014, -0.009, - -0.015, -0.01, -0.045, -0.036, -0.085, -0.017, 0.011, -0.052, 0.048, -0.025, -0.024, -0.035, - -0.031, -0.05, -0.058, -0.013, -0.05, -0.053, -0.058, -0.033, -0.017, 0.001, -0.025, -0.066, - -0.023, -0.014, -0.011, -0.059, -0.019, -0.009, 0.005, -0.007, -0.034, -0.051, 0.002, 0.036, - 0.0, 0.007, -0.022, -0.028, -0.046, -0.051, -0.018, -0.021, -0.013, 0.012, -0.005, -0.035, - -0.036, 0.011, -0.065, 0.025, 0.006, 0.018, -0.103, 0.005, -0.007, -0.025, -0.061, -0.044, - -0.049, -0.008, 0.004, -0.043, 0.022, -0.062, -0.019, -0.055, 0.001, -0.01, -0.057, -0.005, - -0.051, -0.064, -0.023, 0.029, -0.044, 0.079, 0.066, -0.049, -0.043, 0.043, 0.037, 0.009, -0.04, - -0.175, -0.046, -0.025, -0.055, -0.045, 0.01, -0.078, -0.036, -0.127, -0.126, -0.145, 0.102, - 0.094, -0.123, 0.055, -0.109, 0.133, -0.142, -0.009, -0.085, 0.067, 0.024, 0.056, -0.06, 0.156, - 0.025, -0.068, 0.018, -0.106, 0.077, 0.019, -0.02, -0.043, 0.021, -0.021, 0.053, -0.023, -0.091, - -0.044, 0.016, 0.174, 0.024, 0.172, 0.213, -0.05, -0.021, -0.051, -0.255, 0.041, 0.076, -0.168, - 0.031, -0.153, -0.058, -0.022, 0.063, -0.084, -0.122, -0.153, -0.157, -0.171, 0.014, 0.054, - 0.036, 0.084, 0.009, 0.019, 0.018, -0.012, 0.013, 0.008, 0.011, 0.061, 0.061, 0.057, -0.013, - -0.036, -0.024, -0.045, -0.046, -0.101, -0.156, -0.066, -0.239, -0.1, -0.077, -0.243, -0.08, - 0.073, 0.039, 0.179, 0.106, 0.005, 0.086, 0.044, 0.321, 0.232, 0.195, 0.145, -0.074, -0.069, - -0.057, 0.021, -0.1, -0.165, 0.063, 0.139, 0.12, 0.091, 0.134, 0.082, 0.064, -0.056, -0.118, - -0.146, -0.004, -0.123, -0.142, -0.092, -0.121, 0.172, 0.209, 0.083, -0.15, -0.088, -0.128, - -0.152, -0.196, 0.004, -0.017, -0.027, -0.037, -0.042, 0.021, 0.014, 0.009, 0.003, 0.082, 0.074, - 0.061, 0.048, 0.031, 0.013, -0.006, 0.081, 0.074, 0.055, -0.036, 0.041, 0.024, 0.006, -0.068, - -0.022, 0.073, 0.021, 0.055, 0.009, 0.022, 0.079, -0.019, 0.052, 0.08, 0.02, 0.073, 0.04, 0.01, - 0.224, 0.291, 0.218, -0.011, -0.137, -0.234, 0.091, 0.094, 0.095, 0.093, 0.062, 0.02, 0.061, - 0.003, 0.044, -0.063, 0.145, -0.012, -0.046, -0.011, 0.075, 0.117, 0.046, -0.033, 0.108, 0.084, - -0.011, -0.094, -0.024, -0.13, 0.075, 0.1, 0.124, -0.018, -0.058, -0.136, -0.149, -0.227, - -0.022, 0.105, 0.119, 0.113, 0.09, 0.076, 0.045, -0.045, 0.032, 0.029, 0.034, 0.051, 0.055, - 0.044, 0.028, -0.072, -0.095, -0.088, -0.06, -0.032, -0.005, 0.003, -0.016, -0.008, 0.039, - 0.085, 0.076, 0.061, 0.046, 0.035, 0.031, 0.022, 0.018, 0.014, 0.006, 0.014, 0.009, -0.002, - -0.002, 0.003, -0.003, -0.024, -0.018, 0.068, 0.212, 0.049, 0.187, 0.06, 0.024, 0.003, -0.084, - -0.173, 0.03, -0.138, -0.17, -0.157, 0.006, -0.007, -0.026, -0.034, -0.038, -0.045, 0.001, - -0.075, -0.067, 0.237, 0.215, 0.109, 0.055, -0.068, -0.066, -0.059, -0.054, -0.059, -0.062, - -0.065, -0.07, -0.072, -0.064, -0.054, -0.045, -0.035, -0.022, 0.059, 0.161, 0.071, 0.021, - -0.021, -0.027, -0.033, -0.039, -0.044, -0.038, -0.029, -0.034, -0.057, -0.078, -0.075, -0.071, - -0.076, -0.085, -0.083, -0.057, -0.03, -0.018 - ] -} +{"viewpoint":{"lat":48.1929826,"lon":16.3534237,"groundM":183.3},"stepDeg":0.5,"radiusKm":60.0,"minKm":1.0,"curvature":"drop d^2/2R*(1-0.13), R=6371000 m","source":{"dataset":"Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid","tiles":["N47_00_E015_00","N47_00_E016_00","N47_00_E017_00","N48_00_E015_00","N48_00_E016_00","N48_00_E017_00","N49_00_E015_00","N49_00_E016_00","N49_00_E017_00"],"tileBytesEach":"about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed","url":"https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)","licence":"Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty (https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)","attribution":"Contains modified Copernicus data: DEM \u00a9 DLR e.V. 2010-2014 and \u00a9 Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.","bakedBy":"scripts/bake-horizon.py"},"elevationDeg":[1.608,1.522,1.467,1.452,1.433,1.426,1.497,1.6,1.597,1.564,1.532,1.495,1.467,1.544,1.62,1.655,1.707,1.775,1.803,1.683,1.532,1.394,1.263,1.297,1.358,1.412,1.433,1.339,1.264,1.269,1.195,1.142,1.13,1.22,1.401,1.368,1.326,1.294,1.378,1.46,1.488,1.516,1.549,1.598,1.647,1.698,1.769,1.816,1.801,1.783,1.695,1.619,1.586,1.547,1.442,1.335,1.282,1.286,1.335,1.421,1.449,1.437,1.313,1.268,1.164,1.14,1.098,1.06,0.984,0.938,0.91,0.877,0.884,0.872,0.943,0.974,0.919,0.873,0.861,0.839,0.731,0.673,0.56,0.494,0.527,0.601,0.566,0.584,0.579,0.643,0.666,0.741,0.851,0.711,0.703,0.573,0.54,0.488,0.433,0.452,0.467,0.483,0.475,0.517,0.419,0.428,0.457,0.489,0.522,0.482,0.5,0.527,0.528,0.471,0.458,0.487,0.521,0.578,0.553,0.419,0.301,0.324,0.425,0.468,0.512,0.558,0.575,0.521,0.477,0.444,0.372,0.297,0.365,0.446,0.486,0.523,0.532,0.516,0.516,0.531,0.52,0.546,0.565,0.535,0.527,0.508,0.516,0.37,0.359,0.33,0.375,0.454,0.546,0.496,0.449,0.425,0.447,0.398,0.388,0.439,0.476,0.496,0.517,0.541,0.494,0.439,0.383,0.411,0.502,0.621,0.578,0.548,0.568,0.642,0.713,0.708,0.654,0.704,0.741,0.709,0.677,0.661,0.66,0.666,0.662,0.651,0.699,0.727,0.746,0.809,0.788,0.799,0.793,0.702,0.734,0.79,0.754,0.704,0.616,0.598,0.708,0.719,0.706,0.704,0.726,0.826,0.849,0.85,0.813,0.853,0.926,0.971,0.918,1.003,1.0,0.927,0.985,1.031,1.043,1.067,1.13,1.022,1.02,1.043,1.101,1.144,1.099,1.082,1.064,1.2,1.238,1.16,1.084,1.202,1.326,1.39,1.429,1.405,1.332,1.358,1.361,1.357,1.354,1.335,1.319,1.376,1.309,1.289,1.257,1.283,1.237,1.296,1.353,1.259,1.314,1.236,1.307,1.344,1.36,1.251,1.292,1.308,1.26,1.288,1.365,1.391,1.414,1.32,1.299,1.35,1.288,1.361,1.303,1.268,1.29,1.305,1.326,1.344,1.328,1.32,1.358,1.375,1.443,1.477,1.492,1.525,1.513,1.422,1.314,1.294,1.337,1.414,1.343,1.397,1.431,1.409,1.378,1.423,1.438,1.343,1.257,1.208,1.209,1.233,1.251,1.267,1.274,1.248,1.254,1.297,1.34,1.359,1.34,1.199,1.162,1.255,1.292,1.29,1.24,1.222,1.24,1.271,1.264,1.317,1.256,1.26,1.321,1.301,1.266,1.268,1.308,1.357,1.474,1.419,1.438,1.393,1.354,1.343,1.354,1.333,1.321,1.359,1.437,1.47,1.395,1.413,1.446,1.466,1.434,1.446,1.458,1.419,1.522,1.502,1.506,1.512,1.528,1.526,1.485,1.427,1.508,1.5,1.48,1.534,1.514,1.449,1.449,1.386,1.368,1.388,1.404,1.374,1.374,1.352,1.3,1.347,1.332,1.265,1.235,1.39,1.285,1.226,1.233,1.235,1.211,1.164,1.183,1.217,1.143,1.128,1.117,1.112,1.086,1.071,1.118,1.103,1.002,0.99,0.995,1.025,0.998,1.024,0.971,1.045,1.09,1.11,1.108,1.081,1.171,1.308,1.446,1.482,1.484,1.494,1.469,1.472,1.479,1.493,1.467,1.388,1.293,1.203,1.104,1.145,1.12,1.028,1.004,0.982,0.968,0.933,0.97,0.986,1.035,1.12,1.227,1.281,1.316,1.312,1.273,1.187,1.205,1.197,1.199,1.176,1.177,1.142,1.19,1.377,1.369,1.322,1.295,1.385,1.493,1.576,1.679,1.769,1.622,1.711,1.641,1.517,1.582,1.525,1.39,1.389,1.343,1.331,1.369,1.343,1.257,1.212,1.134,1.039,1.03,1.075,1.027,1.059,1.112,1.132,1.14,1.16,1.167,1.178,1.179,1.192,1.214,1.245,1.289,1.286,1.279,1.259,1.256,1.202,1.138,1.159,1.116,1.12,1.102,0.999,1.042,1.051,1.09,1.143,1.244,1.269,1.296,1.353,1.412,1.579,1.686,1.814,1.857,1.773,1.731,1.748,1.742,1.668,1.594,1.628,1.692,1.76,1.822,1.863,1.904,1.895,1.849,1.786,1.778,1.731,1.666,1.633,1.561,1.526,1.662,1.747,1.75,1.704,1.631,1.563,1.473,1.501,1.488,1.464,1.441,1.386,1.31,1.298,1.293,1.306,1.371,1.42,1.416,1.417,1.418,1.409,1.399,1.408,1.431,1.434,1.437,1.509,1.544,1.58,1.674,1.528,1.534,1.517,1.497,1.525,1.546,1.562,1.58,1.55,1.634,1.614,1.66,1.643,1.663,1.68,1.829,1.99,2.058,2.014,1.911,2.025,2.023,2.036,2.031,2.009,2.012,2.02,2.109,2.107,2.12,2.171,2.179,2.125,2.084,2.09,2.129,2.183,2.16,2.172,2.239,2.237,2.218,2.217,2.137,2.169,2.203,2.233,2.262,2.272,2.21,2.133,1.955,1.909,1.97,2.138,2.187,2.229,2.278,2.191,2.167,2.213,2.327,2.409,2.31,2.242,2.205,2.152,2.154,2.258,2.217,2.126,2.054,1.97,1.942,2.022,1.894,1.886,1.921,1.897,1.936,1.964,1.978,1.961,1.927,1.879,1.904,1.96,1.9,1.922,2.023,2.061,2.115,1.99,1.805,1.736,1.776,1.841,1.863,1.934,1.942,1.962,2.043,2.082,2.082,2.092,2.062,1.957,1.794,1.821,1.913,2.009,2.066,2.105,2.036,1.964,1.984,2.005,2.036,2.072,2.094,2.022,2.153,2.177,2.146,2.125,2.061,2.004,1.955,1.902,1.84,1.767,1.7,1.696,1.718,1.725,1.709,1.745,1.805,1.836,1.939,2.053,2.14,2.139,2.142,2.035,1.902,1.779,1.667,1.564,1.57,1.581,1.628,1.693,1.739,1.738,1.738,1.675],"distanceKm":[1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.03,1.0,1.0,1.0,1.0,1.09,1.09,1.0,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.12,1.12,1.12,1.12,1.12,1.12,1.09,1.09,1.09,1.09,1.0,1.0,1.0,1.0,1.03,1.06,1.06,1.03,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.0,1.0,1.06,1.06,1.06,1.06,1.06,1.12,1.0,1.0,1.24,1.24,1.21,1.21,1.3,1.3,1.27,1.3,1.33,1.33,1.33,1.33,1.27,1.24,1.27,1.27,1.27,1.03,1.03,1.42,1.84,1.84,1.81,1.03,1.72,1.63,1.63,1.63,1.33,1.33,1.06,1.06,1.72,1.72,1.69,1.69,1.69,1.69,1.69,1.81,1.81,1.75,1.51,1.75,1.75,1.75,1.75,1.75,1.72,1.72,1.72,1.72,1.72,1.72,1.72,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.42,1.42,1.24,1.3,1.3,1.3,1.3,1.3,1.3,1.3,1.27,1.03,1.36,1.36,1.36,1.36,1.36,1.45,1.45,1.09,1.12,1.12,1.54,1.57,1.48,1.48,1.48,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.18,1.18,1.21,1.21,1.21,1.12,1.12,1.12,1.12,1.12,1.15,1.15,1.15,1.15,1.18,1.15,1.15,1.24,1.24,1.27,1.27,1.24,1.24,1.24,1.27,1.27,1.27,1.9,1.9,1.93,1.93,1.93,1.99,1.99,1.99,1.99,1.9,2.02,2.02,2.02,2.05,2.02,1.99,1.99,1.99,1.48,1.21,1.21,1.57,1.69,1.69,1.66,1.39,1.39,1.39,1.54,1.6,1.54,1.54,1.54,1.48,1.45,1.21,1.21,1.21,1.27,1.39,1.39,1.36,1.39,1.39,1.39,1.33,1.33,1.33,1.33,1.33,1.48,1.45,1.39,1.39,1.18,1.18,1.18,1.48,1.48,1.27,1.24,1.27,1.36,1.36,1.39,1.33,1.3,1.3,1.3,1.3,1.3,1.3,1.33,1.0,1.42,1.42,1.27,1.27,1.27,1.27,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.03,1.06,1.06,1.03,1.03,1.03,1.36,1.06,1.06,1.06,1.06,1.09,1.09,1.18,1.21,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.09,1.15,1.15,1.36,1.36,1.36,1.36,1.33,1.33,3.13,1.21,1.21,3.34,3.31,2.83,2.65,2.65,1.3,2.29,1.3,1.15,1.15,1.15,2.29,2.68,2.41,2.23,2.23,2.2,1.12,2.23,2.17,2.17,2.17,1.84,2.74,1.87,1.87,2.95,2.2,2.17,2.17,2.35,2.35,2.23,2.23,2.11,2.14,2.08,2.2,2.2,2.2,2.2,2.2,2.2,2.41,2.41,2.38,2.41,2.41,2.44,2.44,2.44,1.99,2.08,2.08,2.08,2.08,2.05,2.05,2.5,2.38,2.47,2.29,2.23,2.17,1.69,1.69,2.38,2.47,2.38,2.17,2.71,2.71,2.2,2.47,2.41,2.41,2.32,2.38,2.38,2.08,2.5,2.11,2.11,1.75,1.75,1.48,1.81,1.81,1.78,1.78,1.87,17.2,17.2,17.26,17.32,17.44,18.1,17.56,17.5,17.38,17.5,17.62,17.68,17.74,17.8,14.86,14.92,14.98,2.98,26.38,26.38,26.38,26.62,26.14,26.02,26.02,26.38,26.26,26.38,26.38,26.26,26.26,26.26,14.98,14.98,14.98,14.44,13.12,13.18,13.66,13.72,13.72,13.84,13.84,13.84,13.72,12.52,12.58,12.58,12.28,12.22,12.28,16.54,14.02,15.04,16.18,16.24,17.08,17.26,17.44,17.5,17.44,17.32,17.44,17.02,13.96,14.14,9.22,9.16,9.1,9.22,9.28,9.28,9.22,9.16,9.16,9.34,8.68,8.68,8.62,8.74,8.86,8.92,8.86,8.92,8.92,11.74,14.08,11.98,12.22,12.04,34.3,10.36,10.06,9.64,9.58,9.58,9.7,9.7,10.18,10.18,10.18,10.24,10.06,10.48,9.94,9.82,9.76,9.7,8.02,8.14,8.14,8.14,8.08,8.56,8.38,8.38,8.32,8.38,8.02,8.02,8.02,8.14,8.2,7.9,7.9,7.84,7.78,7.66,7.72,7.72,7.72,1.0,1.0,1.0,1.0,1.0,1.45,1.21,1.21,1.3,1.15,1.15,1.15,1.15,1.15,1.15,1.15,1.33,1.33,1.33,1.48,1.3,1.3,1.3,1.42,1.09,2.02,1.81,2.59,2.59,2.53,1.21,1.75,1.69,1.66,2.38,1.57,1.57,1.57,7.18,7.18,7.24,7.24,7.18,7.12,1.0,1.0,1.0,1.0,1.0,7.36,7.3,7.3,7.18,7.24,7.06,7.06,7.06,7.06,7.0,7.12,7.12,7.12,7.0,7.0,7.0,7.0,7.24,7.24,7.06,7.18,7.0,7.06,7.0,7.0,6.94,7.0,7.24,1.03,1.03,1.03,1.0,1.0,1.0,1.06,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.03,1.03,1.03,1.03,1.03,1.03,1.27,1.27,1.27,1.24,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.09,1.09,1.06,1.06,1.06,1.06,1.06,1.09,1.09,9.64,9.58,9.64,9.64,9.7,9.7,9.7,9.7,9.7,9.64,9.64,9.64,9.7,1.03,1.03,1.03,1.03,1.03,1.03,9.52,1.0,1.0,9.4,8.68,8.68,8.68,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,9.52,9.46,9.46,9.46,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0],"peakHeightM":[211.4,209.9,210.5,210.2,209.9,209.7,211.0,212.1,211.2,210.6,210.1,209.4,211.3,212.7,211.6,214.8,215.8,217.1,217.7,215.4,212.5,209.9,207.4,208.7,209.9,211.0,211.4,209.5,208.1,207.5,206.1,205.1,204.8,204.6,207.8,207.2,206.5,206.6,208.8,210.4,210.1,210.6,211.2,212.1,213.0,213.9,214.2,215.0,214.8,214.5,212.9,212.5,211.9,211.2,209.3,207.3,205.7,205.8,208.1,209.6,210.2,209.9,207.6,208.2,203.7,203.2,207.2,206.3,204.2,203.2,204.0,203.3,203.0,203.2,205.3,206.0,204.7,203.7,202.5,201.5,199.6,198.3,195.8,192.2,192.8,198.3,201.7,202.3,201.8,194.9,203.5,204.5,207.7,203.7,199.7,196.7,193.3,192.4,196.5,197.1,197.2,197.7,197.5,198.7,195.8,197.0,197.9,198.4,197.2,198.2,198.7,199.6,199.6,197.9,197.2,198.1,199.1,200.8,200.1,196.0,192.5,189.0,190.8,191.5,192.3,193.1,193.4,192.4,191.7,191.1,189.8,190.8,192.5,193.0,194.4,195.2,195.5,195.1,195.1,195.4,195.2,195.5,193.5,196.1,195.9,195.5,195.6,192.2,192.5,191.8,190.5,192.2,194.0,196.8,195.7,194.4,195.0,193.7,190.1,191.0,191.7,192.0,192.4,192.8,192.0,191.0,190.0,191.8,193.7,196.5,195.6,194.9,194.5,195.9,197.3,197.2,196.1,197.5,198.2,197.6,196.9,197.0,196.6,196.7,197.7,197.5,198.9,199.5,199.5,200.9,200.4,201.1,201.0,198.9,207.9,209.7,208.9,207.2,204.3,204.3,208.1,208.5,208.1,206.9,209.1,212.7,213.5,214.0,212.2,213.2,215.7,217.3,207.1,204.6,204.5,208.8,212.5,213.9,213.7,209.3,210.8,208.2,210.9,212.6,213.0,214.2,213.0,211.4,210.3,208.7,209.5,207.9,207.4,212.6,215.6,216.4,218.1,217.5,215.7,214.9,215.0,214.9,214.8,214.4,217.5,218.3,215.2,214.7,209.3,209.8,208.9,216.9,218.4,211.3,211.8,210.8,214.4,215.3,216.4,212.4,212.7,213.1,212.0,212.6,214.4,215.0,216.2,206.4,215.6,216.9,211.9,213.6,212.3,211.5,206.5,206.8,207.2,207.5,207.2,207.1,207.8,208.1,210.0,210.7,210.2,210.8,210.5,217.2,207.7,207.3,208.1,209.5,208.9,209.9,212.8,213.1,209.6,210.4,210.7,208.9,207.3,206.3,206.4,206.8,207.2,207.5,208.9,208.4,213.2,214.2,215.2,215.7,214.5,211.2,247.4,209.9,210.7,259.2,255.7,244.2,241.1,242.6,212.1,236.3,211.9,208.7,209.9,209.5,234.2,243.1,238.7,236.5,241.0,238.1,211.5,237.8,234.9,234.5,234.9,226.3,246.9,227.9,230.4,259.6,237.2,237.1,238.4,243.8,242.5,239.9,240.4,235.8,240.4,238.1,241.4,241.7,242.3,242.2,240.6,238.4,247.1,246.8,245.2,248.2,247.4,245.4,245.4,242.7,231.1,234.0,234.5,233.4,233.5,231.9,230.1,242.5,239.0,238.2,233.0,237.7,232.3,219.6,219.9,235.0,235.9,232.0,228.4,241.4,237.8,226.9,231.8,230.5,229.4,227.0,230.1,229.5,219.9,226.9,220.2,221.3,214.0,214.8,208.5,216.5,217.9,218.0,217.9,218.8,554.9,596.3,639.3,651.8,655.9,677.7,654.8,653.7,652.5,660.3,655.7,632.9,605.1,578.6,484.8,496.7,491.5,237.4,693.3,682.9,676.3,665.1,672.6,677.4,699.5,746.5,793.0,820.8,836.9,831.7,813.8,774.3,513.7,511.5,512.0,493.9,464.6,457.9,479.7,526.0,524.0,515.7,509.3,531.0,553.8,538.4,562.8,582.7,541.4,558.5,545.3,640.0,583.8,599.0,593.8,595.2,603.6,604.7,620.9,614.5,586.6,570.3,549.1,511.8,447.5,462.2,354.3,358.3,365.5,371.3,373.8,377.0,376.8,377.3,377.6,383.6,372.4,377.1,382.3,384.6,386.4,384.8,382.9,375.9,365.9,430.3,471.0,427.2,428.5,403.2,887.7,380.7,381.5,382.0,397.5,401.8,409.1,418.8,441.2,470.9,490.1,514.8,516.4,515.3,490.4,489.5,486.6,472.2,410.8,419.1,428.3,437.9,444.8,466.6,466.6,465.3,456.6,449.4,436.6,430.0,421.0,419.8,411.4,398.0,416.8,426.6,425.1,415.1,407.2,398.0,385.9,209.6,209.3,208.9,208.5,207.5,216.6,210.8,210.7,213.0,210.9,211.9,211.8,211.8,211.8,211.6,211.4,216.1,216.6,216.7,220.6,217.6,218.4,219.2,224.9,212.4,237.7,231.4,251.4,252.7,252.0,216.4,231.7,229.2,230.8,250.7,228.9,228.5,229.0,397.4,416.0,438.4,447.0,439.3,424.3,218.7,218.7,218.9,218.8,218.4,445.6,444.4,455.8,451.0,454.8,454.3,455.3,448.6,443.6,442.1,451.4,458.2,455.3,452.1,460.3,460.0,457.7,467.1,457.0,454.1,463.0,459.5,465.5,464.3,456.8,445.0,425.5,428.2,218.8,221.8,222.7,222.3,223.1,221.6,223.5,222.0,224.0,225.4,223.7,222.5,221.8,220.9,222.1,224.0,223.2,221.6,220.3,218.8,226.4,228.2,225.4,224.2,216.9,216.5,217.1,217.6,217.9,217.6,217.0,216.1,216.6,220.6,219.5,218.9,220.8,221.5,222.5,220.2,217.7,216.4,488.4,497.5,503.2,515.2,518.5,522.0,535.7,542.3,542.3,541.8,536.7,518.9,493.5,216.1,217.8,219.5,220.5,221.2,220.0,516.0,218.0,218.4,523.4,502.5,505.8,494.9,220.9,221.3,220.8,220.4,219.3,218.3,217.5,216.5,215.5,214.2,213.0,212.9,213.3,213.5,473.5,477.6,487.5,492.6,217.2,219.2,220.7,220.7,220.7,218.9,216.5,214.4,212.5,210.6,210.7,210.9,211.8,212.9,213.7,213.7,213.7,212.6],"slopeEast":[0.002,-0.001,0.056,0.043,0.022,0.002,0.007,-0.005,0.014,0.012,0.002,-0.013,0.046,0.027,-0.083,-0.005,-0.017,-0.01,-0.007,-0.03,-0.051,-0.056,-0.058,-0.088,-0.109,-0.099,-0.085,-0.079,-0.077,0.008,0.005,0.006,-0.01,0.062,0.068,0.071,0.074,0.084,0.019,-0.001,0.023,0.02,0.021,0.024,0.025,0.031,0.054,0.031,0.003,-0.022,-0.034,-0.054,-0.055,-0.049,-0.047,-0.045,-0.038,-0.041,-0.051,-0.054,-0.073,-0.086,-0.104,-0.05,-0.085,-0.083,0.125,0.082,0.049,0.018,0.064,0.034,-0.013,-0.004,-0.06,-0.087,-0.128,-0.166,-0.129,-0.038,-0.154,-0.158,-0.163,-0.116,-0.12,0.088,0.055,0.048,0.095,0.036,0.154,0.016,-0.009,-0.018,-0.079,-0.121,-0.161,-0.174,0.003,0.037,0.108,0.103,0.1,0.106,0.101,-0.064,-0.045,0.013,0.149,0.083,0.078,0.071,0.039,-0.062,-0.065,-0.118,-0.105,-0.072,-0.045,-0.025,-0.001,-0.008,-0.007,-0.02,-0.037,-0.058,-0.078,-0.075,-0.069,-0.063,-0.054,-0.008,0.003,0.123,0.036,0.015,-0.005,0.017,0.049,0.086,0.068,0.013,0.032,-0.024,-0.01,0.005,0.009,-0.005,-0.004,0.021,0.062,-0.012,-0.014,-0.013,-0.031,0.02,-0.004,-0.021,-0.005,-0.018,-0.026,-0.032,-0.038,-0.046,-0.055,-0.063,-0.067,-0.007,0.009,-0.048,-0.051,-0.051,-0.004,-0.015,-0.025,-0.018,0.002,-0.002,0.005,0.013,0.02,0.049,0.013,0.002,-0.023,-0.009,-0.075,-0.065,0.018,0.02,0.024,-0.023,-0.022,-0.049,0.01,0.056,-0.003,-0.001,0.008,-0.048,-0.05,-0.03,-0.015,0.002,-0.045,-0.077,-0.052,-0.113,-0.035,0.052,0.029,-0.006,-0.008,0.119,0.111,0.049,0.019,-0.001,-0.008,0.003,0.025,0.028,0.004,-0.059,0.008,0.005,-0.0,0.055,0.03,-0.044,-0.024,-0.002,-0.029,-0.015,-0.028,-0.04,-0.079,-0.084,-0.073,0.118,0.104,0.095,0.098,0.122,-0.042,0.014,0.108,0.117,-0.077,-0.066,-0.052,0.006,0.018,-0.026,-0.008,0.016,-0.019,-0.02,0.006,0.033,0.056,0.035,0.017,0.002,0.011,0.009,-0.015,0.039,-0.016,-0.019,0.073,0.06,0.028,0.012,-0.052,-0.037,-0.027,-0.026,-0.025,-0.027,-0.034,-0.033,-0.112,-0.098,-0.018,-0.005,0.014,-0.002,-0.012,-0.021,-0.041,-0.06,-0.073,-0.068,-0.007,0.023,-0.002,0.014,0.031,0.042,0.045,0.05,0.06,0.075,0.066,0.054,0.029,0.03,-0.066,-0.045,-0.033,-0.035,0.1,0.072,0.022,-0.017,0.002,0.006,0.105,0.007,-0.015,0.014,-0.019,-0.045,0.021,-0.027,-0.032,-0.043,0.023,-0.048,0.051,-0.072,-0.099,-0.013,0.016,0.062,0.05,0.066,0.041,0.022,-0.031,-0.028,-0.003,0.047,-0.019,-0.007,0.014,-0.047,0.019,-0.023,-0.023,0.008,-0.064,0.049,-0.035,-0.026,0.01,0.043,0.057,0.094,-0.076,-0.07,0.043,0.029,0.051,0.001,0.036,0.056,-0.016,-0.042,-0.015,-0.0,0.037,0.052,0.089,0.032,0.021,0.06,-0.018,0.018,0.002,0.036,0.058,-0.043,0.005,-0.0,-0.004,-0.084,-0.088,0.055,-0.004,0.02,0.027,0.018,-0.003,-0.013,-0.01,-0.005,0.032,0.037,-0.085,-0.114,0.016,-0.076,-0.081,0.043,0.029,0.014,-0.247,-0.334,-0.183,-0.03,-0.023,0.027,0.067,0.018,-0.17,0.04,0.076,0.16,0.142,0.214,-0.239,-0.036,0.236,0.006,-0.075,-0.015,-0.134,0.191,-0.098,-0.235,-0.094,-0.348,-0.156,-0.126,0.06,-0.008,0.039,0.184,0.048,0.037,0.035,-0.057,-0.06,0.062,-0.295,-0.071,-0.175,0.046,-0.045,-0.173,-0.173,-0.161,-0.148,0.042,-0.034,-0.145,0.247,0.002,-0.209,0.028,-0.129,-0.037,0.154,-0.069,-0.023,0.149,0.119,-0.146,0.175,0.112,-0.005,-0.029,-0.11,-0.064,-0.06,-0.018,-0.04,0.03,-0.087,-0.043,-0.015,-0.031,-0.13,-0.068,-0.06,0.001,-0.021,-0.0,-0.013,0.032,0.012,-0.025,0.013,0.081,0.008,-0.101,0.006,-0.063,-0.072,-0.034,-0.109,-0.03,-0.058,-0.068,-0.174,-0.1,-0.092,-0.088,-0.053,0.072,0.007,-0.043,-0.029,-0.004,-0.033,-0.07,-0.071,-0.07,-0.077,-0.07,-0.021,-0.02,-0.061,-0.004,-0.114,-0.02,-0.023,0.032,0.05,-0.037,-0.005,-0.02,-0.109,-0.063,0.014,-0.015,-0.037,0.007,0.019,0.035,0.051,0.064,-0.018,-0.034,-0.025,0.038,-0.074,-0.071,-0.068,-0.065,-0.061,-0.056,-0.05,-0.038,-0.032,-0.02,0.035,-0.023,-0.034,-0.042,-0.009,-0.053,-0.061,-0.048,0.032,0.055,-0.052,0.019,0.04,-0.026,0.007,-0.033,-0.04,-0.044,-0.047,0.134,-0.002,0.095,0.061,-0.082,-0.127,0.039,0.057,0.077,0.092,0.081,0.007,-0.041,0.057,-0.038,0.02,0.004,-0.126,-0.118,-0.042,-0.061,-0.009,-0.034,-0.028,-0.016,0.066,-0.008,-0.033,-0.062,-0.168,0.054,0.058,-0.007,0.007,-0.054,-0.042,-0.257,-0.152,0.009,0.029,0.032,0.036,-0.033,-0.046,-0.05,0.018,-0.059,-0.065,-0.078,-0.093,-0.109,-0.126,-0.136,-0.072,-0.057,-0.05,-0.039,-0.032,-0.029,-0.024,-0.055,-0.05,-0.057,0.019,0.031,0.057,0.078,0.091,0.084,0.071,0.05,0.033,0.066,0.024,-0.039,-0.043,-0.062,-0.086,-0.078,-0.035,-0.041,0.114,-0.004,0.08,-0.043,0.008,0.035,0.117,0.075,0.091,-0.088,-0.074,-0.157,-0.187,0.023,0.023,0.021,0.021,0.017,0.002,-0.01,0.054,0.037,0.016,0.058,-0.096,-0.292,0.009,0.007,-0.003,-0.011,-0.009,-0.005,-0.017,-0.031,-0.04,-0.029,-0.019,0.007,0.036,0.064,0.06,0.091,0.084,0.021,0.044,0.024,0.007,0.007,0.006,-0.001,-0.004,-0.011,-0.026,-0.037,-0.048,-0.06,-0.057,-0.054,-0.051,-0.031,-0.014,-0.007],"slopeNorth":[-0.005,0.005,-0.003,0.002,-0.006,-0.016,-0.044,-0.027,-0.024,-0.026,-0.026,-0.026,-0.05,-0.054,0.02,-0.042,-0.033,-0.022,-0.009,0.008,0.025,0.032,0.037,0.003,0.01,0.01,0.013,0.017,0.026,0.059,0.018,-0.02,-0.039,0.024,0.024,0.008,-0.005,-0.003,-0.06,-0.088,-0.024,-0.033,-0.037,-0.039,-0.046,-0.052,-0.026,-0.019,-0.023,-0.025,-0.027,-0.106,-0.113,-0.118,-0.111,-0.103,-0.016,-0.016,-0.129,-0.122,-0.096,-0.075,-0.046,-0.071,0.056,0.061,-0.108,-0.077,0.028,0.067,-0.029,-0.009,0.048,-0.009,-0.029,-0.018,0.006,0.025,0.144,0.19,0.17,0.167,0.149,-0.078,-0.083,-0.0,-0.081,-0.04,-0.001,-0.033,-0.082,-0.031,0.061,0.14,0.078,0.132,0.104,0.127,-0.011,-0.01,0.071,0.129,0.113,0.103,0.13,-0.038,-0.037,0.044,0.052,0.016,0.01,-0.01,-0.02,-0.014,0.06,0.086,0.082,0.067,0.049,0.051,0.058,-0.08,-0.095,-0.099,-0.098,-0.093,-0.064,-0.024,0.007,0.03,0.041,-0.085,-0.064,-0.021,-0.069,-0.063,-0.054,-0.027,0.013,0.058,0.082,0.091,0.065,0.04,0.085,0.117,0.139,0.134,0.059,0.047,0.008,0.002,0.005,0.01,0.034,0.039,0.068,0.077,-0.041,-0.017,-0.011,-0.017,-0.021,-0.021,-0.003,0.016,0.034,-0.057,-0.046,-0.067,-0.062,-0.059,-0.032,-0.028,-0.023,-0.01,-0.01,-0.033,-0.028,-0.004,0.019,-0.01,0.056,0.067,-0.061,-0.049,-0.108,-0.079,-0.0,0.018,0.024,0.053,0.08,0.085,-0.008,0.015,0.016,0.027,0.048,-0.05,-0.038,-0.058,-0.086,-0.001,-0.061,-0.038,-0.014,0.027,0.062,-0.036,-0.007,0.046,-0.023,-0.001,0.01,-0.073,0.053,0.073,-0.02,0.004,-0.001,-0.014,-0.069,-0.016,-0.041,0.001,0.021,-0.017,-0.019,-0.115,-0.11,-0.083,-0.119,-0.097,-0.076,-0.101,-0.069,-0.054,-0.022,-0.045,-0.028,-0.016,-0.008,0.019,-0.037,0.005,0.007,0.01,-0.092,-0.096,-0.079,0.074,0.07,-0.076,-0.025,-0.075,-0.039,0.003,-0.006,0.013,-0.03,-0.064,-0.079,-0.076,-0.056,-0.026,0.042,-0.084,0.038,0.055,0.002,0.033,0.01,-0.003,0.001,-0.02,-0.041,-0.057,-0.056,-0.053,-0.045,-0.037,0.043,0.037,-0.023,-0.013,-0.012,0.063,0.029,0.024,0.012,-0.007,0.01,0.006,-0.057,0.007,-0.017,0.02,0.061,0.058,0.039,0.021,0.001,-0.02,-0.025,-0.024,0.039,0.052,0.03,0.03,0.028,0.035,0.003,0.005,0.027,-0.048,-0.059,0.005,-0.037,-0.029,-0.058,-0.066,-0.009,-0.02,0.014,-0.009,-0.015,-0.01,-0.045,-0.036,-0.085,-0.017,0.011,-0.052,0.048,-0.025,-0.024,-0.035,-0.031,-0.05,-0.058,-0.013,-0.05,-0.053,-0.058,-0.033,-0.017,0.001,-0.025,-0.066,-0.023,-0.014,-0.011,-0.059,-0.019,-0.009,0.005,-0.007,-0.034,-0.051,0.002,0.036,0.0,0.007,-0.022,-0.028,-0.046,-0.051,-0.018,-0.021,-0.013,0.012,-0.005,-0.035,-0.036,0.011,-0.065,0.025,0.006,0.018,-0.103,0.005,-0.007,-0.025,-0.061,-0.044,-0.049,-0.008,0.004,-0.043,0.022,-0.062,-0.019,-0.055,0.001,-0.01,-0.057,-0.005,-0.051,-0.064,-0.023,0.029,-0.044,0.079,0.066,-0.049,-0.043,0.043,0.037,0.009,-0.04,-0.175,-0.046,-0.025,-0.055,-0.045,0.01,-0.078,-0.036,-0.127,-0.126,-0.145,0.102,0.094,-0.123,0.055,-0.109,0.133,-0.142,-0.009,-0.085,0.067,0.024,0.056,-0.06,0.156,0.025,-0.068,0.018,-0.106,0.077,0.019,-0.02,-0.043,0.021,-0.021,0.053,-0.023,-0.091,-0.044,0.016,0.174,0.024,0.172,0.213,-0.05,-0.021,-0.051,-0.255,0.041,0.076,-0.168,0.031,-0.153,-0.058,-0.022,0.063,-0.084,-0.122,-0.153,-0.157,-0.171,0.014,0.054,0.036,0.084,0.009,0.019,0.018,-0.012,0.013,0.008,0.011,0.061,0.061,0.057,-0.013,-0.036,-0.024,-0.045,-0.046,-0.101,-0.156,-0.066,-0.239,-0.1,-0.077,-0.243,-0.08,0.073,0.039,0.179,0.106,0.005,0.086,0.044,0.321,0.232,0.195,0.145,-0.074,-0.069,-0.057,0.021,-0.1,-0.165,0.063,0.139,0.12,0.091,0.134,0.082,0.064,-0.056,-0.118,-0.146,-0.004,-0.123,-0.142,-0.092,-0.121,0.172,0.209,0.083,-0.15,-0.088,-0.128,-0.152,-0.196,0.004,-0.017,-0.027,-0.037,-0.042,0.021,0.014,0.009,0.003,0.082,0.074,0.061,0.048,0.031,0.013,-0.006,0.081,0.074,0.055,-0.036,0.041,0.024,0.006,-0.068,-0.022,0.073,0.021,0.055,0.009,0.022,0.079,-0.019,0.052,0.08,0.02,0.073,0.04,0.01,0.224,0.291,0.218,-0.011,-0.137,-0.234,0.091,0.094,0.095,0.093,0.062,0.02,0.061,0.003,0.044,-0.063,0.145,-0.012,-0.046,-0.011,0.075,0.117,0.046,-0.033,0.108,0.084,-0.011,-0.094,-0.024,-0.13,0.075,0.1,0.124,-0.018,-0.058,-0.136,-0.149,-0.227,-0.022,0.105,0.119,0.113,0.09,0.076,0.045,-0.045,0.032,0.029,0.034,0.051,0.055,0.044,0.028,-0.072,-0.095,-0.088,-0.06,-0.032,-0.005,0.003,-0.016,-0.008,0.039,0.085,0.076,0.061,0.046,0.035,0.031,0.022,0.018,0.014,0.006,0.014,0.009,-0.002,-0.002,0.003,-0.003,-0.024,-0.018,0.068,0.212,0.049,0.187,0.06,0.024,0.003,-0.084,-0.173,0.03,-0.138,-0.17,-0.157,0.006,-0.007,-0.026,-0.034,-0.038,-0.045,0.001,-0.075,-0.067,0.237,0.215,0.109,0.055,-0.068,-0.066,-0.059,-0.054,-0.059,-0.062,-0.065,-0.07,-0.072,-0.064,-0.054,-0.045,-0.035,-0.022,0.059,0.161,0.071,0.021,-0.021,-0.027,-0.033,-0.039,-0.044,-0.038,-0.029,-0.034,-0.057,-0.078,-0.075,-0.071,-0.076,-0.085,-0.083,-0.057,-0.03,-0.018]} diff --git a/src/app/features/scene/ridge/weather.ts b/src/app/features/scene/ridge/weather.ts index edcb2ae3..68ea88ea 100644 --- a/src/app/features/scene/ridge/weather.ts +++ b/src/app/features/scene/ridge/weather.ts @@ -43,9 +43,7 @@ export function weatherOf(value: number): Weather { } /** The page URL's `?weather=<0..1>`, when it is a number. */ -export function urlWeather( - search: string = typeof location === 'undefined' ? '' : location.search -): number | undefined { +export function urlWeather(search: string = typeof location === 'undefined' ? '' : location.search): number | undefined { const v = new URLSearchParams(search).get('weather'); if (v === null || v.trim() === '') return undefined; const n = Number(v); diff --git a/src/app/features/scene/ridge/weatherFeed.test.ts b/src/app/features/scene/ridge/weatherFeed.test.ts index 0f490cad..e12bbefc 100644 --- a/src/app/features/scene/ridge/weatherFeed.test.ts +++ b/src/app/features/scene/ridge/weatherFeed.test.ts @@ -30,9 +30,7 @@ const NOW = new Date('2026-11-11T10:50:00Z'); describe('weather from the file', () => { it('credits GeoSphere as its legal page asks, and claims no licence', () => { - expect(WEATHER_ATTRIBUTION).toBe( - 'Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at' - ); + expect(WEATHER_ATTRIBUTION).toBe('Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at'); expect(WEATHER_ATTRIBUTION).not.toMatch(/CC/); }); it('rain is 0.8..1, by rate', () => { @@ -44,41 +42,21 @@ describe('weather from the file', () => { expect(clearSkyGhi(-5)).toBe(0); }); it('noon clear: radiation at the clear-sky value is no cloud', () => { - expect( - weatherFromFile( - file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), - 21 - ) - ).toBe(0); + expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 600, humidity_pct: 95 }), 21)).toBe(0); expect(cloudFromGlow(300, 21)).toBeCloseTo(0.56, 1); // k = 0.9 is still a few oktas }); it('noon overcast: a fifth of the clear-sky radiation is nearly full cloud, and with saturated air, mist', () => { expect(cloudFromGlow(0.2 * clearSkyGhi(21), 21)).toBeGreaterThan(0.9); - expect( - weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21) - ).toBeGreaterThan(0.6); - expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe( - 0.75 - ); + expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 80 }), 21)).toBeGreaterThan(0.6); + expect(weatherFromFile(file({ glow_wm2: 0.2 * clearSkyGhi(21), humidity_pct: 100 }), 21)).toBe(0.75); }); it('the radiation, not the humidity, decides by day: humid air under a bright sky is clear', () => { - expect( - weatherFromFile( - file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), - 30 - ) - ).toBe(0); + expect(weatherFromFile(file({ glow_wm2: clearSkyGhi(30), sunshine_s_10min: 600, humidity_pct: 98 }), 30)).toBe(0); }); it('dusk: with the sun at 3 degrees the humidity ramp speaks, cloud 0 at 60% and 1 at 95%', () => { - expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe( - 0 - ); - expect( - weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3) - ).toBeCloseTo(0.35, 2); - expect( - weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3) - ).toBeCloseTo(0.7, 2); + expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 60 }), 3)).toBe(0); + expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 77.5 }), 3)).toBeCloseTo(0.35, 2); + expect(weatherFromFile(file({ glow_wm2: 5, sunshine_s_10min: 0, humidity_pct: 95 }), 3)).toBeCloseTo(0.7, 2); }); it('night: the radiation and sunshine are 0.0 and the humidity speaks; saturated air is mist, 0.75', () => { expect(weatherFromFile(file({ glow_wm2: 0, humidity_pct: 40 }), -20)).toBe(0); @@ -90,30 +68,21 @@ describe('weather from the file', () => { for (const g of [56, 72, 89]) expect(cloudFromGlow(g, nov)).toBeGreaterThan(0.95); for (const g of [546, 569]) expect(cloudFromGlow(g, oct)).toBe(0); expect(weatherFromFile(file({ glow_wm2: 72, sunshine_s_10min: 0 }), nov)).toBeGreaterThan(0.65); - expect( - weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct) - ).toBe(0); + expect(weatherFromFile(file({ glow_wm2: 560, sunshine_s_10min: 600, humidity_pct: 70 }), oct)).toBe(0); }); it('the sunshine cross-checks the radiation: agreement keeps the radiation, a half-sky disagreement splits it', () => { - expect( - cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21) - ).toBeGreaterThan(0.9); + expect(cloudEstimate(file({ glow_wm2: 0.2 * clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBeGreaterThan(0.9); // Radiation says clear, the sunshine says none: each is half trusted. expect(cloudEstimate(file({ glow_wm2: clearSkyGhi(21), sunshine_s_10min: 0 }), 21)).toBe(0.5); // No radiation reading by day: the sunshine speaks alone. expect(cloudEstimate(file({ glow_wm2: null, sunshine_s_10min: 300 }), 21)).toBe(0.5); expect(cloudEstimate(file({ glow_wm2: 0, sunshine_s_10min: 600 }), 21)).toBe(0); }); - it("with the sun below the instruments' reach there is no estimate: the humidity speaks", () => { + it('with the sun below the instruments\' reach there is no estimate: the humidity speaks', () => { expect(cloudEstimate(file({ glow_wm2: 3, sunshine_s_10min: 0 }), 3)).toBeNull(); }); it('null: unknown measurements are not zero, and nothing known is a clear sky', () => { - expect( - weatherFromFile( - file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), - 21 - ) - ).toBe(CLEAR_SKY); + expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: null, precip_mm_10min: null, sunshine_s_10min: null }), 21)).toBe(CLEAR_SKY); expect(weatherFromFile(file({ glow_wm2: null, humidity_pct: 95 }), 21)).toBeCloseTo(0.7, 2); }); }); @@ -128,26 +97,17 @@ describe('the weather now', () => { expect(currentWeather(f, NOW)).toBe(CLEAR_SKY); }); it('the 30 minute mark itself is still fresh', () => { - expect( - currentWeather( - file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), - NOW - ) - ).toBe(0.75); + expect(currentWeather(file({ glow_wm2: 0, humidity_pct: 100, fetched_at: '2026-11-11T10:20:00Z' }), NOW)).toBe(0.75); }); it('falls back to a clear sky when the observation is more than 40 minutes old, though the file is fresh', () => { const f = file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:09:00Z' }); expect(NOW.getTime() - Date.parse(f.observed_at)).toBeGreaterThan(OBSERVATION_STALE_MS); expect(currentWeather(f, NOW)).toBe(CLEAR_SKY); - expect( - currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW) - ).toBeGreaterThan(0.8); + expect(currentWeather(file({ precip_mm_10min: 2, observed_at: '2026-11-11T10:11:00Z' }), NOW)).toBeGreaterThan(0.8); }); it('falls back to a clear sky with no file, and with a fetch time from the future', () => { expect(currentWeather(null, NOW)).toBe(CLEAR_SKY); - expect( - currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW) - ).toBe(CLEAR_SKY); + expect(currentWeather(file({ precip_mm_10min: 2, fetched_at: '2026-11-12T10:40:00Z' }), NOW)).toBe(CLEAR_SKY); }); }); @@ -157,45 +117,23 @@ describe('bad instrument values', () => { }); it('the radiation is set against the sun of the ten minutes it averaged, not the sun now', () => { // Sun rising: 07:45Z observation (mean of 07:35-07:45), read 35 minutes later at the stale limit. - const f = file({ - observed_at: '2026-11-11T07:45:00Z', - fetched_at: '2026-11-11T07:46:00Z', - glow_wm2: 100, - sunshine_s_10min: null, - humidity_pct: 50, - }); + const f = file({ observed_at: '2026-11-11T07:45:00Z', fetched_at: '2026-11-11T07:46:00Z', glow_wm2: 100, sunshine_s_10min: null, humidity_pct: 50 }); const at = (m: number) => currentWeather(f, new Date(Date.parse(f.observed_at) + m * 60_000)); expect(at(0)).toBe(at(29)); }); }); describe('reading weather.json', () => { - it("reads Eureka's file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night", () => { + it('reads Eureka\'s file as the host writes it: Z times, a whole-station gap as nulls, 0.0 at night', () => { const night = JSON.parse( '{"fetched_at":"2026-11-11T22:48:22Z","observed_at":"2026-11-11T22:40:00Z","station":11035,"precip_mm_10min":0.1,"humidity_pct":100.0,"sunshine_s_10min":0.0,"glow_wm2":0.0,"attribution":"Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at"}' ); - expect(parseWeatherFile(night)).toMatchObject({ - precip_mm_10min: 0.1, - humidity_pct: 100, - glow_wm2: 0, - sunshine_s_10min: 0, - }); - const gap = parseWeatherFile({ - ...night, - precip_mm_10min: null, - humidity_pct: null, - sunshine_s_10min: null, - glow_wm2: null, - }); - expect(gap).toMatchObject({ - precip_mm_10min: null, - humidity_pct: null, - sunshine_s_10min: null, - glow_wm2: null, - }); + expect(parseWeatherFile(night)).toMatchObject({ precip_mm_10min: 0.1, humidity_pct: 100, glow_wm2: 0, sunshine_s_10min: 0 }); + const gap = parseWeatherFile({ ...night, precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null }); + expect(gap).toMatchObject({ precip_mm_10min: null, humidity_pct: null, sunshine_s_10min: null, glow_wm2: null }); expect(currentWeather(gap, new Date('2026-11-11T22:50:00Z'))).toBe(CLEAR_SKY); }); - it("is not another station's file", () => { + it('is not another station\'s file', () => { expect(parseWeatherFile({ ...file(), station: 11036 })).toBeNull(); }); it('parses the agreed shape, keeping nulls and the sunshine it never reads', () => { @@ -208,30 +146,15 @@ describe('reading weather.json', () => { expect(parseWeatherFile(null)).toBeNull(); expect(parseWeatherFile('x')).toBeNull(); }); - it("rejects a time with no zone: it would be read in the client's local time", () => { + it('rejects a time with no zone: it would be read in the client\'s local time', () => { expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T10:40:00' })).toBeNull(); - expect( - parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' }) - ).not.toBeNull(); + expect(parseWeatherFile({ ...file(), observed_at: '2026-11-10T11:40:00+01:00' })).not.toBeNull(); }); it('is no observation when the file is missing, fails, or is not JSON', async () => { + expect(await fetchWeatherFile('/weather.json', (async () => ({ ok: false })) as unknown as typeof fetch)).toBeNull(); + expect(await fetchWeatherFile('/weather.json', (async () => { throw new Error('offline'); }) as unknown as typeof fetch)).toBeNull(); expect( - await fetchWeatherFile('/weather.json', (async () => ({ - ok: false, - })) as unknown as typeof fetch) - ).toBeNull(); - expect( - await fetchWeatherFile('/weather.json', (async () => { - throw new Error('offline'); - }) as unknown as typeof fetch) - ).toBeNull(); - expect( - await fetchWeatherFile('/weather.json', (async () => ({ - ok: true, - json: async () => { - throw new Error('bad'); - }, - })) as unknown as typeof fetch) + await fetchWeatherFile('/weather.json', (async () => ({ ok: true, json: async () => { throw new Error('bad'); } })) as unknown as typeof fetch) ).toBeNull(); }); it('reads a good file', async () => { diff --git a/src/app/features/scene/ridge/weatherFeed.ts b/src/app/features/scene/ridge/weatherFeed.ts index b2b4a7b0..39379fd5 100644 --- a/src/app/features/scene/ridge/weatherFeed.ts +++ b/src/app/features/scene/ridge/weatherFeed.ts @@ -34,8 +34,7 @@ const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null); /** An ISO time that names its zone (`Z` or an offset); `Date.parse` would read a bare one in the client's local time. */ -const isZonedTime = (v: unknown): v is string => - typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v)); +const isZonedTime = (v: unknown): v is string => typeof v === 'string' && /(Z|[+-]\d\d:?\d\d)$/.test(v) && !Number.isNaN(Date.parse(v)); /** A `WeatherFile` from parsed JSON, or null when it does not have the shape (times missing or unparseable). */ export function parseWeatherFile(json: unknown): WeatherFile | null { @@ -97,10 +96,8 @@ export function cloudEstimate(f: WeatherFile, sunEl: number): number | null { export function weatherFromFile(f: WeatherFile, sunEl: number): number { const rain = f.precip_mm_10min ?? 0; if (rain > 0) return clamp01(0.8 + 0.2 * Math.min(1, rain / 1.5)); - const cloud = - cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35)); - const mist = - f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75; + const cloud = cloudEstimate(f, sunEl) ?? (f.humidity_pct === null ? 0 : clamp01((f.humidity_pct - 60) / 35)); + const mist = f.humidity_pct === null || cloud < 0.5 ? 0 : clamp01((f.humidity_pct - 92) / 8) * 0.75; return Math.max(0.7 * cloud, mist); } @@ -109,24 +106,13 @@ export function currentWeather(f: WeatherFile | null, now: Date = new Date()): n if (!f) return CLEAR_SKY; const fetched = now.getTime() - Date.parse(f.fetched_at); const observed = now.getTime() - Date.parse(f.observed_at); - if ( - !( - fetched <= STALE_MS && - fetched >= -SKEW_MS && - observed <= OBSERVATION_STALE_MS && - observed >= -SKEW_MS - ) - ) - return CLEAR_SKY; + if (!(fetched <= STALE_MS && fetched >= -SKEW_MS && observed <= OBSERVATION_STALE_MS && observed >= -SKEW_MS)) return CLEAR_SKY; // GLOW and SO are means of the ten minutes ending at observed_at: the sun of their midpoint is the one to set them against. return weatherFromFile(f, viennaSun(new Date(Date.parse(f.observed_at) - 5 * 60_000)).elevation); } /** Fetch and parse `weather.json`; null on any failure. */ -export async function fetchWeatherFile( - url: string, - fetcher: typeof fetch = fetch -): Promise { +export async function fetchWeatherFile(url: string, fetcher: typeof fetch = fetch): Promise { try { const res = await fetcher(url, { cache: 'no-store' }); return res.ok ? parseWeatherFile(await res.json()) : null; diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index 6c7d7efd..42631d57 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -37,7 +37,5 @@ export function fragmentSource(stage: Stage, defines = ''): string { const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : ''; const world = solid ? sdf : ''; const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]); - return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${ - stages[stage] - }`; + return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`; } diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index a04ac173..7c8e14b1 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -88,13 +88,7 @@ describe('ridgeSkyFor', () => { }); it('draws the sky auto resolves to, never nothing', () => { - const key = (s: ReturnType) => [ - s.top, - s.mid, - s.horizon, - s.band, - s.physical?.sun, - ]; + const key = (s: ReturnType) => [s.top, s.mid, s.horizon, s.band, s.physical?.sun]; setSkyClock(() => atStandardTime(12, 0)); expect(key(ridgeSkyFor('auto', 0.3))).toEqual(key(ridgeSkyFor('day', 0.3))); setSkyClock(() => atStandardTime(0, 0)); diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts index c92892f6..888744b3 100644 --- a/src/app/features/scene/skyInk.test.ts +++ b/src/app/features/scene/skyInk.test.ts @@ -7,11 +7,10 @@ import type { FixedSkyState } from './skyBand'; import { LIGHTEST_PATCH, NIGHT_INK_MAX_L, - SCRIM_INK, - SCRIM_GROUND, + NOTICE_GROUND, inkForState, labelInk, - scrimStyle, + noticeInk, sceneInk, userColorInk, } from './skyInk'; @@ -183,24 +182,27 @@ describe('labelInk', () => { }); }); -describe('scrimStyle', () => { - // The scrim over the lightest possible ground (white) is the worst case the ink can meet. - const [r, g, b, a] = (SCRIM_GROUND.match(/[\d.]+/g) ?? []).map(Number); - const worst = chroma.rgb( - ...([r, g, b].map((c) => c * a + 255 * (1 - a)) as [number, number, number]) - ); - const ratio = (x: number, y: number) => (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +describe('noticeInk', () => { + const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - it('clears AA for its ink over its scrim even on a white ground', () => { - expect(ratio(luminance(SCRIM_INK), luminance(worst.hex()))).toBeGreaterThanOrEqual(AA); + it('is light enough to clear AA on the dark ridge wherever the theme ink does not', () => { + (['day', 'dusk'] as FixedSkyState[]).forEach((state) => { + const ink = noticeInk({ state, phone: true }) as string; + expect(ratio(luminance(ink), NOTICE_GROUND)).toBeGreaterThanOrEqual(AA); + // the dark theme ink it replaces does not + expect( + ratio(luminance(baked.noonday.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND) + ).toBeLessThan(AA); + }); }); - it('is the same in every sky state and viewport while a scene runs, and absent without one', () => { - const styles = (['day', 'dusk', 'night'] as FixedSkyState[]).flatMap((state) => - [true, false].map((phone) => scrimStyle({ state, phone })) + it('leaves night (already light) and every desktop state to the theme', () => { + expect(noticeInk({ state: 'night', phone: true })).toBeUndefined(); + (['day', 'dusk', 'night'] as FixedSkyState[]).forEach((state) => + expect(noticeInk({ state, phone: false })).toBeUndefined() ); - styles.forEach((x) => expect(x).toEqual(styles[0])); - expect(styles[0]).toMatchObject({ color: SCRIM_INK, backgroundColor: SCRIM_GROUND }); - expect(scrimStyle(undefined)).toBeUndefined(); + expect( + ratio(luminance(baked.midnight.tokens.SurfaceVariant.OnContainer), NOTICE_GROUND) + ).toBeGreaterThanOrEqual(AA); }); }); diff --git a/src/app/features/scene/skyInk.ts b/src/app/features/scene/skyInk.ts index b8e38f31..b7a3667f 100644 --- a/src/app/features/scene/skyInk.ts +++ b/src/app/features/scene/skyInk.ts @@ -95,22 +95,18 @@ export const labelInk = ({ state, phone }: SkyInk): string | undefined => state === 'dusk' && phone ? '#29221a' : undefined; /** - * The training-notice line above the composer sits on whatever the scene draws there: the dark ridge on a phone in - * the Starfire shots, but a mid-tone ridge at other widths and the near-black foreground ridge on a desktop, so no - * ink chosen for one ground holds on the others (measured 2026-11-10 on the pretend-server line, ratatoskr-dev-preview/ - * taps2-REPORT.md: phone day 3.08, dusk 3.97 with light ink; desktop day 1.01, dusk 1.22 with the theme's dark ink). - * So the line carries its own ground: light ink on a dark scrim, in every sky state, on every viewport, whatever - * the scene is behind it. The scrim is composited over the lightest ground there is (white); the ink over that - * worst case clears AA (skyInk.test.ts). + * WCAG relative luminance of the lightest pixel under the notice line above the composer on a phone: the dark + * foreground ridge, the same in every state (measured 2026-11-10 from the Starfire phone shots at 390x844, + * ratatoskr-dev-preview/phone-room-{day,dusk,night}.png, text region x 15-355 CSS px: median 0.0052, 90th percentile + * 0.0089, rounded up to 0.01). The labelInk patches above are the lightest terrain behind the timeline; here the + * ground is dark, so the ink must be light. */ -export const SCRIM_INK = baked.midnight.tokens.SurfaceVariant.OnContainer; -export const SCRIM_GROUND = 'rgba(10, 14, 24, 0.82)'; +export const NOTICE_GROUND = 0.01; /** - * The style of a line of text that must be read whatever the scene draws behind it (the training notice, the desk's - * recorded rulings): its own ground and ink while a scene runs, or undefined without one. + * The ink of the training-notice line above the composer where the theme's own does not clear AA there. On a phone the + * line sits on the dark ridge in every state, but Noonday (day, dusk) wears dark ink, 1.2:1 at dusk by the numbers + * above; Midnight's (night) is already light and stays (undefined). Desktop is unmeasured and stays. */ -export const scrimStyle = (ink: SkyInk | undefined) => - ink - ? { color: SCRIM_INK, backgroundColor: SCRIM_GROUND, borderRadius: 4, paddingInline: 6 } - : undefined; +export const noticeInk = ({ state, phone }: SkyInk): string | undefined => + phone && state !== 'night' ? baked.midnight.tokens.SurfaceVariant.OnContainer : undefined; diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index fdbd9360..48dc2b42 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -157,8 +157,10 @@ export function grainAt( distance: number ): number { const w = triWeights(n); - const over = (k: ReadonlyArray, f: typeof waves) => - w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y); + const over = ( + k: ReadonlyArray, + f: typeof waves + ) => w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y); const coarse = over(COARSE, waves); const fine = over(FINE, wavesWarped) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 6c91bc9d..5e1a20f4 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -33,7 +33,6 @@ import { About } from './about'; import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; -import { PhoneHit } from '../../styles/PhoneHit.css'; export enum SettingsPages { GeneralPage, @@ -142,7 +141,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 19407bdf..9ec74305 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -8,7 +8,6 @@ import { LogoMark } from '../../../components/logo/logoMark.css'; import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type AboutProps = { requestClose: () => void; @@ -26,7 +25,7 @@ export function About({ requestClose }: AboutProps) { - + diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx index 77ee8671..c4b56e47 100644 --- a/src/app/features/settings/account/Account.tsx +++ b/src/app/features/settings/account/Account.tsx @@ -5,7 +5,6 @@ import { MatrixId } from './MatrixId'; import { Profile } from './Profile'; import { ContactInformation } from './ContactInfo'; import { IgnoredUserList } from './IgnoredUserList'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type AccountProps = { requestClose: () => void; @@ -21,7 +20,7 @@ export function Account({ requestClose }: AccountProps) { - + diff --git a/src/app/features/settings/account/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx index 791a407b..98db9459 100644 --- a/src/app/features/settings/account/IgnoredUserList.tsx +++ b/src/app/features/settings/account/IgnoredUserList.tsx @@ -8,7 +8,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { isUserId } from '../../../utils/matrix'; import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useAlive } from '../../../hooks/useAlive'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; function IgnoreUserInput({ userList }: { userList: string[] }) { const mx = useMatrixClient(); @@ -68,7 +67,6 @@ function IgnoreUserInput({ userList }: { userList: string[] }) { userId && !ignoring && ( Remove Avatar - setAlertRemove(false)} - radii="300" - > + setAlertRemove(false)} radii="300"> @@ -279,7 +273,6 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { hasChanges && !changingDisplayName && ( void; @@ -52,7 +51,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { - + diff --git a/src/app/features/settings/devices/DeviceTile.tsx b/src/app/features/settings/devices/DeviceTile.tsx index c4912dc0..71b684f5 100644 --- a/src/app/features/settings/devices/DeviceTile.tsx +++ b/src/app/features/settings/devices/DeviceTile.tsx @@ -29,7 +29,6 @@ import { LogoutDialog } from '../../../components/LogoutDialog'; import { stopPropagation } from '../../../utils/keyboard'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; export function DeviceTilePlaceholder() { return ( @@ -289,7 +288,6 @@ export function DeviceTile({ - + diff --git a/src/app/features/settings/devices/Verification.tsx b/src/app/features/settings/devices/Verification.tsx index c09dff55..6c7eab17 100644 --- a/src/app/features/settings/devices/Verification.tsx +++ b/src/app/features/settings/devices/Verification.tsx @@ -35,7 +35,6 @@ import { stopPropagation } from '../../../utils/keyboard'; import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; import { withSearchParam } from '../../../pages/pathUtils'; import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type VerificationStatusBadgeProps = { verificationStatus: VerificationStatus; @@ -289,7 +288,6 @@ export function DeviceVerificationOptions() { return ( <> void; @@ -31,7 +30,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { - + diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx index 38647b13..a9288728 100644 --- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx +++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx @@ -43,7 +43,6 @@ import { allRoomsAtom } from '../../../state/room-list/roomList'; import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { stopPropagation } from '../../../utils/keyboard'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; function GlobalPackSelector({ packs, @@ -369,7 +368,6 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) { {removed ? ( ) : ( ; @@ -498,7 +497,6 @@ function DateHint({ hasChanges, handleReset }: DateHintProps) { > {hasChanges ? ( ) : ( - + diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx index b77cae21..7f84d607 100644 --- a/src/app/features/settings/notifications/KeywordMessages.tsx +++ b/src/app/features/settings/notifications/KeywordMessages.tsx @@ -15,7 +15,6 @@ import { } from '../../../hooks/useNotificationMode'; import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; const NOTIFY_MODE_OPS: NotificationModeOptions = { highlight: true, @@ -76,7 +75,6 @@ function KeywordInput() { keyword && !addingKeyword && ( + {removing ? : } ); diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index 61af1a09..095a9bba 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -8,7 +8,6 @@ import { KeywordMessagesNotifications } from './KeywordMessages'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type NotificationsProps = { requestClose: () => void; @@ -24,7 +23,7 @@ export function Notifications({ requestClose }: NotificationsProps) { - + diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 90630e1c..38115980 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -10,9 +10,6 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { PushNotification, pushSupported } from '../../push/PushNotification'; -import { activePushConfig } from '../../push/config'; -import { useClientConfig } from '../../../hooks/useClientConfig'; function EmailNotification() { const mx = useMatrixClient(); @@ -88,7 +85,6 @@ function EmailNotification() { } export function SystemNotification() { - const pushCfg = activePushConfig(useClientConfig().push); const notifPermission = usePermissionState('notifications', getNotificationState()); const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); const [isNotificationSounds, setIsNotificationSounds] = useSetting( @@ -137,16 +133,6 @@ export function SystemNotification() { } /> - {pushCfg && pushSupported() && ( - - - - )} {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx index e99a4906..72040632 100644 --- a/src/app/features/space-settings/general/General.tsx +++ b/src/app/features/space-settings/general/General.tsx @@ -13,7 +13,6 @@ import { } from '../../common-settings/general'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type GeneralProps = { requestClose: () => void; @@ -34,7 +33,7 @@ export function General({ requestClose }: GeneralProps) { - + diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx index d513dda9..7572a71b 100644 --- a/src/app/features/space-settings/permissions/Permissions.tsx +++ b/src/app/features/space-settings/permissions/Permissions.tsx @@ -9,7 +9,6 @@ import { usePermissionGroups } from './usePermissionItems'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type PermissionsProps = { requestClose: () => void; @@ -46,7 +45,7 @@ export function Permissions({ requestClose }: PermissionsProps) { - + diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 7e4aa4aa..2ba69290 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,7 +1,6 @@ import { createContext, useContext } from 'react'; import type { DingsConfig } from '../features/dings/dings'; import type { BackgroundSceneConfig } from '../features/scene/tier'; -import type { PushConfig } from '../features/push/config'; export type HashRouterConfig = { enabled?: boolean; @@ -42,9 +41,6 @@ export type ClientConfig = { // service's own registration page (a value, never a route this client invents). registration?: RegistrationConfig; - // Web Push to the installed app (src/app/features/push). Off unless all three keys are filled; ships empty. - push?: PushConfig; - // Dev preview only: the build answers a pretend homeserver in-page (src/preview-demo). previewDemo?: boolean; }; diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 305df247..2b888f55 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -4,7 +4,7 @@ import * as css from './styles.css'; export function AuthFooter() { return ( - + About diff --git a/src/app/pages/auth/ServerPicker.tsx b/src/app/pages/auth/ServerPicker.tsx index 393cf34e..a2a78106 100644 --- a/src/app/pages/auth/ServerPicker.tsx +++ b/src/app/pages/auth/ServerPicker.tsx @@ -23,7 +23,6 @@ import FocusTrap from 'focus-trap-react'; import { useDebounce } from '../../hooks/useDebounce'; import { stopPropagation } from '../../utils/keyboard'; -import { PhoneHit } from '../../styles/PhoneHit.css'; export function ServerPicker({ server, @@ -130,7 +129,6 @@ export function ServerPicker({ } > (); @@ -95,7 +93,6 @@ function UsernameHint({ server }: { server: string }) { } > - - Forget Password? - + Forget Password? diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index f8a7a95b..e762207b 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -54,17 +54,6 @@ export const AuthFooter = style({ padding: config.space.S200, }); -// The footer's links: 16 px apart, and on a phone 22 px between rows, so each link's 11 px of hit area above and -// below meets its neighbour's and does not cross it (see the tap-target rules at the foot of this file). -export const AuthFooterLinks = style({ - gap: config.space.S400, - '@media': { [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { rowGap: 22 } }, -}); - -// Forget Password? sits 5 px under the password field and 16 px above Login: on a phone its hit area takes the 5 and -// grows 7 px of real layout so the 44 reach neither. -export const ForgotLink = style({}); - // While the background scene runs (html[data-scene], set by features/scene/BackgroundScene), the // signed-out ground is clear and the card is a pane shaped like a message bubble (BubbleContent: // radius R500, no border), filled with the sky's bubble colour. The scene is one fixed dusk for @@ -145,17 +134,6 @@ globalStyle(`${scene} ${AuthFooter} a`, { // back out of the layout, so nothing moves and nothing is drawn. An inline link ignores the margin and its padding // never moves a line, which is the same result. const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; -globalStyle(`${AuthCard} a.${ForgotLink}`, { - '@media': { - [SCREEN_PHONE]: { - display: 'inline-block', - paddingTop: 5, - paddingBottom: 23, - marginTop: -5, - marginBottom: -16, - }, - }, -}); globalStyle(`${AuthCard} a`, { '@media': { [SCREEN_PHONE]: { paddingBlock: 14, marginBlock: -14 } }, }); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 6fcf173e..5bc97d65 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -29,7 +29,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { mDirectAtom } from '../../state/mDirectList'; import { motifFor } from '../../features/dings/dings'; import { useDings } from '../../features/dings/useDings'; -import { usePushOpen } from '../../features/push/usePushOpen'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -281,7 +280,6 @@ type ClientNonUIFeaturesProps = { }; export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - usePushOpen(); return ( <> diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 565ff058..93f0526e 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -36,7 +36,6 @@ import { SyncStatus } from './SyncStatus'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; import { AutoDiscovery } from './AutoDiscovery'; -import { PhoneHit } from '../../styles/PhoneHit.css'; function ClientRootLoading() { return ( @@ -62,7 +61,6 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { return ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index 6944c9f7..dae83166 100644 --- a/src/app/pages/client/explore/Explore.tsx +++ b/src/app/pages/client/explore/Explore.tsx @@ -37,7 +37,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { stopPropagation } from '../../../utils/keyboard'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; export function AddServer() { const mx = useMatrixClient(); @@ -97,12 +96,7 @@ export function AddServer() { Add Server - setDialog(false)} - radii="300" - > + setDialog(false)} radii="300"> diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx index c28fad0a..f056cbb5 100644 --- a/src/app/pages/client/explore/Featured.tsx +++ b/src/app/pages/client/explore/Featured.tsx @@ -18,7 +18,6 @@ import * as css from './style.css'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; export function FeaturedRooms() { const { featuredCommunities } = useClientConfig(); @@ -34,7 +33,7 @@ export function FeaturedRooms() { {(onBack) => ( - + )} diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx index 036c6488..48f267cc 100644 --- a/src/app/pages/client/explore/Server.tsx +++ b/src/app/pages/client/explore/Server.tsx @@ -45,7 +45,6 @@ import { getMxIdServer } from '../../../utils/matrix'; import { stopPropagation } from '../../../utils/keyboard'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams => useMemo( @@ -500,7 +499,7 @@ export function PublicRooms() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx index 8e277889..fddd75aa 100644 --- a/src/app/pages/client/home/CreateRoom.tsx +++ b/src/app/pages/client/home/CreateRoom.tsx @@ -12,7 +12,6 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { CreateRoomForm } from '../../../features/create-room'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; export function HomeCreateRoom() { const screenSize = useScreenSizeContext(); @@ -26,7 +25,7 @@ export function HomeCreateRoom() { {(onBack) => ( - + )} diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx index aa454d2c..d5ddfb77 100644 --- a/src/app/pages/client/home/Search.tsx +++ b/src/app/pages/client/home/Search.tsx @@ -5,7 +5,6 @@ import { MessageSearch } from '../../../features/message-search'; import { useHomeRooms } from './useHomeRooms'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; export function HomeSearch() { const scrollRef = useRef(null); @@ -20,7 +19,7 @@ export function HomeSearch() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 4d1580af..ca26bac3 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -63,7 +63,6 @@ import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; const COMPACT_CARD_WIDTH = 548; @@ -746,7 +745,7 @@ export function Invites() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 8fddba44..9592224f 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -97,7 +97,6 @@ import { } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; type RoomNotificationsGroup = { roomId: string; @@ -643,7 +642,7 @@ export function Notifications() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} @@ -695,7 +694,6 @@ export function Notifications() { onVisibilityChange={handleScrollTopVisibility} > virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/pages/client/space/Search.tsx b/src/app/pages/client/space/Search.tsx index a2849ab8..017262b5 100644 --- a/src/app/pages/client/space/Search.tsx +++ b/src/app/pages/client/space/Search.tsx @@ -11,7 +11,6 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; -import { PhoneHit } from '../../../styles/PhoneHit.css'; export function SpaceSearch() { const mx = useMatrixClient(); @@ -35,7 +34,7 @@ export function SpaceSearch() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/phoneHit.call.test.ts b/src/app/phoneHit.call.test.ts deleted file mode 100644 index 56971b05..00000000 --- a/src/app/phoneHit.call.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it } from 'vitest'; -import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; - -// Every IconButton of the call bars carries the phone hit-area variant of its drawn size, in source order -// (mic, sound, video, share, chat; the menu button; the call-status mic, sound, video, share). -describe('icon buttons in the call controls', () => { - it.each([ - ['features/call/Controls.tsx', Array(5).fill('PhoneHitSpread40')], - ['features/call/CallControls.tsx', ['PhoneHitSpread40']], - ['features/call-status/CallControl.tsx', Array(4).fill('PhoneHitSpread26')], - ])('%s', (file, want) => { - expectPhoneHitClasses(file as string, want as string[]); - expectSizeTie(file as string); - }); -}); diff --git a/src/app/phoneHit.commonSettings.test.ts b/src/app/phoneHit.commonSettings.test.ts deleted file mode 100644 index fdb2c4e0..00000000 --- a/src/app/phoneHit.commonSettings.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it } from 'vitest'; -import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; - -// Every IconButton under features/common-settings, in source order. Header close buttons are alone at the -// header end (the title beside them is text), the dialog close buttons are alone in their Header, the -// scroll-to-top button floats alone, and the RoomPacks pair is a ternary (only one renders): all PhoneHit. -const files: [string, string[]][] = [ - ['features/common-settings/members/Members.tsx', ['PhoneHit', 'PhoneHit']], - ['features/common-settings/developer-tools/SendRoomEvent.tsx', ['PhoneHit']], - ['features/common-settings/developer-tools/StateEventEditor.tsx', ['PhoneHit']], - ['features/common-settings/developer-tools/DevelopTools.tsx', ['PhoneHit']], - ['features/common-settings/permissions/PowersEditor.tsx', ['PhoneHit']], - ['features/common-settings/emojis-stickers/EmojisStickers.tsx', ['PhoneHit']], - ['features/common-settings/emojis-stickers/RoomPacks.tsx', ['PhoneHit', 'PhoneHit']], - ['features/common-settings/general/RoomEncryption.tsx', ['PhoneHit']], - ['features/common-settings/general/RoomUpgrade.tsx', ['PhoneHit']], -]; - -describe.each(files)('phone hit area: %s', (file, want) => { - it('carries the variant its drawn size and neighbours call for', () => { - expectPhoneHitClasses(file, want); - }); - it('names a size that matches what it draws', () => { - expectSizeTie(file); - }); -}); diff --git a/src/app/phoneHit.content.test.ts b/src/app/phoneHit.content.test.ts deleted file mode 100644 index 741a5828..00000000 --- a/src/app/phoneHit.content.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it } from 'vitest'; -import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; - -describe('icon buttons in message content and data editors', () => { - // The class each IconButton must carry, in source order. - const expected: Record = { - // mute toggle, 16 px icon + 8 padding = 24 px, 8 px from the volume slider (tappable) - 'components/message/content/AudioContent.tsx': ['PhoneHitSpread24'], - // download, alone at the end of the header row (26 px) - 'components/message/FileHeader.tsx': ['PhoneHit'], - // code block expand/collapse, 24 px, 8 px from the Copy chip - 'plugins/react-custom-html-parser.tsx': ['PhoneHitSpread24'], - // close at the header end, alone - 'components/AccountDataEditor.tsx': ['PhoneHit'], - 'components/image-pack-view/ImagePackView.tsx': ['PhoneHit'], - }; - - it.each(Object.entries(expected))( - '%s: each IconButton carries its own PhoneHit class', - (file, want) => expectPhoneHitClasses(file, want) - ); - - it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => - expectSizeTie(file) - ); -}); diff --git a/src/app/phoneHit.coverage.test.ts b/src/app/phoneHit.coverage.test.ts deleted file mode 100644 index a032d7d8..00000000 --- a/src/app/phoneHit.coverage.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { readdirSync, readFileSync } from 'fs'; -import { describe, expect, it } from 'vitest'; -import { classLists, emitted, expectPhoneHitClasses, phoneHitClasses } from './phoneHitTestKit'; - -// Every IconButton under src carries a PhoneHit class, or is named here with the reason it has none. The per-group -// suites pin which variant a listed file's buttons carry; this one sees files no group lists, so a new bare -// IconButton fails here instead of shipping with a sub-44px phone hit area. -const EXEMPT: Record = { - 'features/estate/EstateNavigator.tsx': { - indices: [0], - why: 'Find a room sits in a css.Targets row that already gives it a 44 px phone target', - }, - 'features/lobby/LobbyHeader.tsx': { - indices: [1], - why: 'Members button, hidden on mobile', - }, -}; - -const tsxFiles = (dir: string): string[] => - readdirSync(`${process.cwd()}/src/app/${dir}`, { withFileTypes: true }).flatMap((e) => { - if (e.isDirectory()) return tsxFiles(`${dir}${e.name}/`); - return e.name.endsWith('.tsx') ? [`${dir}${e.name}`] : []; - }); - -describe('phone hit areas: every IconButton is covered or named', () => { - it('lists exactly the IconButtons without a PhoneHit class', () => { - const bare: Record = {}; - tsxFiles('').forEach((file) => { - const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); - if (!src.includes(' (c === null ? [i] : [])); - if (missing.length > 0) bare[file] = missing; - }); - expect(bare).toEqual( - Object.fromEntries(Object.entries(EXEMPT).map(([f, e]) => [f, e.indices])) - ); - }); -}); - -// UrlPreviewCard's arrows are absolutely positioned by their own class; PhoneHit would set position: relative on them -// and win or lose by stylesheet order, so they carry the variant that adds only the hit area. -describe('phone hit area of a button positioned by its own class', () => { - it('the arrows carry PhoneHitInPlace', () => { - expectPhoneHitClasses('components/url-preview/UrlPreviewCard.tsx', [ - 'PhoneHitInPlace', - 'PhoneHitInPlace', - ]); - }); - - it('PhoneHitInPlace draws the ::after and sets no position of its own', async () => { - const lists = await classLists('styles/PhoneHit.css.ts'); - expect(lists.PhoneHitInPlace).toHaveLength(1); - const css = await emitted('styles/PhoneHit.css.ts'); - const rules = [...css.matchAll(/^([^{}\n]*PhoneHitInPlace__[^{}\n]*) \{$/gm)].map((m) => m[1]); - expect(rules.length).toBeGreaterThan(0); - rules.forEach((sel) => expect(sel).toMatch(/::after$/)); - }); -}); diff --git a/src/app/phoneHit.dialogs.test.ts b/src/app/phoneHit.dialogs.test.ts deleted file mode 100644 index 8b3416a5..00000000 --- a/src/app/phoneHit.dialogs.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it } from 'vitest'; -import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; - -// Dialog and prompt close buttons: each is a 32 px button (24 px Icon + 8 px padding) alone at the end of a -// header whose title is not tappable, so the plain PhoneHit; no neighbour to spread from, no clipping container. -describe('icon buttons in dialogs and prompts', () => { - const expected: Record = { - 'features/create-space/CreateSpaceModal.tsx': ['PhoneHit'], - 'features/create-room/CreateRoomModal.tsx': ['PhoneHit'], - 'features/add-existing/AddExisting.tsx': ['PhoneHit'], - 'components/room-topic-viewer/RoomTopicViewer.tsx': ['PhoneHit'], - 'components/leave-room-prompt/LeaveRoomPrompt.tsx': ['PhoneHit'], - 'components/leave-space-prompt/LeaveSpacePrompt.tsx': ['PhoneHit'], - 'components/event-readers/EventReaders.tsx': ['PhoneHit'], - 'components/invite-user-prompt/InviteUserPrompt.tsx': ['PhoneHit'], - 'components/join-address-prompt/JoinAddressPrompt.tsx': ['PhoneHit'], // join cancel - 'components/user-profile/PowerChip.tsx': ['PhoneHit', 'PhoneHit'], // self demotion, shared power - }; - - it.each(Object.entries(expected))( - '%s: each IconButton carries its own PhoneHit class', - (file, want) => expectPhoneHitClasses(file, want) - ); - - it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => - expectSizeTie(file) - ); -}); diff --git a/src/app/phoneHit.editor.test.ts b/src/app/phoneHit.editor.test.ts deleted file mode 100644 index ef016b59..00000000 --- a/src/app/phoneHit.editor.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; -import { - classLists, - emitted, - expectPhoneHitClasses, - expectSizeTie, - iconButtonTags, - readSource, - rulesFor, - type SizeAllow, -} from './phoneHitTestKit'; - -describe('icon buttons in the editor, emoji board, upload cards and client root', () => { - // The class each IconButton must carry, in source order. - const expected: Record = { - 'components/editor/Toolbar.tsx': [ - 'PhoneHitSpread36', // mark button: size 400 + 20 px icon, in a row 4 px apart - 'PhoneHitSpread36', // block button, same row - 'PhoneHitRoom36', // heading 1, inside the heading Menu (overflow hidden) - 'PhoneHitRoom36', // heading 2 - 'PhoneHitRoom36', // heading 3 - 'PhoneHit', // heading opener: two 20 px icons, 56 px wide, only its height is short - 'PhoneHit', // Exit chip: text label, wider than 44 px - 'PhoneHit', // markdown toggle, alone at the end of the toolbar - ], - // A column of 40 px buttons 4 px apart (the stack's gap="100", pinned below): there is no vertical variant, so only - // PhoneHit; the 44 px areas then touch with no overlap. - 'components/emoji-board/components/Sidebar.tsx': ['PhoneHit'], - // Both cards put a Retry chip beside the cancel button when the upload failed. - 'components/upload-card/UploadCardRenderer.tsx': ['PhoneHitSpread28'], - 'components/upload-card/CompactUploadCardRenderer.tsx': ['PhoneHitSpread28'], - 'pages/client/ClientRoot.tsx': ['PhoneHit'], // alone, absolute in the corner - }; - - const sizeAllow: SizeAllow = {}; - - it.each(Object.entries(expected))( - '%s: each IconButton carries its own PhoneHit class', - (file, want) => expectPhoneHitClasses(file, want) - ); - - it.each(Object.keys(expected))('%s: Spread and Room variants name the size they draw', (file) => - expectSizeTie(file, sizeAllow) - ); -}); - -/** The JSX element opened at `open` (index of its ``: nesting is counted, not guessed (no self-closing Box here). */ -const boxSpan = (src: string, open: number) => { - const marks = [...src.slice(open).matchAll(/])|<\/Box>/g)]; - const depths = marks.map((m, i) => - marks.slice(0, i + 1).reduce((d, x) => d + (x[0] === '' ? -1 : 1), 0) - ); - const end = depths.indexOf(0); - if (end === -1) throw new Error('unbalanced '); - return [open, open + (marks[end].index ?? 0) + marks[end][0].length] as const; -}; - -describe('the estate navigator find button', () => { - // The only IconButton there needs no PhoneHit: every button in the navigator header is 44 px on a phone because - // the Targets class sets a min size, and the button sits inside the Box that carries that class. - it('is 44 px by the Targets class instead', async () => { - const src = readSource('features/estate/EstateNavigator.tsx'); - const tags = iconButtonTags(src); - expect(tags).toHaveLength(1); - expect(tags[0]).not.toMatch(/PhoneHit/); - - const header = src.indexOf(''); - const boxStart = src.indexOf('', boxStart))).toMatch(/className=\{css\.Targets\}/); - const button = src.indexOf(' { - // PhoneHit makes the button position: relative on a phone; the inline style wins over a class, so the corner - // button stays absolute and its ::after anchors to it. - it('ClientRoot keeps its corner button absolutely positioned inline', () => { - const [tag] = iconButtonTags(readSource('pages/client/ClientRoot.tsx')); - expect(tag).toMatch(/style=\{\{[^}]*position:\s*'absolute'/); - }); - - // A 40 px button with a 44 px area, in a column 4 px apart: the areas touch and do not overlap. A smaller gap - // overlaps them, a larger one leaves a dead strip. - it('the emoji sidebar stack keeps its 4 px gap', () => { - const src = readSource('components/emoji-board/components/Sidebar.tsx'); - const stack = src.slice( - src.indexOf('export const SidebarStack'), - src.indexOf('export function SidebarDivider') - ); - expect(stack).toMatch(/direction="Column"/); - expect(stack).toMatch(/gap="100"/); - }); -}); diff --git a/src/app/phoneHit.pages.test.ts b/src/app/phoneHit.pages.test.ts deleted file mode 100644 index 97eb77b8..00000000 --- a/src/app/phoneHit.pages.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it } from 'vitest'; -import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; - -// Page back buttons (and two lone buttons beside them). Every one is alone in its row, 40 px drawn (default -// IconButton and Icon), so plain PhoneHit; the vertical stacks of the page have no variant. -// null = deliberately without: LobbyHeader's Members button renders only when screenSize !== Mobile. -const files: Record = { - 'features/lobby/LobbyHeader.tsx': ['PhoneHit', null, 'PhoneHit'], - 'features/join-before-navigate/JoinBeforeNavigate.tsx': ['PhoneHit'], - 'pages/client/space/Search.tsx': ['PhoneHit'], - 'pages/client/explore/Server.tsx': ['PhoneHit'], - 'pages/client/explore/Featured.tsx': ['PhoneHit'], - 'pages/client/home/CreateRoom.tsx': ['PhoneHit'], - 'pages/client/home/Search.tsx': ['PhoneHit'], - // the second is the 30 px scroll-to-top pill, floating alone at the top centre - 'pages/client/inbox/Notifications.tsx': ['PhoneHit', 'PhoneHit'], - 'pages/client/inbox/Invites.tsx': ['PhoneHit'], - 'pages/client/direct/DirectCreate.tsx': ['PhoneHit'], -}; - -describe.each(Object.entries(files))('phone hit area: %s', (file, want) => { - it('gives each IconButton its variant', () => { - expectPhoneHitClasses(file, want); - }); - // plain PhoneHit has no size to tie; this fails when a sized variant appears whose number is not the drawn px - it('ties any sized variant to the drawn size', () => { - expectSizeTie(file); - }); -}); diff --git a/src/app/phoneHit.roomSpace.test.ts b/src/app/phoneHit.roomSpace.test.ts deleted file mode 100644 index c98c2b09..00000000 --- a/src/app/phoneHit.roomSpace.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it } from 'vitest'; -import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; - -describe('icon buttons in room settings, space settings, lobby, search and explore', () => { - // The class each IconButton must carry, in source order. Vertical stacks and lone buttons: the plain PhoneHit. - const expected: Record = { - 'features/room-settings/RoomSettings.tsx': ['PhoneHit'], // close, phone only, alone at the header end - 'features/room-settings/general/General.tsx': ['PhoneHit'], // close, 40 px - 'features/room-settings/permissions/Permissions.tsx': ['PhoneHit'], - 'features/space-settings/SpaceSettings.tsx': ['PhoneHit'], - 'features/space-settings/general/General.tsx': ['PhoneHit'], - 'features/space-settings/permissions/Permissions.tsx': ['PhoneHit'], - // 16 px icon + 8 padding = 24 px, 8-12 px from the Open / Add chips beside it in a hierarchy card - 'features/lobby/HierarchyItemMenu.tsx': ['PhoneHitSpread24'], - 'features/lobby/Lobby.tsx': ['PhoneHit'], // floating scroll-to-top, alone - 'features/message-search/MessageSearch.tsx': ['PhoneHit'], // floating scroll-to-top, alone - 'pages/client/explore/Explore.tsx': ['PhoneHit'], // Add Server dialog close - }; - - it.each(Object.entries(expected))( - '%s: each IconButton carries its own PhoneHit class', - (file, want) => expectPhoneHitClasses(file, want) - ); - - it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => - expectSizeTie(file) - ); -}); diff --git a/src/app/phoneHit.settingsA.test.ts b/src/app/phoneHit.settingsA.test.ts deleted file mode 100644 index 37f1f3c2..00000000 --- a/src/app/phoneHit.settingsA.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it } from 'vitest'; -import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; - -describe('icon buttons in settings A', () => { - // The class each IconButton must carry, in source order. Every one is alone in its row (a header close next to - // non-tappable title text, an input slot, or a list tile beside a non-tappable Avatar), so the plain PhoneHit. - const expected: Record = { - 'features/settings/developer-tools/DevelopTools.tsx': ['PhoneHit'], // header close, 40 px - 'features/settings/notifications/Notifications.tsx': ['PhoneHit'], // header close, 40 px - 'features/settings/notifications/KeywordMessages.tsx': ['PhoneHit', 'PhoneHit'], // input reset, remove: 26 px, alone - 'features/settings/emojis-stickers/EmojisStickers.tsx': ['PhoneHit'], // header close, 40 px - 'features/settings/emojis-stickers/GlobalPacks.tsx': ['PhoneHit', 'PhoneHit'], // undo / remove, a ternary pair, 26 px - 'features/settings/Settings.tsx': ['PhoneHit'], // mobile-only header close, 40 px - 'features/settings/about/About.tsx': ['PhoneHit'], // header close, 40 px - 'features/settings/general/General.tsx': ['PhoneHit', 'PhoneHit', 'PhoneHit'], // reset / info (ternary), header close - 'features/settings/account/Account.tsx': ['PhoneHit'], // header close, 40 px - 'features/settings/devices/Devices.tsx': ['PhoneHit'], // header close, 40 px - }; - - it.each(Object.entries(expected))('%s carries the right hit-area class', (file, want) => { - expectPhoneHitClasses(file, want); - }); - - // Plain PhoneHit has no size to tie, so nothing is allow-listed; the check still fails an allow-list entry - // or a sized variant that disagrees with the drawn size if one is added later. - it.each(Object.keys(expected))('%s ties a sized variant to the drawn size', (file) => { - expectSizeTie(file); - }); -}); diff --git a/src/app/phoneHit.settingsB.test.ts b/src/app/phoneHit.settingsB.test.ts deleted file mode 100644 index 4d2e3dee..00000000 --- a/src/app/phoneHit.settingsB.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, it } from 'vitest'; -import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; - -// Each IconButton here is alone in its slot (a dialog header's close, an input's clear button, a tile's lone -// menu or chevron button, its neighbours being text or a badge), so each takes PhoneHit. Every size is derivable -// from the source, so nothing is allow-listed; plain PhoneHit has no number to tie. -const files: [string, string[]][] = [ - ['features/settings/account/Profile.tsx', ['PhoneHit', 'PhoneHit']], - ['features/settings/account/IgnoredUserList.tsx', ['PhoneHit']], - ['features/settings/devices/Verification.tsx', ['PhoneHit']], - ['features/settings/devices/DeviceTile.tsx', ['PhoneHit']], - ['pages/auth/ServerPicker.tsx', ['PhoneHit']], - ['components/BackupRestore.tsx', ['PhoneHit']], - ['components/DeviceVerification.tsx', ['PhoneHit']], - ['components/DeviceVerificationSetup.tsx', ['PhoneHit', 'PhoneHit']], -]; - -describe.each(files)('phone hit area: %s', (file, want) => { - it('gives each icon button its variant', () => expectPhoneHitClasses(file, want)); - it('ties each sized variant to the size it draws', () => expectSizeTie(file)); -}); diff --git a/src/app/phoneHit.viewers.test.ts b/src/app/phoneHit.viewers.test.ts deleted file mode 100644 index 3e579f4c..00000000 --- a/src/app/phoneHit.viewers.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it } from 'vitest'; -import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; - -// Viewer headers and the UIA stages. The back/close button sits beside a title that is not tappable, so it is a -// plain PhoneHit; the zoom out/in buttons sit either side of a tappable zoom Chip, so they spread at their drawn -// 24 px (IconButton size 300 around a 16 px Icon). -// Not covered: the two scroll arrows of components/url-preview/UrlPreviewCard.tsx. They are position: absolute and -// PhoneHit sets position: relative on phones, which would pull them into the flow; they need a variant that keeps -// the position, which PhoneHit.css.ts does not have yet. -const files: [string, string[]][] = [ - ['components/uia-stages/PasswordStage.tsx', ['PhoneHit']], - ['components/uia-stages/SSOStage.tsx', ['PhoneHit']], - ['components/UIAFlowOverlay.tsx', ['PhoneHit']], - ['components/text-viewer/TextViewer.tsx', ['PhoneHit']], - ['components/image-editor/ImageEditor.tsx', ['PhoneHit']], - ['components/image-viewer/ImageViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']], - ['components/Pdf-viewer/PdfViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']], -]; - -describe.each(files)('phone hit area: %s', (file, want) => { - it('gives each icon button the variant of its surroundings', () => { - expectPhoneHitClasses(file, want); - }); - it('ties each sized variant to the size the button draws', () => { - expectSizeTie(file); - }); -}); diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts deleted file mode 100644 index a77a2e27..00000000 --- a/src/app/phoneHitTestKit.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { relative } from 'path'; -import { - drawnPx, - expectPhoneHitClasses, - expectSizeTie, - iconButtons, - phoneHitOf, -} from './phoneHitTestKit'; - -describe('phone hit test kit', () => { - it('reads { - const src = - ''; - expect(iconButtons(src).map((b) => b.tag)).toEqual([ - '', - '', - ]); - }); - - it('derives a drawn size from literal size props, defaults being 400', () => { - expect(drawnPx('', '')).toEqual({ px: 24 }); - expect(drawnPx('', '')).toEqual({ px: 40 }); - }); - - it('reads { - expect( - drawnPx('', '') - ).toEqual({ - px: 24, - }); - }); - - it('calls a size undetermined when props are spread into the IconButton or the Icon', () => { - expect(drawnPx('', '')).toHaveProperty('undetermined'); - expect(drawnPx('', '')).toHaveProperty( - 'undetermined' - ); - expect(drawnPx('', '')).toHaveProperty( - 'undetermined' - ); - expect(drawnPx('', '')).toHaveProperty('undetermined'); - }); - - it('fails a file with no IconButton, which would otherwise pass vacuously', () => { - expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); - }); - - describe('phoneHitOf reads the imported class off the AST, applied unconditionally', () => { - const tag = (expr: string) => ``; - const ok = (expr: string, name: string) => expect(phoneHitOf(tag(expr))).toBe(name); - const no = (expr: string) => expect(phoneHitOf(tag(expr))).toBeNull(); - - it('accepts the bare identifier and merges that always include it', () => { - ok('PhoneHitSpread26', 'PhoneHitSpread26'); - ok('classNames(PhoneHitRoom36, css.other)', 'PhoneHitRoom36'); - ok('clsx(css.a, { [css.b]: on }, PhoneHit)', 'PhoneHit'); - ok('cx(PhoneHit)', 'PhoneHit'); - ok('`${PhoneHit} ${css.other}`', 'PhoneHit'); - ok('classNames(/* PhoneHit */ PhoneHit)', 'PhoneHit'); - }); - - it('rejects the name as a string, a concatenation or a comment', () => { - no("'PhoneHitSpread26'"); - no('`PhoneHitSpread26`'); - no('"PhoneHitSpread26" + "x"'); - no("'PhoneHit' + 'Spread26'"); - no('/* PhoneHit */ undefined'); - no('css.a // PhoneHit'); - }); - - it('rejects a conditionally applied name', () => { - no('cond ? PhoneHitSpread26 : undefined'); - no('cond && PhoneHitSpread26'); - no('cond || PhoneHitSpread26'); - no('maybe ?? PhoneHitSpread26'); - no("classNames(cond ? PhoneHit : '', css.a)"); - no('classNames(PhoneHit, cond && PhoneHit)'); - }); - - it('rejects every other expression shape the name can hide in', () => { - no('classNames({ PhoneHit: on })'); - no('clsx({ [PhoneHit]: on })'); - no('`${`${PhoneHit}`}`'); - no('typeof PhoneHit'); - no("(PhoneHit = 'x')"); - no('(PhoneHit, css.a)'); - no('(PhoneHit)'); - no('() => PhoneHit'); - no('PhoneHit.foo'); - no('other.PhoneHit'); - no('classNames(css.a, [PhoneHit])'); - no('wrap(PhoneHit)'); - no('PhoneHitSpread26é'); - no('`${css.a}`'); - }); - - it('rejects a tag without className, with two, or with a non-expression one', () => { - expect(phoneHitOf('')).toBeNull(); - expect(phoneHitOf('')).toBeNull(); - expect(phoneHitOf('')).toBeNull(); - expect(phoneHitOf('')).toBe('PhoneHit'); - }); - - it('rejects a spread after className, which can override it, and accepts one before', () => { - expect(phoneHitOf('')).toBeNull(); - expect(phoneHitOf('')).toBeNull(); - expect(phoneHitOf('')).toBe('PhoneHit'); - }); - - it('reads a className past braces inside strings', () => { - ok("classNames('{', PhoneHit)", 'PhoneHit'); - }); - }); - - describe('expectPhoneHitClasses', () => { - const check = (src: string, want: (string | null)[]) => { - const dir = mkdtempSync(`${tmpdir()}/phone-hit-kit-`); - try { - writeFileSync(`${dir}/Probe.tsx`, src); - expectPhoneHitClasses(relative(`${process.cwd()}/src/app`, `${dir}/Probe.tsx`), want); - } finally { - rmSync(dir, { recursive: true }); - } - }; - const imp = "import { PhoneHit, PhoneHitSpread26 } from '../../styles/PhoneHit.css';\n"; - const src = - imp + - 'const A = () => (<>);'; - - it('pins each button in order, null meaning no class', () => { - check(src, ['PhoneHit', 'PhoneHitSpread26', null]); - expect(() => check(src, ['PhoneHit', 'PhoneHit', null])).toThrow(); - expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26', 'PhoneHit'])).toThrow(); - expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26'])).toThrow(); - }); - - it('fails a name that is not read from PhoneHit.css or is applied conditionally', () => { - const local = "const PhoneHit = 'x';\n"; - expect(() => check(local, ['PhoneHit'])).toThrow(); - expect(() => check(local, [null])).not.toThrow(); - const cond = imp + ''; - expect(() => check(cond, ['PhoneHit'])).toThrow(); - const aliased = - "import { Other as PhoneHit } from '../../styles/PhoneHit.css';\n"; - expect(() => check(aliased, ['PhoneHit'])).toThrow(); - const wrongModule = - "import { PhoneHit } from './other';\n"; - expect(() => check(wrongModule, ['PhoneHit'])).toThrow(); - }); - }); -}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts deleted file mode 100644 index d8449cba..00000000 --- a/src/app/phoneHitTestKit.ts +++ /dev/null @@ -1,251 +0,0 @@ -// Shared helpers for the phone hit-area tests (phoneTapTargets.test.ts and one phoneHit..test.ts per later -// group of IconButtons). Not a test file: it registers nothing, the test files call it. -import { readFileSync } from 'fs'; -import { expect } from 'vitest'; -import ts from 'typescript'; -import zlib from 'zlib'; -import { compile, processVanillaFile } from '@vanilla-extract/integration'; - -// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; -// vanilla-extract gzips the inlined source only when it is large, so both forms are read). -const compiled = async (file: string) => { - const filePath = `${process.cwd()}/src/app/${file}`; - const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - return processVanillaFile({ source, filePath, identOption: 'debug' }); -}; - -/** The class list each export of a .css.ts file resolves to (a composed class lists the classes it composes). */ -export const classLists = async (file: string): Promise> => - Object.fromEntries( - [...(await compiled(file)).matchAll(/^export var (\w+) = '([^']*)';$/gm)].map((m) => [ - m[1], - m[2].split(' '), - ]) - ); - -export const emitted = async (file: string) => { - const code = await compiled(file); - // A file with recipes inlines one source per rule group; read them all. - return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] - .map((m) => { - const bytes = Buffer.from(m[1], 'base64'); - return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); - }) - .join('\n'); -}; - -export type Decls = Record; -const parse = (body: string): Decls => - Object.fromEntries( - body - .split('\n') - .map((l) => l.trim().replace(/;$/, '')) - .filter((l) => /^[a-z-]+: -?\d+px$/.test(l)) - .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))]) - ); - -/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */ -export const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => { - const scope = media - ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)] - .filter((m) => m[1] === media) - .map((m) => m[2]) - .join('\n') - : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); - return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)] - .filter((m) => selector.test(m[1])) - .map((m) => parse(m[2])); -}; - -/** The source of a file under src/app. */ -export const readSource = (file: string) => - readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); - -/** The end of a JSX opening tag starting at `from`: its closing > outside any {} expression. */ -const tagEnd = (src: string, from: number) => { - let depth = 0; - let j = from; - for (; j < src.length; j += 1) { - if (src[j] === '{') depth += 1; - else if (src[j] === '}') depth -= 1; - else if (src[j] === '>' && depth === 0) break; - } - return j; -}; - -const tsx = (src: string) => - ts.createSourceFile('source.tsx', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); - -type Opening = ts.JsxOpeningElement | ts.JsxSelfClosingElement; - -/** Every IconButton element (not IconButtonX) of a parsed source, in source order. */ -const elements = (sf: ts.SourceFile) => { - const found: Opening[] = []; - const walk = (n: ts.Node) => { - if ( - (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) && - ts.isIdentifier(n.tagName) && - n.tagName.text === 'IconButton' - ) - found.push(n); - ts.forEachChild(n, walk); - }; - walk(sf); - return found; -}; - -const bodyOf = (el: Opening) => - ts.isJsxOpeningElement(el) && ts.isJsxElement(el.parent) - ? el.getSourceFile().text.slice(el.end, el.parent.closingElement.getStart()) - : ''; - -/** Every - elements(tsx(src)).map((el) => ({ tag: el.getText(), body: bodyOf(el) })); - -export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); - -/** Local names the source imports from the PhoneHit.css module (as written, not aliased). */ -const importedHits = (sf: ts.SourceFile) => - new Set( - sf.statements.flatMap((st) => { - const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined; - return nb && - ts.isNamedImports(nb) && - ts.isStringLiteral((st as ts.ImportDeclaration).moduleSpecifier) && - /^(\.\.\/)+styles\/PhoneHit\.css$/.test( - ((st as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text - ) - ? nb.elements.filter((e) => !e.propertyName).map((e) => e.name.text) - : []; - }) - ); - -const MERGERS = new Set(['classNames', 'clsx', 'cx']); - -/** The PhoneHit* class an IconButton carries, or null. className must appear exactly once, with no props spread - * after it (a spread can override it at runtime), and its expression must be: the bare identifier; a classNames / - * clsx / cx call with it as a direct argument; or a template with it as a direct span. The identifier must occur - * nowhere else in the expression, so a key, a member, a closure, a conditional or a comma can never carry it. When - * `imported` is given, the identifier must also be a binding imported from the PhoneHit.css module. */ -const hitOf = (el: Opening, imported?: Set): string | null => { - const props = el.attributes.properties; - const at = props.flatMap((p, i) => - ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'className' ? [i] : [] - ); - if (at.length !== 1 || props.slice(at[0] + 1).some(ts.isJsxSpreadAttribute)) return null; - const attr = props[at[0]] as ts.JsxAttribute; - const expr = - attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression; - if (!expr) return null; - const isHit = (n: ts.Node): n is ts.Identifier => - ts.isIdentifier(n) && /^PhoneHit\w*$/.test(n.text) && (!imported || imported.has(n.text)); - let direct: ts.Node[] = []; - if (ts.isIdentifier(expr)) direct = [expr]; - else if ( - ts.isCallExpression(expr) && - ts.isIdentifier(expr.expression) && - MERGERS.has(expr.expression.text) - ) - direct = [...expr.arguments]; - else if (ts.isTemplateExpression(expr)) direct = expr.templateSpans.map((sp) => sp.expression); - const hits = direct.filter(isHit); - if (hits.length !== 1) return null; - let occurrences = 0; - const count = (n: ts.Node) => { - if (ts.isIdentifier(n) && n.text === hits[0].text) occurrences += 1; - ts.forEachChild(n, count); - }; - count(expr); - return occurrences === 1 ? hits[0].text : null; -}; - -/** The PhoneHit* class of one tag's text (see hitOf), without the import-binding check. */ -export const phoneHitOf = (tag: string) => { - const els = elements(tsx(tag.endsWith('/>') ? tag : `${tag}`)); - return els.length === 1 ? hitOf(els[0]) : null; -}; - -/** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not - * the one imported from PhoneHit.css. */ -export const phoneHitClasses = (src: string) => { - const sf = tsx(src); - const imported = importedHits(sf); - return elements(sf).map((el) => hitOf(el, imported)); -}; - -/** The class each IconButton must carry, in source order (null = must carry none): the variant is chosen by the - * button's drawn size and whether a clipping container or neighbours surround it, so a swapped variant is a wrong - * hit area. The name must also be the one PhoneHit.css exports, not a local of the same name. */ -export const expectPhoneHitClasses = (file: string, want: (string | null)[]) => - expect(phoneHitClasses(readSource(file))).toEqual(want); - -// IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400. -const BUTTON_PAD: Record = { '300': 4, '400': 8, '500': 12, '600': 16 }; -const ICON_PX: Record = { - '50': 16, - '100': 18, - '200': 20, - '300': 22, - '400': 24, - '500': 28, -}; - -/** The size prop of a tag: its literal value, the default when absent, or undefined when it is an expression. */ -const sizeProp = (tag: string) => { - // props spread in may carry a size of their own - if (tag.includes('{...')) return undefined; - const m = tag.match(/\ssize=(?:"(\w+)"|\{([^}]*)\})/); - if (!m) return '400'; - return m[1] ?? m[2].match(/^'(\w+)'$/)?.[1]; -}; - -/** The px an IconButton draws, from its size prop and the Icon inside; undefined with a reason when the source - * does not say (a variable size, no Icon, Icons of different sizes). */ -export const drawnPx = (tag: string, body: string): { px: number } | { undetermined: string } => { - const pad = BUTTON_PAD[sizeProp(tag) ?? '']; - if (pad === undefined) - return { undetermined: 'size of the IconButton is not a literal (or props are spread in)' }; - const icons = [...body.matchAll(//])/g)].map((m) => { - const t = body.slice(m.index, tagEnd(body, m.index ?? 0) + 1); - return ICON_PX[sizeProp(t) ?? '']; - }); - if (icons.length === 0) return { undetermined: 'no inside' }; - if (icons.some((p) => p === undefined)) - return { undetermined: 'size of an Icon is not a literal (or props are spread in)' }; - if (new Set(icons).size > 1) return { undetermined: 'Icons of different sizes inside' }; - return { px: icons[0] + 2 * pad }; -}; - -/** Per file, per IconButton index (source order): why its drawn size cannot be derived from the source. */ -export type SizeAllow = Record>; - -/** A button carrying PhoneHitSpread or PhoneHitRoom draws N px. Plain PhoneHit has no number to tie. An - * undetermined size passes only with an entry in `allow`, and an entry for a size that can be derived fails. */ -export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { - const allowed = allow[file] ?? {}; - const seen = new Set(); - const src = readSource(file); - const buttons = iconButtons(src); - const classes = phoneHitClasses(src); - expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0); - buttons.forEach(({ tag, body }, i) => { - const variant = classes[i]?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); - if (!variant) { - expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); - return; - } - const drawn = drawnPx(tag, body); - if ('undetermined' in drawn) { - expect( - allowed[i], - `${file} #${i}: ${drawn.undetermined}; add it to the allow-list` - ).toBeDefined(); - seen.add(i); - return; - } - expect(allowed[i], `${file} #${i} is allow-listed but its size is derivable`).toBeUndefined(); - expect(Number(variant[1]), `${file} #${i} draws ${drawn.px} px`).toBe(drawn.px); - }); - expect(Object.keys(allowed).map(Number).sort()).toEqual([...seen].sort()); -}; diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index 192f8414..57a40bfc 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,66 +1,78 @@ import { describe, expect, it } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; -import { - classLists, - emitted, - expectPhoneHitClasses, - expectSizeTie, - rulesFor, - type SizeAllow, -} from './phoneHitTestKit'; + +// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; +// vanilla-extract gzips the inlined source only when it is large, so both forms are read). +const emitted = async (file: string) => { + const filePath = `${process.cwd()}/src/app/${file}`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + const bytes = Buffer.from( + (code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1], + 'base64' + ); + return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); +}; + +type Decls = Record; +const parse = (body: string): Decls => + Object.fromEntries( + body + .split('\n') + .map((l) => l.trim().replace(/;$/, '')) + .filter((l) => /^[a-z-]+: -?\d+px$/.test(l)) + .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))]) + ); + +/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */ +const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => { + const scope = media + ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)] + .filter((m) => m[1] === media) + .map((m) => m[2]) + .join('\n') + : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); + return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)] + .filter((m) => selector.test(m[1])) + .map((m) => parse(m[2])); +}; const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; const TAP = 44; -// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844), free space measured -// above and below the target before its neighbour's box (taps2-REPORT.md): the hit area may reach out of the layout -// box (negative margin) by no more than that, or it takes taps from its neighbour. Space it needs beyond that is -// real layout growth, which the rules above take from the padding the margin does not give back. -const targets: [string, string, RegExp, number, number, number][] = [ +// [target, css file, selector, measured height in CSS px on the Starfire phone run (390x844)]. +const targets: [string, string, RegExp, number][] = [ [ 'sender-name button', 'components/message/layout/layout.css.ts', /^button\.layout_Username__/, 23, - 11, - 11, ], [ 'room-topic button', 'features/room/RoomViewHeader.css.ts', /RoomViewHeader_HeaderTopic__\w+$/, 18, - 0, - 26, ], - ['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16, 14, 14], - [ - 'forget-password link', - 'pages/auth/styles.css.ts', - /styles_AuthCard__\w+ a\.styles_ForgotLink__\w+$/, - 16, - 5, - 16, - ], - // The footer's rows are 22 px apart on a phone (AuthFooterLinks), 11 px for each side of the gap. - ['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22, 11, 11], + ['sign-in card links', 'pages/auth/styles.css.ts', /styles_AuthCard__\w+ a$/, 16], + ['sign-in footer links', 'pages/auth/styles.css.ts', /styles_AuthFooter__\w+ a$/, 22], ]; -describe.each(targets)('phone tap target: %s', (_name, file, selector, height, above, below) => { +describe.each(targets)('phone tap target: %s', (_name, file, selector, height) => { it(`grows its hit area to ${TAP} px without moving layout`, async () => { const css = await emitted(file); const [phone] = rulesFor(css, selector, PHONE); expect(phone, 'a phone rule exists').toBeDefined(); - const top = phone['padding-top'] ?? phone['padding-block'] ?? 0; - const bottom = phone['padding-bottom'] ?? phone['padding-block'] ?? 0; - const marginTop = phone['margin-top'] ?? phone['margin-block'] ?? 0; - const marginBottom = phone['margin-bottom'] ?? phone['margin-block'] ?? 0; + const top = phone['padding-top'] ?? phone['padding-block']; + const bottom = phone['padding-bottom'] ?? phone['padding-block']; + const marginTop = phone['margin-top'] ?? phone['margin-block']; + const marginBottom = phone['margin-bottom'] ?? phone['margin-block']; expect(height + top + bottom).toBeGreaterThanOrEqual(TAP); - // the hit area reaches out of the layout box by no more than the free space on that side - expect(-marginTop).toBeLessThanOrEqual(above); - expect(-marginBottom).toBeLessThanOrEqual(below); - expect(marginTop).toBeLessThanOrEqual(0); - expect(marginBottom).toBeLessThanOrEqual(0); + // the margin takes back exactly what the padding added, so the target does not move or push its neighbours + expect(marginTop).toBe(-top); + expect(marginBottom).toBe(-bottom); }); it('leaves every other screen as it was: no padding or margin outside the phone query', async () => { @@ -70,154 +82,3 @@ describe.each(targets)('phone tap target: %s', (_name, file, selector, height, a ); }); }); - -describe('phone navigator rows', () => { - // The links and buttons inside a row are 44 px (EstateNavigator.css.ts); a row shorter than that lets them spill - // onto the next row (the 38 px pitch of taps2-REPORT.md), so the row and the section header are 44 too. - it('are as tall as their tap targets', async () => { - const css = await emitted('components/nav/styles.css.ts'); - const phone = [ - ...css.matchAll(/@media screen and \(max-width: 750px\) \{\n([\s\S]*?\n)\}(?:\n|$)/g), - ] - .map((m) => m[1]) - .join('\n'); - expect(phone.match(/min-height: 2\.75rem/g)?.length).toBeGreaterThanOrEqual(2); - }); -}); - -describe('phone icon-button hit areas', () => { - const stripPhone = (css: string) => css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); - - it('draw a 44 x 44 box around the button on a phone and nothing on any other screen', async () => { - const css = await emitted('styles/PhoneHit.css.ts'); - const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]; - expect(phone.map((m) => m[1])).toEqual([PHONE]); - expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); - // no content, no box: the pseudo-element is not rendered at all - expect(css).toMatch(/::after \{[^}]*content: "";/); - expect(css).toMatch(/::after \{[^}]*position: absolute;/); - // centred on the button: top and left at half, pulled back by half its own size - expect(css).toMatch(/::after \{[^}]*top: 50%;/); - expect(css).toMatch(/::after \{[^}]*left: 50%;/); - expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); - // the box anchors to its own button, receives the tap, and sits above nothing it should not - expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/); - // PhoneHit and PhoneHitInPlace each draw it; the Spread and Room classes compose PhoneHit - const afters = [...css.matchAll(/::after \{\n([^}]*)\}/g)]; - expect(afters).toHaveLength(2); - const afterDecls = afters.flatMap((m) => - m[1] - .split('\n') - .map((l) => l.trim().split(':')[0]) - .filter(Boolean) - ); - expect([...new Set(afterDecls)].sort()).toEqual([ - 'content', - 'height', - 'left', - 'position', - 'top', - 'transform', - 'width', - ]); - // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the boxes) - expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([ - 'absolute', - 'absolute', - 'relative', - ]); - expect(stripPhone(css).trim()).toBe(''); - }); - - it('open a row up by half the shortfall, so neighbouring hit areas meet and do not overlap', async () => { - const css = await emitted('styles/PhoneHit.css.ts'); - const margins = (selector: RegExp) => - rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); - // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, - // the menu around them clips a hit area drawn outside its box) - // each class owns its own margin: (44 - drawn size) / 2 - const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`)); - expect([24, 26, 28, 32, 36, 40].map(own)).toEqual([[10], [9], [8], [6], [4], [2]]); - // the Room variants open the block axis by the same amount, on top of their Spread class - const room = (size: number) => - rulesFor(css, new RegExp(`PhoneHit_PhoneHitRoom${size}__`), PHONE); - expect([room(26), room(36)]).toEqual([[{ 'margin-block': 9 }], [{ 'margin-block': 4 }]]); - }); - - it('compose a Room variant from the Spread class of the same size, which composes PhoneHit', async () => { - const lists = await classLists('styles/PhoneHit.css.ts'); - const named = (list: string[], name: string) => list.filter((c) => c.includes(`_${name}__`)); - [26, 36].forEach((size) => { - const room = lists[`PhoneHitRoom${size}`]; - expect( - named(room, `PhoneHitSpread${size}`), - `Room${size} composes Spread${size}` - ).toHaveLength(1); - expect(named(room, 'PhoneHit'), `Room${size} composes PhoneHit`).toHaveLength(1); - }); - expect(lists.PhoneHitRoom36.some((c) => c.includes('_PhoneHitSpread26__'))).toBe(false); - }); - - it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { - const css = await emitted('components/message/Reaction.css.ts'); - const [chip] = rulesFor(css, /^button\.Reaction_Reaction__\w+$/, PHONE); - expect(chip['margin-block']).toBe((TAP - 26) / 2); - expect(css).toMatch(/::after \{[^}]*inset: -9px 0;/); - rulesFor(css, /^button\.Reaction_Reaction__\w+$/, undefined).forEach((d) => - expect(Object.keys(d)).not.toContain('margin-block') - ); - }); - - it('give a sender name a 44 px wide button too, text kept at its start', async () => { - const css = await emitted('components/message/layout/layout.css.ts'); - const [phone] = rulesFor(css, /^button\.layout_Username__/, PHONE); - expect(phone['min-width']).toBe(TAP); - expect(css).toMatch(/text-align: start;/); - }); -}); - -describe('icon buttons in the room view', () => { - // The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size - // and whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. - const expected: Record = { - 'features/room/message/Message.tsx': [ - 'PhoneHitSpread32', // quick-reaction emoji buttons - 'PhoneHitSpread32', // delete dialog close - 'PhoneHitSpread32', // report dialog close - ...Array(6).fill('PhoneHitRoom26'), // message options bar, inside a clipping menu - ], - 'features/room/RoomInput.tsx': ['PhoneHitSpread24', ...Array(5).fill('PhoneHitSpread32')], // reply cancel is a 16 px icon, the rest 24 px - 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), - // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. - 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji - 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread24', 'PhoneHitSpread24'], // chat toggle, options: 16 px icon + 8 padding = 24 px, 0 gap - 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top - 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'], - 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHitSpread26', 'PhoneHit'], // unpin (26 px, 8 px from the Open chip), close - 'features/room/RoomViewTyping.tsx': ['PhoneHit'], - 'features/room/CallChatView.tsx': ['PhoneHit'], - 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'], - // Buttons that were fixed with #125 and had no test. - 'components/password-input/PasswordInput.tsx': ['PhoneHit'], - 'features/desk/Desk.tsx': ['PhoneHit'], - 'features/scene/SkyStateControl.tsx': ['PhoneHit'], - 'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'], - }; - - // IconButtons whose drawn size the source does not say (file -> index in source order -> why). Everything else - // is derived from the size props, so a Spread32 on a 24 px button fails. - const sizeAllow: SizeAllow = { - 'features/room/message/Message.tsx': { - 0: 'quick-reaction button wraps an emoji , no Icon; 32 px by its CSS class', - }, - }; - - it.each(Object.entries(expected))( - '%s: each IconButton carries its own PhoneHit class', - (file, want) => expectPhoneHitClasses(file, want) - ); - - it.each(Object.keys(expected))('%s: each sized variant equals the px its button draws', (file) => - expectSizeTie(file, sizeAllow) - ); -}); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 2398a4c9..961131d9 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -22,7 +22,6 @@ import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; import * as css from '../styles/CustomHtml.css'; -import { PhoneHitSpread24 } from '../styles/PhoneHit.css'; import { getMxIdLocalPart, getCanonicalAliasRoomId, @@ -283,7 +282,6 @@ export function CodeBlock({ variant="SurfaceVariant" outlined radii="300" - className={PhoneHitSpread24} onClick={toggleExpand} aria-label={expanded ? 'Collapse' : 'Expand'} > diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts deleted file mode 100644 index f5a0f35b..00000000 --- a/src/app/state/room/composerStore.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Descendant } from 'slate'; -import { - PAGE_ID, - clearDraft, - enqueueOutbox, - failOutbox, - loadDraft, - loadOutbox, - removeOutbox, - retryOutbox, - saveDraft, - subscribeOutbox, -} from './composerStore'; - -const memoryStore = () => { - const m = new Map(); - return { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - size: () => m.size, - }; -}; - -const para = (text: string): Descendant[] => [{ type: 'paragraph', children: [{ text }] } as never]; - -describe('draft', () => { - it('survives a reload: a new read of the same store returns what input wrote', () => { - const store = memoryStore(); - saveDraft('@a:x', '!r:x', para('half a thought'), store); - expect(loadDraft('@a:x', '!r:x', store)).toEqual(para('half a thought')); - }); - - it('is keyed by account and room', () => { - const store = memoryStore(); - saveDraft('@a:x', '!r:x', para('one'), store); - expect(loadDraft('@b:x', '!r:x', store)).toEqual([]); - expect(loadDraft('@a:x', '!other:x', store)).toEqual([]); - }); - - it('emptying the editor removes the stored draft', () => { - const store = memoryStore(); - saveDraft('@a:x', '!r:x', para('x'), store); - saveDraft('@a:x', '!r:x', para(''), store); - expect(store.size()).toBe(0); - }); - - it('clearDraft removes it; corrupt storage reads as no draft', () => { - const store = memoryStore(); - saveDraft('@a:x', '!r:x', para('x'), store); - clearDraft('@a:x', '!r:x', store); - expect(loadDraft('@a:x', '!r:x', store)).toEqual([]); - store.setItem('ratatoskr.draft.@a:x.!r:x', '{not json'); - expect(loadDraft('@a:x', '!r:x', store)).toEqual([]); - }); - - it('a throwing store (quota) does not throw into the composer', () => { - const store = { - getItem: () => null, - setItem: () => { - throw new Error('quota'); - }, - removeItem: () => undefined, - }; - expect(() => saveDraft('@a:x', '!r:x', para('x'), store)).not.toThrow(); - }); -}); - -describe('outbox', () => { - const entry = { txnId: 't1', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' } }; - - it('keeps a sent message until it is confirmed, across a reload', () => { - const store = memoryStore(); - enqueueOutbox('@a:x', entry, 5, store); - const after = loadOutbox('@a:x', store); - expect(after).toEqual([{ ...entry, status: 'pending', ts: 5, tab: PAGE_ID }]); - removeOutbox('@a:x', 't1', store); - expect(loadOutbox('@a:x', store)).toEqual([]); - expect(store.size()).toBe(0); - }); - - it('a failed send stays, marked failed with its error; retry returns it to pending', () => { - const store = memoryStore(); - enqueueOutbox('@a:x', entry, 5, store); - failOutbox('@a:x', 't1', 'offline', store); - expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'failed', error: 'offline' }); - retryOutbox('@a:x', 't1', 9, 't1', store); - expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'pending', txnId: 't1' }); - expect(loadOutbox('@a:x', store)[0].error).toBeUndefined(); - }); - - it('a resend can rekey the entry and restarts its clock', () => { - const store = memoryStore(); - enqueueOutbox('@a:x', entry, 5, store); - failOutbox('@a:x', 't1', 'offline', store); - retryOutbox('@a:x', 't1', 50, 't2', store); - expect(loadOutbox('@a:x', store)).toMatchObject([{ txnId: 't2', status: 'pending', ts: 50 }]); - }); - - it('re-enqueueing the same txn id does not duplicate it; other accounts are separate', () => { - const store = memoryStore(); - enqueueOutbox('@a:x', entry, 5, store); - enqueueOutbox('@a:x', entry, 6, store); - expect(loadOutbox('@a:x', store)).toHaveLength(1); - expect(loadOutbox('@b:x', store)).toEqual([]); - }); - - it('notifies subscribers on every change', () => { - const store = memoryStore(); - let n = 0; - const off = subscribeOutbox(() => { - n += 1; - }); - enqueueOutbox('@a:x', entry, 5, store); - removeOutbox('@a:x', 't1', store); - off(); - enqueueOutbox('@a:x', entry, 5, store); - expect(n).toBe(2); - }); - - it('when storage refuses the write, memory keeps the entry so a failed send still shows', () => { - const broken = { - getItem: () => null, - setItem: () => { - throw new Error('quota'); - }, - removeItem: () => undefined, - }; - enqueueOutbox('@q:x', entry, 5, broken); - failOutbox('@q:x', 't1', 'offline', broken); - expect(loadOutbox('@q:x', broken)).toMatchObject([{ txnId: 't1', status: 'failed' }]); - removeOutbox('@q:x', 't1', broken); - expect(loadOutbox('@q:x', broken)).toEqual([]); - }); -}); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts deleted file mode 100644 index 91933d72..00000000 --- a/src/app/state/room/composerStore.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { Descendant } from 'slate'; - -/** - * Durable composer state. The in-memory jotai atoms in roomInputDrafts.ts are a cache of this; - * a page reload keeps only what was written here. - * - * Draft: the unsent editor contents of one room, rewritten on input and removed when emptied. - * Outbox: a sent message stays here, with its txn id, until the server confirms it. A reload - * mid-send therefore leaves it behind to be shown and resent; resending under the same txn id - * makes the server deduplicate if the first attempt did land. - */ - -export type OutboxStatus = 'pending' | 'failed'; - -export type OutboxEntry = { - txnId: string; - roomId: string; - /** Event type; absent means m.room.message. */ - type?: string; - content: Record; - status: OutboxStatus; - error?: string; - ts: number; - /** The page that last sent it: only that page's watchdog may judge it stalled. */ - tab?: string; -}; - -export const PAGE_ID = Math.random().toString(36).slice(2); - -type Store = Pick; - -const defaultStore = (): Store | undefined => { - try { - return localStorage; - } catch { - return undefined; - } -}; - -const draftKey = (userId: string, roomId: string) => `ratatoskr.draft.${userId}.${roomId}`; -const outboxKey = (userId: string) => `ratatoskr.outbox.${userId}`; - -const readJson = (store: Store | undefined, key: string): T | undefined => { - try { - const raw = store?.getItem(key); - if (raw === null || raw === undefined) return undefined; - return JSON.parse(raw) as T; - } catch { - return undefined; - } -}; - -const writeJson = (store: Store | undefined, key: string, value: unknown): boolean => { - try { - if (!store) return false; - store.setItem(key, JSON.stringify(value)); - return true; - } catch { - // quota or private mode: the in-memory atom still holds the draft for this session - return false; - } -}; - -const removeKey = (store: Store | undefined, key: string): void => { - try { - store?.removeItem(key); - } catch { - // nothing durable to remove - } -}; - -const isEmptyDraft = (draft: Descendant[]): boolean => - draft.length === 0 || - (draft.length === 1 && - 'children' in draft[0] && - draft[0].children.length === 1 && - 'text' in draft[0].children[0] && - draft[0].children[0].text === ''); - -export const loadDraft = ( - userId: string, - roomId: string, - store: Store | undefined = defaultStore() -): Descendant[] => { - const draft = readJson(store, draftKey(userId, roomId)); - return Array.isArray(draft) ? draft : []; -}; - -export const saveDraft = ( - userId: string, - roomId: string, - draft: Descendant[], - store: Store | undefined = defaultStore() -): void => { - if (isEmptyDraft(draft)) { - removeKey(store, draftKey(userId, roomId)); - return; - } - if (!writeJson(store, draftKey(userId, roomId), draft)) - removeKey(store, draftKey(userId, roomId)); -}; - -export const clearDraft = ( - userId: string, - roomId: string, - store: Store | undefined = defaultStore() -): void => { - removeKey(store, draftKey(userId, roomId)); -}; - -/** Outbox contents whose last write to storage failed: memory is then the only copy, so it wins. */ -const unpersisted = new Map(); - -export const loadOutbox = ( - userId: string, - store: Store | undefined = defaultStore() -): OutboxEntry[] => { - const held = unpersisted.get(userId); - if (held) return held; - const entries = readJson(store, outboxKey(userId)); - return Array.isArray(entries) ? entries : []; -}; - -const outboxListeners = new Set<() => void>(); -export const subscribeOutbox = (listener: () => void): (() => void) => { - outboxListeners.add(listener); - return () => outboxListeners.delete(listener); -}; - -const saveOutbox = (userId: string, entries: OutboxEntry[], store: Store | undefined) => { - const ok = - entries.length === 0 - ? (removeKey(store, outboxKey(userId)), true) - : writeJson(store, outboxKey(userId), entries); - if (ok) unpersisted.delete(userId); - else unpersisted.set(userId, entries); - outboxListeners.forEach((l) => l()); -}; - -export const enqueueOutbox = ( - userId: string, - entry: Omit, - now: number = Date.now(), - store: Store | undefined = defaultStore() -): void => { - const rest = loadOutbox(userId, store).filter((e) => e.txnId !== entry.txnId); - saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now, tab: PAGE_ID }], store); -}; - -export const failOutbox = ( - userId: string, - txnId: string, - error: string, - store: Store | undefined = defaultStore() -): void => { - saveOutbox( - userId, - loadOutbox(userId, store).map((e) => - e.txnId === txnId ? { ...e, status: 'failed', error } : e - ), - store - ); -}; - -/** - * A resend begins: the entry is pending again from `now`, under `newTxnId` when the SDK still - * holds the failed attempt's echo under the old one (it refuses to add a second echo for a txn id). - */ -export const retryOutbox = ( - userId: string, - txnId: string, - now: number = Date.now(), - newTxnId: string = txnId, - store: Store | undefined = defaultStore() -): void => { - saveOutbox( - userId, - loadOutbox(userId, store).map((e) => - e.txnId === txnId - ? { ...e, txnId: newTxnId, status: 'pending', error: undefined, ts: now, tab: PAGE_ID } - : e - ), - store - ); -}; - -/** Server confirmed the send, or the user discarded it. */ -export const removeOutbox = ( - userId: string, - txnId: string, - store: Store | undefined = defaultStore() -): void => { - saveOutbox( - userId, - loadOutbox(userId, store).filter((e) => e.txnId !== txnId), - store - ); -}; diff --git a/src/app/state/room/outboxSend.test.ts b/src/app/state/room/outboxSend.test.ts deleted file mode 100644 index 8f1db8fd..00000000 --- a/src/app/state/room/outboxSend.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { enqueueOutbox, failOutbox, loadOutbox } from './composerStore'; -import { resendInterrupted, resendTracked, sendTracked } from './outboxSend'; - -const stubLocalStorage = () => { - const m = new Map(); - vi.stubGlobal('localStorage', { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - }); -}; - -const fakeClient = (send: (...a: unknown[]) => Promise) => - ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendEvent: send } as never); - -describe('sendTracked', () => { - beforeEach(stubLocalStorage); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - const content = { msgtype: 'm.text', body: 'typed before reload' }; - - it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => { - let finish!: () => void; - const mx = fakeClient( - () => - new Promise((r) => { - finish = () => r({ event_id: '$e' }); - }) - ); - const done = sendTracked(mx, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]); - finish(); - await done; - expect(loadOutbox('@a:x')).toEqual([]); - }); - - it('a failed send stays, marked failed, and is not cleared', async () => { - const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET'))); - await sendTracked(mx, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([ - { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content }, - ]); - }); - - it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => { - const send = vi.fn().mockResolvedValue({}); - await sendTracked(fakeClient(send), '!r:x', content); - expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1'); - }); -}); - -describe('resendTracked', () => { - beforeEach(stubLocalStorage); - afterEach(() => { - vi.unstubAllGlobals(); - }); - const entry = { - txnId: 'txn-old', - roomId: '!r:x', - content: { msgtype: 'm.text', body: 'b' }, - status: 'failed' as const, - ts: 1, - }; - - it('uses a fresh txn id while the SDK still holds the failed echo (it would throw on the old one)', async () => { - const send = vi.fn().mockResolvedValue({}); - const mx = { - getUserId: () => '@a:x', - makeTxnId: () => 'txn-new', - sendEvent: send, - getRoom: () => ({ getEventForTxnId: () => ({}) }), - } as never; - await resendTracked(mx, entry); - expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', entry.content, 'txn-new'); - }); - - it('reuses the txn id after a reload, when no echo exists, so the server deduplicates', async () => { - const send = vi.fn().mockResolvedValue({}); - const mx = { - getUserId: () => '@a:x', - makeTxnId: () => 'txn-new', - sendEvent: send, - getRoom: () => ({ getEventForTxnId: () => undefined }), - } as never; - await resendTracked(mx, entry); - expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', entry.content, 'txn-old'); - }); - - it('a synchronous throw from the SDK marks the entry failed instead of escaping', async () => { - const mx = { - getUserId: () => '@a:x', - makeTxnId: () => 'txn-new', - sendEvent: () => { - throw new Error('addPendingEvent called on an event with known txnId'); - }, - getRoom: () => undefined, - } as never; - await sendTracked(mx, '!r:x', { body: 'x' }); - expect(loadOutbox('@a:x')[0]).toMatchObject({ status: 'failed' }); - }); -}); - -describe('resendInterrupted', () => { - beforeEach(stubLocalStorage); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('resends pending entries older than the page under their own txn id, and leaves failed ones', async () => { - const send = vi.fn().mockResolvedValue({}); - const mx = { - getUserId: () => '@a:x', - makeTxnId: () => 'fresh', - sendEvent: send, - getRoom: () => undefined, - } as never; - enqueueOutbox('@a:x', { txnId: 'old', roomId: '!r:x', content: { body: 'o' } }, 5); - enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'b' } }, 5); - failOutbox('@a:x', 'bad', 'nope'); - enqueueOutbox('@a:x', { txnId: 'new', roomId: '!r:x', content: { body: 'n' } }, 500); - await resendInterrupted(mx, 100); - expect(send).toHaveBeenCalledTimes(1); - expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', { body: 'o' }, 'old'); - }); -}); diff --git a/src/app/state/room/outboxSend.ts b/src/app/state/room/outboxSend.ts deleted file mode 100644 index 845e64a0..00000000 --- a/src/app/state/room/outboxSend.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { MatrixClient } from 'matrix-js-sdk'; -import { - OutboxEntry, - enqueueOutbox, - failOutbox, - loadOutbox, - removeOutbox, - retryOutbox, -} from './composerStore'; - -/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */ -export const PAGE_START = Date.now(); - -const attempt = async (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => { - try { - await mx.sendEvent( - entry.roomId, - (entry.type ?? 'm.room.message') as never, - entry.content as never, - entry.txnId - ); - removeOutbox(userId, entry.txnId); - } catch (e) { - // sendEvent can also throw synchronously (e.g. a txn id the SDK already holds) - failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e)); - } -}; - -/** - * Send, keeping the message in the durable outbox until the server confirms it. The timeline - * hides the SDK's local echo until then, so this is the only place an unconfirmed send shows. - */ -export const sendTracked = ( - mx: MatrixClient, - roomId: string, - content: Record, - type?: string -): Promise => { - const userId = mx.getUserId() ?? ''; - const entry = { roomId, content, txnId: mx.makeTxnId(), ...(type ? { type } : {}) }; - enqueueOutbox(userId, entry); - return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() }); -}; - -/** - * Resend. An echo still held by the SDK under the old txn id (a failure in this session) makes - * it refuse the same id, so that attempt is abandoned under a fresh id. After a reload the echo - * is gone and the old id is reused, so a send that did land is deduplicated by the server. - */ -export const resendTracked = (mx: MatrixClient, entry: OutboxEntry): Promise => { - const userId = mx.getUserId() ?? ''; - const held = mx.getRoom(entry.roomId)?.getEventForTxnId(entry.txnId); - const txnId = held ? mx.makeTxnId() : entry.txnId; - retryOutbox(userId, entry.txnId, Date.now(), txnId); - return attempt(mx, userId, { ...entry, txnId, status: 'pending', ts: Date.now() }); -}; - -/** - * After a reload, send again every entry the reload interrupted. They have no SDK echo, so each - * keeps its txn id and the server deduplicates one that did land. Failed entries are left for - * the user: they failed with an answer, these are only unknown. - */ -export const resendInterrupted = ( - mx: MatrixClient, - since: number = PAGE_START -): Promise => { - const userId = mx.getUserId() ?? ''; - return Promise.all( - loadOutbox(userId) - .filter((e) => e.status === 'pending' && e.ts < since) - .map((e) => resendTracked(mx, e)) - ); -}; diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index f3bf9e1c..5d8ec8c7 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -20,7 +20,9 @@ export type TUploadItem = { export type TUploadListAtom = ReturnType>; -export const roomIdToUploadItemsAtomFamily = atomFamily(createListAtom); +export const roomIdToUploadItemsAtomFamily = atomFamily( + createListAtom +); export const roomUploadAtomFamily = createUploadAtomFamily(); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts deleted file mode 100644 index e971ecb3..00000000 --- a/src/app/styles/PhoneHit.css.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { style } from '@vanilla-extract/css'; -import { MOBILE_BREAKPOINT } from '../hooks/useScreenSize'; - -// Phone tap target for an icon button: on a phone the button's hit area is 44 x 44 px centred on the drawn -// button (a ::after the button's own click reaches), so nothing drawn changes size. A button with neighbours -// 44 px closer than that would take their taps, so the `Spread` classes also give it horizontal margin of half -// the shortfall: the row grows by that, on phones only, and the hit areas meet instead of overlapping. -// Vertical room is cheaper than horizontal on a phone, so only the horizontal axis pays. -export const PHONE_TAP = 44; -const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; - -const hitArea = { - '@media': { - [PHONE]: { - content: '""', - position: 'absolute', - top: '50%', - left: '50%', - width: `max(100%, ${PHONE_TAP}px)`, - height: `max(100%, ${PHONE_TAP}px)`, - transform: 'translate(-50%, -50%)', - }, - }, -} as const; - -/** An icon button on its own: the hit area grows, nothing else moves. */ -export const PhoneHit = style({ - '@media': { [PHONE]: { position: 'relative' } }, - selectors: { '&::after': hitArea }, -}); - -/** For a button that is already positioned (absolute or fixed) by its own class: PhoneHit's `position: relative` - * would fight that class by stylesheet order, and the positioned button already contains its ::after. */ -export const PhoneHitInPlace = style({ selectors: { '&::after': hitArea } }); - -const spreads = new Map(); - -/** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ -const spread = (visual: number) => { - const cls = style( - [PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }], - `PhoneHitSpread${visual}` - ); - spreads.set(visual, cls); - return cls; -}; - -export const PhoneHitSpread24 = spread(24); -export const PhoneHitSpread26 = spread(26); -export const PhoneHitSpread28 = spread(28); -export const PhoneHitSpread32 = spread(32); -export const PhoneHitSpread36 = spread(36); -export const PhoneHitSpread40 = spread(40); - -/** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row - * opens up on both axes, so the 44 px sits inside the container. It is the Spread class of the same size plus the - * block margin, so the two axes cannot disagree about the size. */ -const room = (visual: number) => { - const spreadClass = spreads.get(visual); - if (!spreadClass) throw new Error(`PhoneHitRoom${visual} needs a PhoneHitSpread${visual}`); - return style( - [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], - `PhoneHitRoom${visual}` - ); -}; - -export const PhoneHitRoom26 = room(26); -export const PhoneHitRoom36 = room(36); diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts deleted file mode 100644 index 95a8d966..00000000 --- a/src/client/initMatrix.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { PAGE_ID, enqueueOutbox, failOutbox } from '../app/state/room/composerStore'; -import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix'; - -const memory = () => { - const m = new Map(); - return { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - }; -}; - -describe('reloadToRecoverSync', () => { - it('reloads once, then refuses inside the gap so a down server cannot loop it', () => { - const store = memory(); - const reload = vi.fn(); - expect(reloadToRecoverSync(1_000_000, () => store, reload)).toBe(true); - expect( - reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, () => store, reload) - ).toBe(false); - expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, () => store, reload)).toBe( - true - ); - expect(reload).toHaveBeenCalledTimes(2); - }); -}); - -describe('reloadToRecoverSync with unusable storage', () => { - it('refuses instead of throwing when sessionStorage throws', () => { - const reload = vi.fn(); - const denied = () => { - throw new Error('SecurityError'); - }; - expect(reloadToRecoverSync(1, denied, reload)).toBe(false); - expect(reload).not.toHaveBeenCalled(); - }); -}); - -describe('ownPendingAges', () => { - it("counts only this page's pending sends, not other pages' or failed ones", () => { - const m = new Map(); - vi.stubGlobal('localStorage', { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - }); - const e = (txnId: string) => ({ txnId, roomId: '!r:x', content: {} }); - enqueueOutbox('@a:x', e('mine'), 1000); - enqueueOutbox('@a:x', e('failed'), 1000); - failOutbox('@a:x', 'failed', 'x'); - enqueueOutbox('@a:x', e('theirs'), 1000); - const entries = JSON.parse(m.get('ratatoskr.outbox.@a:x') as string); - entries.find((x: { txnId: string }) => x.txnId === 'theirs').tab = `${PAGE_ID}-other`; - m.set('ratatoskr.outbox.@a:x', JSON.stringify(entries)); - expect(ownPendingAges('@a:x', 1500)).toEqual([500]); - vi.unstubAllGlobals(); - }); -}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 871c186a..871a7625 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -1,19 +1,9 @@ -import { - ClientEvent, - createClient, - MatrixClient, - IndexedDBStore, - IndexedDBCryptoStore, - SyncState, -} from 'matrix-js-sdk'; +import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk'; import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; import { startResumeSync } from './resumeSync'; -import { startWatchdog } from './watchdog'; -import { PAGE_ID, loadOutbox } from '../app/state/room/composerStore'; -import { resendInterrupted } from '../app/state/room/outboxSend'; type Session = { baseUrl: string; @@ -56,54 +46,11 @@ const startSync = (mx: MatrixClient) => lazyLoadMembers: true, }); -const RECOVER_RELOAD_KEY = 'ratatoskr.recoverReloadAt'; -export const RECOVER_RELOAD_MIN_GAP_MS = 60_000; - -/** Reload to bring a dead connection back, at most once a minute, so a server that is down cannot loop us. */ -export const reloadToRecoverSync = ( - now: number = Date.now(), - getStore: () => Pick = () => sessionStorage, - reload: () => void = () => window.location.reload() -): boolean => { - try { - const store = getStore(); - const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0); - if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false; - store.setItem(RECOVER_RELOAD_KEY, String(now)); - } catch { - return false; - } - reload(); - return true; -}; - -/** Ages in ms of the sends this page made that are still pending: the only ones it may judge stalled. */ -export const ownPendingAges = (userId: string, now: number = Date.now()): number[] => - loadOutbox(userId) - .filter((e) => e.status === 'pending' && e.tab === PAGE_ID) - .map((e) => now - e.ts); - -const started = new WeakSet(); - export const startClient = async (mx: MatrixClient) => { await startSync(mx); - if (started.has(mx)) return; - started.add(mx); - startResumeSync(mx); - startWatchdog({ - pendingAges: () => ownPendingAges(mx.getUserId() ?? ''), - isVisible: () => document.visibilityState === 'visible', - isOnline: () => navigator.onLine, - recover: () => reloadToRecoverSync(), - }); - // What a reload interrupted is sent again once sync is up. - const onSync = (state: SyncState) => { - if (state !== SyncState.Prepared && state !== SyncState.Syncing) return; - mx.off(ClientEvent.Sync, onSync); - resendInterrupted(mx); - }; - mx.on(ClientEvent.Sync, onSync); + startResumeSync(mx, { restart: startSync }); }; + export const clearCacheAndReload = async (mx: MatrixClient) => { mx.stopClient(); clearNavToActivePathStore(mx.getSafeUserId()); diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts index e90ac6a8..eada97af 100644 --- a/src/client/resumeSync.test.ts +++ b/src/client/resumeSync.test.ts @@ -1,15 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; +import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; class Target extends EventTarget { visibilityState: 'visible' | 'hidden' = 'visible'; } -const setup = () => { +const setup = (state: string | null = 'SYNCING') => { const doc = new Target(); const win = new Target(); - const mx = { retryImmediately: vi.fn(() => true) }; - const stop = startResumeSync(mx, { doc: doc as never, win: win as never }); + const mx = { + state, + getSyncState: () => mx.state, + retryImmediately: vi.fn(() => true), + stopClient: vi.fn(), + }; + const restart = vi.fn(async () => {}); + const stop = startResumeSync(mx, { + restart, + doc: doc as never, + win: win as never, + }); const hide = () => { doc.visibilityState = 'hidden'; doc.dispatchEvent(new Event('visibilitychange')); @@ -18,7 +28,7 @@ const setup = () => { doc.visibilityState = 'visible'; doc.dispatchEvent(new Event('visibilitychange')); }; - return { doc, win, mx, stop, hide, show }; + return { doc, win, mx, restart, stop, hide, show }; }; const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); @@ -46,7 +56,7 @@ describe('startResumeSync', () => { t.hide(); await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1); t.show(); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); expect(t.mx.retryImmediately).not.toHaveBeenCalled(); }); @@ -58,6 +68,7 @@ describe('startResumeSync', () => { t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); await settle(); expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); t.win.dispatchEvent(new Event('online')); await settle(); expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); @@ -72,14 +83,50 @@ describe('startResumeSync', () => { expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); }); + it('does not restart when sync is healthy after the check window', async () => { + for (const state of ['PREPARED', 'SYNCING', 'CATCHUP']) { + const t = setup(state); + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.restart).not.toHaveBeenCalled(); + t.stop(); + } + }); + + it('does one stop/start when sync is still unhealthy, never overlapping', async () => { + const t = setup('RECONNECTING'); + let release!: () => void; + t.restart.mockImplementation( + () => + new Promise((r) => { + release = r; + }) + ); + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.stopClient).toHaveBeenCalledTimes(1); + expect(t.restart).toHaveBeenCalledTimes(1); + // a trigger while the restart is in flight does nothing + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + expect(t.restart).toHaveBeenCalledTimes(1); + release(); + await vi.advanceTimersByTimeAsync(0); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); + }); + it('removes listeners and cancels pending work on dispose', async () => { - const t = setup(); + const t = setup('ERROR'); t.win.dispatchEvent(new Event('online')); + await settle(); t.stop(); - await settle(); - expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); + expect(t.restart).not.toHaveBeenCalled(); t.win.dispatchEvent(new Event('online')); await settle(); - expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); }); }); diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts index a09f39f8..5f81547b 100644 --- a/src/client/resumeSync.ts +++ b/src/client/resumeSync.ts @@ -1,38 +1,60 @@ /** The slice of MatrixClient this module drives; a MatrixClient satisfies it. */ export type SyncHost = { + getSyncState(): string | null; retryImmediately(): boolean; + stopClient(): void; }; +// matrix-js-sdk SyncState values for a live sync loop. +const HEALTHY: ReadonlySet = new Set(['PREPARED', 'SYNCING', 'CATCHUP']); + export const RESUME_DEBOUNCE_MS = 250; export const MIN_HIDDEN_MS = 5000; +export const HEALTH_CHECK_MS = 3000; -type ResumeSyncOptions = { +type ResumeSyncOptions = { /** Event sources and visibility; default to the browser globals. */ doc?: Pick; win?: Pick; + /** Restart the client after stopClient; must resolve once startClient has been issued. */ + restart: (mx: Host) => Promise; }; /** * A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout. * On return (visible after a real absence, pageshow from bfcache, online) nudge the - * poll with retryImmediately. It never stops and restarts the client: stopClient() closes - * the rust crypto's OlmMachine for good and startClient() does not reopen it. A connection - * that stays dead is the watchdog's business. Returns a disposer that removes every - * listener and timer. + * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, do one + * stop/start. Returns a disposer that removes every listener and timer. */ -export const startResumeSync = ( - mx: SyncHost, - { doc = document, win = window }: ResumeSyncOptions = {} +export const startResumeSync = ( + mx: Host, + { restart, doc = document, win = window }: ResumeSyncOptions ): (() => void) => { let hiddenAt: number | undefined; let debounce: ReturnType | undefined; + let healthCheck: ReturnType | undefined; + let restarting = false; let disposed = false; + const check = async () => { + healthCheck = undefined; + if (disposed || restarting || HEALTHY.has(mx.getSyncState())) return; + restarting = true; + try { + mx.stopClient(); + await restart(mx); + } finally { + restarting = false; + } + }; + const resume = () => { if (debounce !== undefined) clearTimeout(debounce); debounce = setTimeout(() => { debounce = undefined; - if (!disposed) mx.retryImmediately(); + if (disposed || restarting || healthCheck !== undefined) return; + mx.retryImmediately(); + healthCheck = setTimeout(check, HEALTH_CHECK_MS); }, RESUME_DEBOUNCE_MS); }; @@ -56,6 +78,7 @@ export const startResumeSync = ( return () => { disposed = true; if (debounce !== undefined) clearTimeout(debounce); + if (healthCheck !== undefined) clearTimeout(healthCheck); doc.removeEventListener('visibilitychange', onVisibility); win.removeEventListener('pageshow', onPageShow); win.removeEventListener('online', resume); diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts deleted file mode 100644 index ae1e5217..00000000 --- a/src/client/stallRecovery.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { PAGE_ID, loadOutbox } from '../app/state/room/composerStore'; -import { resendInterrupted, sendTracked } from '../app/state/room/outboxSend'; -import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix'; -import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; - -// The dead-connection story from the user's side: a send hangs, the page notices and reloads -// itself once, and the message goes out again from the outbox under its own txn id. -const memoryStore = (m = new Map()) => ({ - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, -}); - -const OUTBOX_KEY = 'ratatoskr.outbox.@a:x'; - -describe('a send that hangs on a dead connection', () => { - let storage: Map; - beforeEach(() => { - vi.useFakeTimers(); - storage = new Map(); - vi.stubGlobal('localStorage', memoryStore(storage)); - }); - afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - // Every send gets its own txn id, as the SDK's makeTxnId gives; a client that hands out one - // constant id could not tell a resend of the right message from a resend of the wrong one. - const hungClient = () => { - let n = 0; - return { - getUserId: () => '@a:x', - makeTxnId: () => { - n += 1; - return `txn-${n}`; - }, - sendEvent: vi.fn( - () => - new Promise(() => { - // never settles: the request is hung - }) - ), - getRoom: () => undefined, - }; - }; - const watch = (env: { visible: boolean; online: boolean }) => { - const reload = vi.fn(); - const session = memoryStore(); - const stop = startWatchdog({ - pendingAges: () => ownPendingAges('@a:x'), - isVisible: () => env.visible, - isOnline: () => env.online, - recover: () => reloadToRecoverSync(Date.now(), () => session, reload), - }); - return { reload, stop }; - }; - const stalled = () => vi.advanceTimersByTime(SEND_STALL_MS + 2 * WATCHDOG_INTERVAL_MS); - - it('is kept in the outbox, reloads the page once after the stall, and each message is resent under its own txn id', async () => { - const mx = hungClient(); - const first = { msgtype: 'm.text', body: 'hello from a dead connection' }; - const second = { msgtype: 'm.text', body: 'and a second one' }; - const { reload, stop } = watch({ visible: true, online: true }); - - sendTracked(mx as never, '!r:x', first); - sendTracked(mx as never, '!q:x', second); - expect(loadOutbox('@a:x')).toMatchObject([ - { txnId: 'txn-1', status: 'pending' }, - { txnId: 'txn-2', status: 'pending' }, - ]); - - vi.advanceTimersByTime(SEND_STALL_MS - WATCHDOG_INTERVAL_MS); - expect(reload).not.toHaveBeenCalled(); - vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS); - expect(reload).toHaveBeenCalledTimes(1); - // the stall persists across the reload guard window: no reload loop - vi.advanceTimersByTime(RECOVER_RELOAD_MIN_GAP_MS - 2 * WATCHDOG_INTERVAL_MS); - expect(reload).toHaveBeenCalledTimes(1); - stop(); - - // the reloaded page: the entries are older than the page, the network works again - const send = vi.fn().mockResolvedValue({ event_id: '$e' }); - const reloaded = { ...mx, sendEvent: send }; - await resendInterrupted(reloaded as never, Date.now() + 1); - expect(send).toHaveBeenCalledTimes(2); - expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', first, 'txn-1'); - expect(send).toHaveBeenCalledWith('!q:x', 'm.room.message', second, 'txn-2'); - expect(loadOutbox('@a:x')).toEqual([]); - }); - - it('resends under a fresh txn id when the SDK still holds an echo for the old one', async () => { - const mx = hungClient(); - const content = { msgtype: 'm.text', body: 'echo still held' }; - sendTracked(mx as never, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1' }]); - - const send = vi.fn().mockResolvedValue({ event_id: '$e' }); - const held = { - ...mx, - makeTxnId: () => 'txn-fresh', - sendEvent: send, - getRoom: () => ({ getEventForTxnId: (id: string) => (id === 'txn-1' ? {} : undefined) }), - }; - await resendInterrupted(held as never, Date.now() + 1); - expect(send).toHaveBeenCalledTimes(1); - expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-fresh'); - expect(loadOutbox('@a:x')).toEqual([]); - }); - - it.each([ - ['hidden', { visible: false, online: true }], - ['offline', { visible: true, online: false }], - ])('does not reload a stalled page while it is %s, and does once it is back', (_name, env) => { - const mx = hungClient(); - const state = { ...env }; - const { reload, stop } = watch(state); - sendTracked(mx as never, '!r:x', { body: 'x' }); - stalled(); - vi.advanceTimersByTime(10 * SEND_STALL_MS); - expect(reload).not.toHaveBeenCalled(); - state.visible = true; - state.online = true; - vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS); - expect(reload).toHaveBeenCalledTimes(1); - stop(); - }); - - it('does not reload for a send that belongs to another tab, or one that already failed', () => { - const mx = hungClient(); - const { reload, stop } = watch({ visible: true, online: true }); - sendTracked(mx as never, '!r:x', { body: 'theirs' }); - sendTracked(mx as never, '!r:x', { body: 'failed' }); - const entries = JSON.parse(storage.get(OUTBOX_KEY) as string); - entries[0].tab = `${PAGE_ID}-other`; - entries[1].status = 'failed'; - storage.set(OUTBOX_KEY, JSON.stringify(entries)); - stalled(); - vi.advanceTimersByTime(10 * SEND_STALL_MS); - expect(reload).not.toHaveBeenCalled(); - stop(); - }); -}); diff --git a/src/client/startClient.test.ts b/src/client/startClient.test.ts deleted file mode 100644 index 11b488e3..00000000 --- a/src/client/startClient.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { enqueueOutbox } from '../app/state/room/composerStore'; -import { PAGE_START } from '../app/state/room/outboxSend'; -import { startClient } from './initMatrix'; -import { startResumeSync } from './resumeSync'; -import { startWatchdog } from './watchdog'; - -vi.mock('./resumeSync', () => ({ startResumeSync: vi.fn() })); -vi.mock('./watchdog', async (importOriginal) => ({ - ...(await importOriginal()), - startWatchdog: vi.fn(() => () => undefined), -})); - -const memoryStore = (m = new Map()) => ({ - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, -}); - -// startClient is the one place the dead-connection pieces are wired to the page: what the watchdog reads, -// when it may fire, what it does, and what runs after the reload. These tests call it and exercise that wiring. -describe('startClient wiring', () => { - let handlers: Array<(state: string) => void>; - let mx: Record & { - sendEvent: ReturnType; - off: ReturnType; - }; - beforeEach(() => { - vi.clearAllMocks(); - handlers = []; - vi.stubGlobal('localStorage', memoryStore()); - mx = { - startClient: vi.fn().mockResolvedValue(undefined), - getUserId: () => '@a:x', - makeTxnId: () => 'txn-new', - getRoom: () => undefined, - sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }), - on: (_ev: string, h: (state: string) => void) => { - handlers.push(h); - }, - off: vi.fn(), - }; - }); - afterEach(() => { - vi.unstubAllGlobals(); - }); - - const watchdogOptions = () => - (startWatchdog as unknown as ReturnType).mock.calls[0][0] as Parameters< - typeof startWatchdog - >[0]; - - it('starts sync, the resume nudge and the watchdog once per client', async () => { - await startClient(mx as never); - await startClient(mx as never); - expect(mx.startClient).toHaveBeenCalledTimes(2); - expect(startResumeSync).toHaveBeenCalledTimes(1); - expect(startResumeSync).toHaveBeenCalledWith(mx); - expect(startWatchdog).toHaveBeenCalledTimes(1); - }); - - it("feeds the watchdog this page's pending sends for this user", async () => { - await startClient(mx as never); - const { pendingAges } = watchdogOptions(); - expect(pendingAges()).toEqual([]); - enqueueOutbox('@a:x', { txnId: 't', roomId: '!r:x', content: {} }, Date.now() - 7000); - enqueueOutbox('@b:x', { txnId: 'u', roomId: '!r:x', content: {} }, Date.now() - 1000); - const ages = pendingAges(); - expect(ages).toHaveLength(1); - expect(ages[0]).toBeGreaterThanOrEqual(7000); - }); - - it('reads visibility and connectivity from the page', async () => { - const doc = { visibilityState: 'visible' }; - const nav = { onLine: true }; - vi.stubGlobal('document', doc); - vi.stubGlobal('navigator', nav); - await startClient(mx as never); - const { isVisible, isOnline } = watchdogOptions(); - expect([isVisible(), isOnline()]).toEqual([true, true]); - doc.visibilityState = 'hidden'; - nav.onLine = false; - expect([isVisible(), isOnline()]).toEqual([false, false]); - }); - - it('recovers by a guarded reload, at most once a minute', async () => { - const reload = vi.fn(); - vi.stubGlobal('window', { location: { reload } }); - vi.stubGlobal('sessionStorage', memoryStore()); - await startClient(mx as never); - const { recover } = watchdogOptions(); - recover(); - recover(); - expect(reload).toHaveBeenCalledTimes(1); - }); - - it('resends what a reload interrupted once sync is up, and only once', async () => { - enqueueOutbox( - '@a:x', - { txnId: 'old', roomId: '!r:x', content: { body: 'x' } }, - PAGE_START - 5000 - ); - await startClient(mx as never); - expect(handlers).toHaveLength(1); - handlers[0]('CONNECTING'); - expect(mx.sendEvent).not.toHaveBeenCalled(); - handlers[0]('SYNCING'); - expect(mx.off).toHaveBeenCalledWith('sync', handlers[0]); - expect(mx.sendEvent).toHaveBeenCalledWith('!r:x', 'm.room.message', { body: 'x' }, 'old'); - }); -}); diff --git a/src/client/watchdog.test.ts b/src/client/watchdog.test.ts deleted file mode 100644 index 7d80f099..00000000 --- a/src/client/watchdog.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; - -const setup = (over: Partial[0]> = {}) => { - const recover = vi.fn(); - const stop = startWatchdog({ - pendingAges: () => [], - isVisible: () => true, - isOnline: () => true, - recover, - ...over, - }); - return { recover, stop }; -}; -const tick = (n: number) => vi.advanceTimersByTime(WATCHDOG_INTERVAL_MS * n); - -describe('watchdog', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('stays quiet while nothing is pending', () => { - const t = setup(); - tick(100); - expect(t.recover).not.toHaveBeenCalled(); - t.stop(); - }); - - it('recovers when a send has been pending past the stall threshold, not before', () => { - let age = 0; - const t = setup({ pendingAges: () => [age] }); - age = SEND_STALL_MS - 1; - tick(1); - expect(t.recover).not.toHaveBeenCalled(); - age = SEND_STALL_MS; - tick(1); - expect(t.recover).toHaveBeenCalledTimes(1); - t.stop(); - }); - - it('does nothing while the page is hidden or offline', () => { - const hidden = setup({ isVisible: () => false, pendingAges: () => [1e9] }); - const offline = setup({ isOnline: () => false, pendingAges: () => [1e9] }); - tick(50); - expect(hidden.recover).not.toHaveBeenCalled(); - expect(offline.recover).not.toHaveBeenCalled(); - hidden.stop(); - offline.stop(); - }); - - it('stop() ends it', () => { - const t = setup({ pendingAges: () => [1e9] }); - t.stop(); - tick(10); - expect(t.recover).not.toHaveBeenCalled(); - }); -}); diff --git a/src/client/watchdog.ts b/src/client/watchdog.ts deleted file mode 100644 index 6b97e8fc..00000000 --- a/src/client/watchdog.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * The page can go on looking alive while its connection is dead: sync polls are cut by their - * own timeout and js-sdk retries them, but a send has no timeout at all, so on a half-open - * connection it hangs SENDING and no request reaches the server. A send pending past - * `stallMs` is the sign, and `recover` (a guarded reload) the answer; the outbox then sends - * again what the reload interrupted. Sync state is not watched: ERROR is also what offline - * looks like, and js-sdk recovers from that itself. - * Nothing fires while the page is hidden (a frozen tab is not a dead connection) or offline - * (a reload there only blanks the page). - */ -export type WatchdogOptions = { - /** Ages in ms of this page's sends still pending. */ - pendingAges: () => number[]; - isVisible: () => boolean; - isOnline: () => boolean; - recover: () => void; - intervalMs?: number; - stallMs?: number; -}; - -export const WATCHDOG_INTERVAL_MS = 5000; -export const SEND_STALL_MS = 20_000; - -export const startWatchdog = ({ - pendingAges, - isVisible, - isOnline, - recover, - intervalMs = WATCHDOG_INTERVAL_MS, - stallMs = SEND_STALL_MS, -}: WatchdogOptions): (() => void) => { - const id = setInterval(() => { - if (!isVisible() || !isOnline()) return; - if (pendingAges().some((age) => age >= stallMs)) recover(); - }, intervalMs); - return () => clearInterval(id); -}; diff --git a/src/sw.ts b/src/sw.ts index ce9878c9..69293b1d 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,14 +1,8 @@ /// -import { handleClick, handlePush, PushTarget, WorkerPort } from './app/features/push/worker'; -import { idbPort, sessionStore } from './app/features/push/sessionStore'; - export type {}; declare const self: ServiceWorkerGlobalScope; -// What a push can do with no tab open: the session is kept in IndexedDB (sessionStore.ts), not only in `sessions` below. -const persisted = sessionStore(idbPort(self.indexedDB)); - type SessionInfo = { accessToken: string; baseUrl: string; @@ -36,13 +30,6 @@ async function cleanupDeadClients() { } function setSession(clientId: string, accessToken: any, baseUrl: any) { - persisted - .saveIfSubscribed( - baseUrl, - accessToken, - async () => !!(await self.registration.pushManager.getSubscription()) - ) - .catch(() => undefined); if (typeof accessToken === 'string' && typeof baseUrl === 'string') { sessions.set(clientId, { accessToken, baseUrl }); } else { @@ -169,29 +156,3 @@ self.addEventListener('fetch', (event: FetchEvent) => { }) ); }); - -const workerPort: WorkerPort = { - windows: async () => [ - ...(await self.clients.matchAll({ type: 'window', includeUncontrolled: true })), - ], - openWindow: (url) => self.clients.openWindow(url), - show: (title, options) => self.registration.showNotification(title, options), - loadSession: () => persisted.load().catch(() => undefined), - fetch: (url, init) => fetch(url, init), - scope: self.registration.scope, -}; - -self.addEventListener('push', (event: PushEvent) => { - let raw: unknown; - try { - raw = event.data?.json(); - } catch { - raw = undefined; - } - event.waitUntil(handlePush(raw, workerPort)); -}); - -self.addEventListener('notificationclick', (event: NotificationEvent) => { - event.notification.close(); - event.waitUntil(handleClick(event.notification.data as PushTarget | undefined, workerPort)); -}); diff --git a/vite.config.js b/vite.config.js index 699c3148..e9b67550 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,7 +10,6 @@ import { VitePWA } from 'vite-plugin-pwa'; import fs from 'fs'; import path from 'path'; import buildConfig from './build.config'; -import { buildShaPlugin } from './scripts/buildSha'; const copyFiles = { targets: [ @@ -105,7 +104,6 @@ export default defineConfig({ }), viteStaticCopy(copyFiles), vanillaExtractPlugin(), - buildShaPlugin(), wasm(), react(), VitePWA({